-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.cpp
More file actions
4117 lines (3906 loc) · 196 KB
/
Copy pathengine.cpp
File metadata and controls
4117 lines (3906 loc) · 196 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "sub/engine.h"
#include "content/spells/casting.h" // kSpellCasterRadius — the player body radius's one home
#include "macro/macro_stock.h"
#include "macro/cell_facts.h"
#include "macro/landmark_grid.h"
#include "macro/faction.h"
#include "macro/politik.h"
#include "macro/squad.h"
#include "macro/player_entity.h"
#include "sub/vk_camera_math.h"
#include "sub/lighting.h"
#include "gpu/vk_device.h"
#include "sub/spawn.h"
#include "sub/targeting.h"
#include "sub/ai.h"
#include "sub/movement.h"
#include "sub/spell_effects.h"
#include "sub/damage.h"
#include "sub/base_generator.h"
#include "sub/dgn/dispatch.h"
#include "sub/body.h"
#include "sub/material.h"
#include "ecs/npc_character.h"
#include "sub/height.h"
#include "ecs/systems.h"
#include "macro/spells.h"
#include "macro/state.h"
#include "macro/entry_context.h"
#include "macro/npc.h"
#include "macro/items.h"
#include "macro/attributes.h"
#include "macro/character_sheet.h"
#include "macro/map_generator.h"
#include "macro/features.h"
#include "macro/biomes.h"
#include "macro/tree_layer.h"
#include "macro/movement_cost.h"
#include "macro/currency.h"
#include "macro/seasons.h"
#include "macro/zones.h"
#include "core/rng.h"
#include "core/torus.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <utility>
namespace sm::sub {
namespace {
using Clock = std::chrono::steady_clock;
double elapsed_ms(Clock::time_point a, Clock::time_point b) {
return std::chrono::duration<double, std::milli>(b - a).count();
}
bool seam_trace_enabled() {
static const bool enabled = [] {
const char* env = std::getenv("TIMAERT_SEAM_TRACE");
return env && env[0] != '\0' && env[0] != '0';
}();
return enabled;
}
// Fine-grid resolution ceiling. If the crowd is spread wider than
// kBattleGridMaxDim cells the cell GROWS instead of the allocation — density is
// low in that case, so the neighbour bound holds either way.
constexpr int kBattleGridMaxDim = 256;
// The player side's faction id now comes from the registry itself
// (macro/faction.h kPlayerFactionId) — the player is an ordinary row with an
// ordinary row in the relation matrix, so there is no local spelling of it here
// and no way for the two to drift.
// The last-resort body radius lives with the rest of body sizing, in
// sub/body.h as kBodyRadiusFallback.
// Deaths retired per simulation STEP (SubworldEngine::tick is one step). Named
// for the step, not the frame — the world runs on a fixed tick, so a frame may
// carry several steps or none.
constexpr int kMaxSubworldDeathsPerStep = 512;
constexpr int kMaxSubworldEntityReaps = 2048;
// kHitFlashDuration now lives in sub/spell_effects.h — one constant for every
// weapon's on-hit flash.
// kPlayerMeleeRange / kPlayerMeleeCooldown / kPlayerBaseMeleeDamage moved to
// sub/engine.h (Session 15): the macro encounter's auto-resolve must price
// the player with the same numbers this file arms his body with.
// Player combat body radius (BodyRadius component) — READ from its one home,
// kSpellCasterRadius (content/spells/casting.h), not a twin literal "kept in
// lockstep" by a comment: the player is struck at the same range through
// every universal path (melee, projectile, blast) and his own bolts clear
// the same shell they strike.
constexpr float kPlayerBodyRadius = kSpellCasterRadius;
// Player carried-light (LightEmitter component). The player is the first honest
// point-light emitter: a warm lantern/torch glow gathered through the SAME
// universal path (view<Position, LightEmitter, SubworldTag>) that every future
// emitter — NPC torches, spell glows, lit windows — will use, with no
// player-special-case in the renderer. Additive over the directional sun, so it
// reads as a warm pool at night and is washed out by daylight on its own. Height
// offset seats it at roughly lantern/chest height above the feet position; the
// radius/intensity are tuned so the pool is readable in first person without
// blowing out the pixel-art palette. These are the "тюнер" knobs.
constexpr float kPlayerLightHeightM = 1.2f;
constexpr float kPlayerLightRadiusM = 16.0f;
constexpr float kPlayerLightIntensity = 1.35f;
constexpr float kPlayerLightR = 1.00f; // warm orange-white lantern
constexpr float kPlayerLightG = 0.72f;
constexpr float kPlayerLightB = 0.42f;
// kAllyRepThreshold moved to macro/faction.h — both ends of the one relation
// scale live beside the matrix they cut.
// kKillRepPenalty moved to macro/faction.h — the auto-resolve pays the same
// price for the same crime.
// Flight ceiling margin is sub::kFlightMaxAboveTerrainM (height.h): the
// ceiling itself is renderer3dVk_.max_height_m() + that margin — absolute for
// the loaded window, never below any terrain the window can show.
// Eye height now lives in sub/height.h as kBodyEyeM — the SAME number the
// projectile muzzle uses, so the camera and the guns of every body agree.
constexpr std::uint32_t kFnvOffset =
std::uint32_t{2147483647} + std::uint32_t{18652614};
constexpr std::uint32_t kFnvPrime = std::uint32_t{16777619};
constexpr std::uint32_t kCellSeedX = std::uint32_t{73856093};
constexpr std::uint32_t kCellSeedY = std::uint32_t{19349663};
constexpr std::uint32_t kSquadSpawnSalt =
std::uint32_t{2147483647} + std::uint32_t{622657538};
constexpr std::uint32_t kMacroProjectionSalt =
std::uint32_t{2147483647} + std::uint32_t{1181783497};
constexpr std::uint32_t kEntityLootMix =
std::uint32_t{2147483647} + std::uint32_t{506952114};
constexpr std::uint32_t kNpcMissileSpellId = 0x4E50434Du; // "NPCM"
// ── Body size and eyesight, straight from the authoring tables ─────────────
// Both are DATA, resolved from the one row that already defines the fighter:
// NpcTypeDef::combat for a humanoid, FaunaEntry for a creature (whose `radius`
// column also scales its sprite, so a body can never be a different size than it
// looks). Reading the row per tick — one array index — keeps them in sync with
// the tables for free: making a dragon wide and far-seeing is two numbers in
// fauna.cpp and no code at all. ecs::BodyRadius still wins when present, because
// the player body is the camera and carries an explicit radius.
// Body SIZE moved to sub/body.h (`body_radius`), where BOTH weapons now read it.
// This file's copy was the better of the two — it consulted both body tables —
// but keeping a private one here is exactly what let the projectile copy drift
// away from it unnoticed. The row lookup went with it, as `row_for`, and SIGHT
// below reads the same row.
// THE player's bag — his squad entity's ordinary NpcInventory. The engine
// reaches it through the same door the macro layer does; a scene without a
// macro world (a bare harness) gets a scratch pack rather than a null.
Inventory& player_bag_of(ecs::World* ecs) {
static Inventory scratch{};
Inventory* bag = ecs ? player_inventory(*ecs) : nullptr;
return bag ? *bag : scratch;
}
float body_sight(const entt::registry& reg, entt::entity e) {
const auto* kind = reg.try_get<ecs::NPCKind>(e);
if (const NpcTypeDef* row = row_for(kind)) {
if (row->combat.sight > 0.0f) return row->combat.sight;
}
return kDetectionRadius;
}
thread_local Rng* gLootRng = nullptr;
float loot_rng_f01() {
return gLootRng ? gLootRng->next_f01() : 0.0f;
}
float dist2(float ax, float ay, float bx, float by) {
const float dx = ax - bx;
const float dy = ay - by;
return dx * dx + dy * dy;
}
float dist3sq(float ax, float ay, float az, float bx, float by, float bz) {
const float dx = ax - bx;
const float dy = ay - by;
const float dz = az - bz;
return dx * dx + dy * dy + dz * dz;
}
// One fall-damage path for EVERY body, player included: honest kinetics
// (height.h fall_damage), body radius as the mass proxy, the blow itself
// through THE damage door. "No XP for gravity" is the Fall kind's row, not a
// skipped component here. Returns the damage applied (0 when the kinetics
// round to nothing or the body is already dead) so the player path can log
// it. The physics stays float and rounds ONCE, here — the blow past this
// line is integer like every combat quantity.
int apply_fall_damage(entt::registry& reg, entt::entity e, float impactVz,
float radius, EventBus* bus) {
const int dmg = int(std::lround(fall_damage(impactVz, radius)));
if (dmg <= 0) return 0;
// Typed Blunt for form's sake — the Fall row says armour is not in the way.
return apply_damage(reg, e, DamageSource{}, dmg, DamageKind::Fall,
DamageType::Blunt, bus)
.applied;
}
// ONE index space: NPCKind.factionIdx is an index into the faction registry
// (macro/faction.h) for humanoids and monsters alike. The two per-vocabulary
// dictionaries that used to live here — with colliding indices across the
// monster-type bit — are gone; unknown / kNoFaction degrades to the empty id,
// which every relation path treats as neutral.
const char* faction_id_for_kind(const ecs::NPCKind* kind) {
return kind ? faction_id_for_index(kind->factionIdx) : "";
}
// player_reputation / add_player_reputation / faction_relation used to live
// here, reading a map that hung off PlayerState. They are now one API over the
// ONE relation matrix (macro/state.h) — the player is a row in it like everyone
// else — so the subworld reads exactly what the macro layer wrote.
bool is_player_side(entt::registry& reg, entt::entity e) {
return reg.any_of<ecs::PlayerTag, ecs::PlayerSoldierTag>(e);
}
bool token_equals(const char* raw, const char* lit) {
if (!raw || !lit) return false;
while (*raw && *lit) {
char a = *raw;
char b = *lit;
if (a >= 'A' && a <= 'Z') a = char(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = char(b - 'A' + 'a');
if (a != b) return false;
++raw;
++lit;
}
return *raw == '\0' && *lit == '\0';
}
// A token names a ROW, and the row says its own name: every line of the one
// body table carries a stable `id` column, so this is a scan over the table
// rather than a hand-kept if-chain (which knew seven of the eleven roles and
// none of the creatures — `spawn wolf` used to fall through to Bandit here and
// only worked because a second, creature-only branch caught it downstream).
// An unknown token still becomes a bandit: something hostile appears, which is
// what a spawn command that names nothing recognisable should do.
NPCType npc_type_from_token(const char* token) {
for (const NpcTypeDef& row : kNpcTypeDefs) {
if (row.id && token_equals(token, row.id)) return row.type;
}
return NPCType::Bandit;
}
std::uint32_t string_hash(const char* s) {
std::uint32_t h = kFnvOffset;
if (!s) return h;
while (*s) {
h ^= std::uint8_t(*s++);
h *= kFnvPrime;
}
return h;
}
// (The third face-maker used to live here — same six draws as the other two,
// a different tint base, and a display name mixed into the seed of a face that
// could not carry a name. Faces are derived in ONE place now: sub/spawn.cpp.)
bool alive_subworld_entity(entt::registry& reg, entt::entity e) {
const auto* h = reg.try_get<ecs::Health>(e);
return h && h->hp > 0.0f && reg.all_of<ecs::SubworldTag>(e)
&& !reg.any_of<ecs::Dead>(e);
}
bool hostile_to_player_entity(entt::registry& reg,
entt::entity e,
const GameState* gs) {
if (!alive_subworld_entity(reg, e) || is_player_side(reg, e)) {
return false;
}
if (reg.any_of<ecs::TempHostileToPlayer>(e)) return true;
const char* factionId = faction_id_for_kind(reg.try_get<ecs::NPCKind>(e));
return player_hostile_to(gs, factionId);
}
// NOTE. The old per-pair `entities_hostile(reg, a, b, gs)` is GONE. It was the
// measured hot spot: two std::map<std::string> lookups plus strcmp for every
// candidate pair, and the target scan produced ~N² pairs per frame in a real
// battle. The same rules now live in one relation callback
// (SubworldEngine::battle_relation_callback) that build_faction_masks() applies
// once per tick over the factions PRESENT, yielding a 64-bit enemy mask each; "is j my enemy" is a shift and an AND (BodyCrowd::hostile).
// Player-vs-NPC still reads reputation, NPC-vs-NPC still reads the macro faction
// matrix, and the per-entity TempHostileToPlayer exception rides in the unit's
// own mask — same semantics, integer cost.
const char* subworld_attacker_label(entt::registry& reg, entt::entity e) {
const auto* kind = reg.try_get<ecs::NPCKind>(e);
if (kind && kind->type < std::uint16_t(NPCType::Count)) {
const NPCType type = static_cast<NPCType>(std::uint8_t(kind->type));
return npc_def(type).label;
}
return "Hostile";
}
// maybe_emplace_missile_attack / maybe_emplace_carried_light: the file-local
// twins that lived here moved to their one home — sub/spawn.{h,cpp}.
void maybe_flip_temp_hostile(entt::registry& reg,
entt::entity target,
const GameState* gs,
const char* factionId) {
if (!gs || !reg.valid(target) || !factionId || factionId[0] == '\0') {
return;
}
if (reg.any_of<ecs::TempHostileToPlayer>(target)) return;
if (player_reputation(gs, factionId) >= kAllyRepThreshold) return;
reg.emplace_or_replace<ecs::TempHostileToPlayer>(target);
if (auto* ai = reg.try_get<ecs::SubworldAi>(target)) {
if (ai->kind == ecs::SubworldAi::Wander) {
ai->kind = ecs::SubworldAi::Combat;
}
}
}
void apply_player_hit_reputation(entt::registry& reg,
entt::entity target,
GameState* gs) {
if (!gs || !reg.valid(target)) return;
if (hostile_to_player_entity(reg, target, gs)) return;
const char* factionId = faction_id_for_kind(reg.try_get<ecs::NPCKind>(target));
if (!factionId || factionId[0] == '\0') return;
add_player_reputation(*gs, factionId, kHitRepPenalty);
maybe_flip_temp_hostile(reg, target, gs, factionId);
}
void apply_player_kill_reputation(GameState* gs, const ecs::NPCKind* kind) {
if (!gs) return;
const char* factionId = faction_id_for_kind(kind);
if (!factionId || factionId[0] == '\0') return;
// Whose death the world holds against you is a COLUMN of the faction
// registry (killIsNoCrime), not three ids spelled into the reaper: a new
// lawless faction is a row, and this file never learns who the outlaws
// are.
if (kill_is_no_crime(factionId)) return;
add_player_reputation(*gs, factionId, kKillRepPenalty);
}
const char* compass_from_delta(float dx, float dy) {
const float ax = std::abs(dx);
const float ay = std::abs(dy);
if (ax < 0.35f && ay < 0.35f) return "here";
if (ax > ay * 1.7f) return dx >= 0.0f ? "E" : "W";
if (ay > ax * 1.7f) return dy >= 0.0f ? "N" : "S";
if (dy >= 0.0f) return dx >= 0.0f ? "NE" : "NW";
return dx >= 0.0f ? "SE" : "SW";
}
void spawn_npc_missile(entt::registry& reg,
entt::entity attacker,
const ecs::Position& origin,
const ecs::Combat& combat,
Rng& combatRng,
float targetX,
float targetY,
float targetZ) {
const auto* missile = reg.try_get<ecs::MissileAttack>(attacker);
const float speed = missile && missile->speed > 0.0f
? missile->speed
: 200.0f;
// EYE TO EYE, not foot to foot. Both ends rise by the same kBodyEyeM, so a
// shot between two bodies standing on level ground stays level — but it now
// flies 1.7 m over the dirt instead of grazing it, and a missile aimed at a
// torso no longer has to be aimed at a pair of boots. Raising only ONE end
// would tilt every shot; the fix is that both ends measure from the same
// place, which is the whole point of having one height.
const float originZ = origin.z + kBodyEyeM;
const float aimZ = targetZ + kBodyEyeM;
const float dx = targetX - origin.x;
const float dy = targetY - origin.y;
const float dz = aimZ - originZ;
const float dist3 = std::sqrt(dx * dx + dy * dy + dz * dz) + 0.0001f;
const float nx = dx / dist3;
const float ny = dy / dist3;
const float nz = dz / dist3;
const float attackerRadius = body_radius(reg, attacker);
// Muzzle geometry mirrors the player's caster_spawn_offset(): spawn the
// bolt fully clear of the caster's own hit shell so it can never strike
// its owner on frame 1. Since Inc 4d removed the owner self-exclusion,
// this offset is the ONLY thing keeping a caster off its own projectile —
// the old attackerRadius+1.0 spawned INSIDE the 1.2 shell and leaned on
// that (now-deleted) exclusion.
const float projectileRadius = 1.2f;
const float muzzle = attackerRadius + projectileRadius + 2.0f;
const float sx = origin.x + nx * muzzle;
const float sy = origin.y + ny * muzzle;
const float sz = originZ + nz * muzzle;
const float life = std::max(0.5f, (combat.attackRange + 4.0f) / speed);
const float blast = missile ? missile->blastRadius : 0.0f;
const std::uint32_t color = missile ? missile->colorRGBA : 0xFFFFFFFFu;
const std::uint8_t r = std::uint8_t((color >> 16) & 0xFFu);
const std::uint8_t g = std::uint8_t((color >> 8) & 0xFFu);
const std::uint8_t b = std::uint8_t(color & 0xFFu);
const std::uint8_t a = std::uint8_t((color >> 24) & 0xFFu);
// The missile's wound is rolled AT LOOSE through the one assembly — the
// arrow leaves the bow carrying its number, the crit verdict included.
const StrikeRoll loose = roll_strike(combatRng, combat.dice,
combat.flatAdd, combat.multPct,
int(combat.luck));
entt::entity e = reg.create();
reg.emplace<ecs::Position>(e, sx, sy, sz);
reg.emplace<ecs::Projectile>(
e,
nx * speed, ny * speed, nz * speed,
projectileRadius, life, life,
loose.amount,
blast,
sx, sy,
0.0f,
std::uint8_t(0),
0.0f,
kNpcMissileSpellId,
std::uint32_t(entt::to_integral(attacker)),
std::int16_t(0),
ecs::Projectile::Bolt,
false,
false,
false,
combat.dmgType,
loose.critical);
reg.emplace<ecs::Sprite>(e, std::uint16_t(0x1FD), r, g, b,
a == 0 ? std::uint8_t(255) : a, 1.2f);
reg.emplace<ecs::SubworldTag>(e);
}
void clear_subworld_entities(ecs::World& w) {
auto& reg = w.reg;
std::array<entt::entity, kMaxSubworldEntityReaps> doomed{};
for (;;) {
int doomedCount = 0;
auto view = reg.view<ecs::SubworldTag>();
for (auto e : view) {
if (doomedCount >= kMaxSubworldEntityReaps) break;
doomed[std::size_t(doomedCount++)] = e;
}
if (doomedCount == 0) break;
for (int i = 0; i < doomedCount; ++i) {
const entt::entity e = doomed[std::size_t(i)];
if (reg.valid(e)) reg.destroy(e);
}
}
}
} // namespace
// Universal signed stance — reuses the SAME anonymous-namespace helpers and
// thresholds the combat/AI paths use above, so a marker's colour cannot drift
// from real hostility. The two saturation ends mirror hostile_to_player_entity
// exactly (TempHostileToPlayer or reputation below kHostileThreshold read as
// fully hostile); the positive end mirrors the ally threshold; 0 reputation is
// dead-centre neutral. Each side is scaled independently by its own threshold
// so an asymmetric retune stays correct. Alive/scene filtering is the caller's
// job (collect_minimap_blips already iterates only live scene NPCs).
float player_stance(entt::registry& reg, entt::entity e, const GameState* gs) {
if (is_player_side(reg, e)) return 1.0f; // own side
if (reg.any_of<ecs::TempHostileToPlayer>(e)) return -1.0f; // provoked
const char* factionId = faction_id_for_kind(reg.try_get<ecs::NPCKind>(e));
const int rep = player_reputation(gs, factionId);
if (rep >= 0) {
return std::min(1.0f, float(rep) / float(kAllyRepThreshold));
}
return std::max(-1.0f, float(rep) / float(-kHostileThreshold));
}
const std::vector<MinimapBlip>& SubworldEngine::collect_minimap_blips() const {
minimapBlips_.clear();
if (!ecs_) return minimapBlips_;
entt::registry& reg = ecs_->reg;
// Same candidate set as targeting/melee: live, current-scene NPCs/monsters.
// The hero body carries no NPCKind, but a POSSESSED foreign body does (Inc
// 5c), so exclude PlayerTag explicitly — the player is the map centre / its
// own heading triangle, never a blip. Projected player soldiers keep their
// NPCKind (and no PlayerTag) and read as fully allied (+1).
auto view = reg.view<ecs::Position, ecs::Health, ecs::NPCKind,
ecs::SubworldTag>(entt::exclude<ecs::Dead, ecs::PlayerTag>);
for (auto e : view) {
if (view.get<ecs::Health>(e).hp <= 0) continue;
const auto& pos = view.get<ecs::Position>(e);
minimapBlips_.push_back(
MinimapBlip{pos.x, pos.y, player_stance(reg, e, gs_)});
}
return minimapBlips_;
}
float SubworldEngine::crosshair_stance() const {
if (!ecs_ || !gs_) return std::numeric_limits<float>::quiet_NaN();
entt::registry& reg = ecs_->reg;
// Camera aim ray in 3D (tile/metre space — kTileMeters = 1).
const float cp = std::cos(cam_.pitch);
const float fx = std::cos(cam_.yaw) * cp;
const float fy = std::sin(cam_.yaw) * cp;
const float fz = std::sin(cam_.pitch);
constexpr float kMaxRange = 200.0f;
// Ray segment: player position → player + dir * kMaxRange.
const float ax = playerX_, ay = playerY_, az = playerZ_;
entt::entity best = entt::null;
float bestT = kMaxRange;
auto view = reg.view<ecs::Position, ecs::Health, ecs::NPCKind,
ecs::SubworldTag>(entt::exclude<ecs::Dead>);
for (auto e : view) {
if (reg.any_of<ecs::PlayerTag>(e)) continue;
if (view.get<ecs::Health>(e).hp <= 0) continue;
const auto& pos = view.get<ecs::Position>(e);
const float r = body_radius(reg, e);
// Ray-sphere: project entity onto the aim segment, check distance.
const float dx = pos.x - ax, dy = pos.y - ay, dz = pos.z - az;
const float dot = dx * fx + dy * fy + dz * fz;
if (dot < 0.0f || dot > kMaxRange) continue;
const float d2 = dx * dx + dy * dy + dz * dz;
const float perp2 = d2 - dot * dot;
if (perp2 > r * r) continue;
if (dot < bestT) {
bestT = dot;
best = e;
}
}
if (best == entt::null) return std::numeric_limits<float>::quiet_NaN();
return player_stance(reg, best, gs_);
}
void SubworldEngine::init(const gpu::VulkanDevice& dev, VkRenderPass mainPass) {
if (inited_) return;
dev_ = &dev;
const bool trace = [] {
const char* env = std::getenv("TIMAERT_BOOT_TRACE");
return env && env[0] != '\0' && env[0] != '0';
}();
if (trace) { std::fprintf(stderr, "[boot] subworld renderer3dVk init start\n"); std::fflush(stderr); }
renderer3dVk_.init(dev, mainPass);
if (trace) { std::fprintf(stderr, "[boot] subworld renderer3dVk init done\n"); std::fflush(stderr); }
inited_ = true;
}
void SubworldEngine::destroy(const gpu::VulkanDevice& dev) {
if (!inited_) return;
renderer3dVk_.destroy(dev);
inited_ = false;
dev_ = nullptr;
}
void SubworldEngine::enter(const MacroWorld& mw, EventBus& bus,
const float* posOverride) {
statusLine_.clear();
statusTimer_ = 0.0f;
combatLogCount_ = 0;
// A normal enter is always the open-air window; the dungeon session sets
// its own scene through enter_dungeon_scene.
sceneKind_ = SceneKind::Overworld;
// A fresh session starts with no known threat: the exit gate can be asked
// before the first combat tick runs.
playerThreatD2_ = kNoThreatDistance2;
if (!mw.gs || !mw.terrain || !mw.features || !mw.world
|| !mw.terrain->has_rgba_storage()) {
mw_ = {};
gs_ = nullptr;
terrain_ = nullptr;
features_ = nullptr;
ecs_ = nullptr;
bus_ = nullptr;
zones_ = nullptr;
treeLayer_ = nullptr;
active_ = false;
pendingUpload3d_ = {};
set_status("Subworld unavailable: invalid terrain");
return;
}
// The envelope, captured whole; the named pointers are its views (see
// engine.h) and are assigned HERE and in the reset paths only.
mw_ = mw;
gs_ = mw_.gs; terrain_ = mw_.terrain; features_ = mw_.features;
ecs_ = mw_.world; bus_ = &bus; zones_ = mw_.zones; treeLayer_ = mw_.trees;
GameState& gs = *gs_;
ecs::World& ecs = *ecs_; // shadows the namespace, as the old parameter did
int cx = int(gs.player.x);
int cy = int(gs.player.y);
auto resolver = [this](int x, int y) { return resolve_context(x, y); };
mgr_.init(cx, cy, resolver);
// First upload is unconditionally full inside the renderer (device buffers
// and images not yet created). Consume the manager's dirty (load_all marked
// it full) and hand it straight to upload(), then clear the accumulator.
const CompositeDirty enterDirty = mgr_.consume_composite_dirty_cells();
if (dev_) renderer3dVk_.upload(*dev_, mgr_, enterDirty);
active_ = true;
pendingUpload3d_ = {};
// The scene's solids, indexed BEFORE anybody is placed in it. This used
// to wait for the first frame, so everything that arrived on entry — the
// player, his squad, the projected macro figures — was placed against
// bare terrain and only learned about walls and decks a tick later. The
// heights are uploaded just above, which is the only thing the rebuild
// needs.
structIndex_.rebuild(mgr_.structures(),
&SubworldEngine::ground_height_callback, this);
structIndexDirty_ = false;
// Entry-side placement (macro/entry_context.h): the player lands in the
// CENTRE cell on the side they actually walked in from, at a depth that
// grows with time spent in the macro cell — walked in from the south just
// now → near the south edge; been in the cell for minutes (or no known
// entry: fresh spawn, load, teleport) → the old centre. Deterministic
// mid-band (u = 0.5): the same save re-enters at the same spot. The squad
// ring and the hostile-encounter ring key off playerX_/playerY_, so the
// whole entourage follows for free.
{
int sdx = 0, sdy = 0;
(void)unpack_entry_dir(gs.player.entryDir, sdx, sdy);
playerX_ = float(kCellSize)
+ entry_axis_pos(sdx, gs.player.entryTicks, float(kCellSize), 0.5f);
playerY_ = float(kCellSize)
+ entry_axis_pos(sdy, gs.player.entryTicks, float(kCellSize), 0.5f);
// DRY FOOTING (sub/height.h): a body arrives where something would
// carry it above the water — the ground, or a solid standing on it.
// Same law the projected macro figures follow, and it is about the
// WATER, not about bridges: a walled quay or a future jetty answers
// it identically. Deterministic re-tries within the same entry band,
// so a re-entry lands in the same place; if the whole band is water
// (mid-sea cell) the mid-band point stands, as it always did.
if (!is_dry_footing(footing_height_m(playerX_, playerY_))) {
Rng landing{gs.worldSeed ^ (std::uint32_t(cx) << 8)
^ std::uint32_t(cy) ^ 0xB21D6Eu};
for (int attempt = 0; attempt < 20; ++attempt) {
const float tx = float(kCellSize) + entry_axis_pos(
sdx, gs.player.entryTicks, float(kCellSize),
landing.next_f01());
const float ty = float(kCellSize) + entry_axis_pos(
sdy, gs.player.entryTicks, float(kCellSize),
landing.next_f01());
if (is_dry_footing(footing_height_m(tx, ty))) {
playerX_ = tx;
playerY_ = ty;
break;
}
}
}
}
// Exact landing spot (dungeon exit: back out of the very door you opened).
// Applied BEFORE the squad / projection spawns below, so the entourage
// rings the player's true position.
if (posOverride) {
playerX_ = std::clamp(posOverride[0], 1.0f, float(kFullSize - 2));
playerY_ = std::clamp(posOverride[1], 1.0f, float(kFullSize - 2));
}
playerAttackHeld_ = false;
playerAttackTimer_ = 0.0f;
reset_player_motion();
// ...and PUT HIM ON THE GROUND. playerZ_ is persistent engine state: without
// this it still held the height of wherever the last subworld session ended,
// so leaving a mountain and entering a lowland cell hours of travel later
// spawned the player in mid-air, falling — with fall damage waiting at the
// bottom. Entering is not moving: there is no arc to preserve, and the only
// honest z for a body that has just arrived is the surface under its feet —
// the SUPPORT surface (footing_height_m), the same max(terrain, solid top)
// sync_player_vertical rides every tick afterwards, so arriving on a deck
// or a wall walk seats you on it instead of inside the water below.
playerZ_ = footing_height_m(playerX_, playerY_);
playerGrounded_ = true;
spellRng_ = Rng{gs.worldSeed
+ std::uint32_t(cx) * std::uint32_t{1000}
+ std::uint32_t(cy)};
// Same cell identity, decorrelated stream (golden-ratio odd constant —
// the standard stream-splitting mix, not a tunable).
combatRng_ = Rng{(gs.worldSeed
+ std::uint32_t(cx) * std::uint32_t{1000}
+ std::uint32_t(cy)) ^ 0x9E3779B9u};
// Fill all nine window cells from their own macro contexts (per-cell fauna
// + settlement citizens), so the whole visible 3×3 is populated up front
// and neighbouring cities are alive before you ever step toward them.
refresh_window_step_weights();
spawn_all_cells();
// The squad wears its owner's colours — the player's own realm row. The
// rule lives at the call site because the owner is what the call site knows.
// The owner's AURA rides the same way (character_sheet.h squad_bonuses): the player leads this
// squad, so the player's sheet buffs every soldier born here — the
// EFFECTIVE sheet (phase 4): a leader in a +CHA crown leads like one.
const BonusTotals playerBonuses =
squad_bonuses(player_effective_sheet(ecs, gs.player));
spawn_player_squad(ecs, player_roster(ecs) ? *player_roster(ecs)
: SoldierSquad{},
mgr_, playerX_, playerY_,
gs.worldSeed ^ kSquadSpawnSalt ^ (std::uint32_t(cx) << 8) ^ std::uint32_t(cy),
std::uint16_t(faction_index(kPlayerFactionId)), &playerBonuses);
// Project the persistent macro NPCs standing in this 3×3 window into the
// scene as real combat bodies (Inc 5d) — the overworld lords / bandits /
// peasants are physically MET where they roam, and each projection carries a
// MacroOrigin backlink so leave() (5e) can land the player on the right macro
// cell. The macro entities stay authoritative and untouched (the macro tick
// is frozen while a subworld is active). Runs AFTER the world fill and BEFORE
// the player entity so projections are part of the scene the player enters.
bool projectionTruncated = false;
const int projected = project_macro_npcs_into_subworld(ecs, mgr_, cx, cy,
gs.mapW, gs.mapH,
gs.worldSeed ^ kMacroProjectionSalt ^ (std::uint32_t(cx) << 8)
^ std::uint32_t(cy), &projectionTruncated, &structIndex_);
if (projected > 0) {
char msg[80];
std::snprintf(msg, sizeof(msg), "%d overworld figure%s nearby%s",
projected, projected == 1 ? "" : "s",
projectionTruncated ? " (and more beyond the cap)" : "");
set_status(msg);
}
// Materialise the player as a real ECS entity (the movable PlayerTag flag /
// subworld sim-centre): a full combat actor (Health + BodyRadius + Combat +
// SubworldTag) that hostiles target through the universal paths (Inc 4b).
spawn_player_entity();
if (gs_) {
set_flying(spellbook_rule_active(gs_->player.spellBook, SpellRuleId::Flight));
}
// ── PLACES IN THIS SCENE THAT MEAN SOMETHING ────────────────────────
// The owner's own example: a circle of a certain radius, and standing in
// it means «посетил круг силы». A spire IS such a circle, so it gets one —
// and a generator that wants another writes `add_sub_zone`, not a
// mechanism.
//
// The zone sits in the CENTRE cell of the window, which is the macro cell
// the player entered: the spire stands on its own square of the map, and
// WHERE on that square is the generator's exported placement
// (dgn/dispatch.h kSpireTowerLocalCenter) — window offset + tower axis.
subZoneCount_ = 0;
subZonesEntered_ = 0;
for (const auto& sp : gs.landmarks) {
if (sp.type != LandmarkType::Spire) continue;
if (sp.x != cx || sp.y != cy) continue;
const float mid = float(kCellSize) + kSpireTowerLocalCenter;
add_sub_zone(mid, mid, float(kCellSize) * 0.5f, FactKind::Explored,
int(sp.spellId) + 1);
break;
}
}
void SubworldEngine::sync_macro_player_to_center() {
if (!gs_ || !terrain_ || terrain_->width <= 0 || terrain_->height <= 0) {
return;
}
int nx = mgr_.center_cx() % terrain_->width;
int ny = mgr_.center_cy() % terrain_->height;
if (nx < 0) nx += terrain_->width;
if (ny < 0) ny += terrain_->height;
gs_->player.x = float(nx);
gs_->player.y = float(ny);
// The remap is a jump, not a walk — no entry edge to speak of. The next
// enter() falls back to the centre until the player actually crosses a
// macro cell boundary again.
gs_->player.entryDir = kEntryDirNone;
gs_->player.entryTicks = 0;
gs_->player.entryTickAccum = 0;
}
entt::entity SubworldEngine::remap_macro_player_to_origin() {
if (!gs_ || !ecs_ || !terrain_ || terrain_->width <= 0 || terrain_->height <= 0) {
return entt::null;
}
// The body currently wearing the player flag (never null mid-subworld); the
// pure query returns has == false for a normal un-possessed exit.
const entt::entity body = current_player_body(*ecs_);
const MacroExitCell cell =
macro_exit_cell_for_body(*ecs_, body, terrain_->width, terrain_->height);
if (!cell.has) return entt::null;
gs_->player.x = float(cell.cx);
gs_->player.y = float(cell.cy);
// Same as sync_macro_player_to_center: a remap is a jump, no entry edge.
gs_->player.entryDir = kEntryDirNone;
gs_->player.entryTicks = 0;
gs_->player.entryTickAccum = 0;
return cell.macro; // adopted by leave() as the persistent player (5e-2)
}
// ── Player entity (Inc 4b) ──────────────────────────────────────────────
//
// The player is a movable "flag" (`ecs::PlayerTag`) on a real ECS entity — the
// owner's §8 model where any NPC can receive the flag and the flagged entity is
// the subworld sim-centre. It is a FULL combat actor: Position + PlayerTag +
// Health + BodyRadius + Combat + SubworldTag. Because its signature now matches
// the combat/projectile views, hostiles melee it and spells strike it through
// exactly the same universal paths as any NPC — no player special-case in the
// sim. Its scalars are a transient projection of the macro-authoritative
// player: sync_player_entity_position pulls Position + Health in at each tick
// top, combat mutates Health in place, and reconcile_player_hp_to_macro pushes
// the result back onto combatStats.currentHp (which drives the death screen).
// Lifecycle is explicit and symmetric — spawn_player_entity() on enter,
// clear_player_entity() on leave — so exactly one PlayerTag entity is live while
// a subworld is active and none survives into the macro world. (The player
// carries SubworldTag, so the cell-crossing reapers that skip PlayerTag in
// spawn.cpp keep it across seams, while the leave-time clear_subworld_entities
// would also catch it; clear_player_entity remains the authoritative teardown.)
// Outgoing player damage is still input-driven (tick_player_melee) and the
// entity's Combat is inert until Inc 4c routes the player's own attacks here.
void SubworldEngine::clear_player_entity() {
if (!ecs_) return;
auto& reg = ecs_->reg;
// Collect then destroy — never mutate the registry while iterating a view.
// Normally there is exactly one PlayerTag entity; the small fixed cap is a
// defensive backstop against a hypothetical leak, never expected to fill.
std::array<entt::entity, 8> doomed{};
int n = 0;
for (auto e : reg.view<ecs::PlayerTag>()) {
if (n >= int(doomed.size())) break;
doomed[std::size_t(n++)] = e;
}
for (int i = 0; i < n; ++i) {
const entt::entity e = doomed[std::size_t(i)];
if (!reg.valid(e)) continue;
// A possessed MACRO NPC (Inc 5e-2) wears the flag while the player walks
// the overworld as that lord. Entering a subworld drops possession to the
// hero, but the lord must SURVIVE as an autonomous NPC — so strip only the
// flag (its AI resumes automatically), never destroy it. The hero husk and
// every subworld body carry no MacroNpcRuntime, so those are still fully
// destroyed exactly as before.
if (reg.all_of<ecs::MacroNpcRuntime>(e)) reg.remove<ecs::PlayerTag>(e);
else reg.destroy(e);
}
}
void SubworldEngine::spawn_player_entity() {
if (!ecs_) return;
// Defensive: never leave a stale flag behind (e.g. an enter without a prior
// leave). Exactly one PlayerTag entity must exist while a subworld is live.
clear_player_entity();
// Entering a subworld drops any macro-side possession (Inc 5e-2): the player
// becomes the hero husk built below, not the lord it may have inhabited on the
// overworld. clear_player_entity() just stripped the flag off that lord (it
// survives as an autonomous NPC); clear the persisted ordinal too so a
// mid-subworld save records the hero — matching what load will restore.
if (gs_) gs_->player.possessedMacroSpawnId = -1;
auto& reg = ecs_->reg;
const entt::entity e = reg.create();
reg.emplace<ecs::Position>(e, playerX_, playerY_, 0.0f);
reg.emplace<ecs::PlayerTag>(e);
// Inc 4b: the player is a full combat participant, not an inert anchor.
// - Health mirrors the authoritative macro scalar (combatStats.currentHp);
// sync_player_entity_position pulls it in at each tick top and
// reconcile_player_hp_to_macro pushes the post-combat result back out.
// - SubworldTag puts the entity in the combat actor set so hostiles pick
// it as a melee/projectile target through the SAME paths as any NPC.
// - BodyRadius gives it a sane hit size (it has no SubworldAi/Sprite to
// stand in), so melee reach and projectile contact against the player
// match a humanoid instead of the coarse body_radius() fallback.
// - Combat carries the player's OUTGOING melee identity (Inc 4c): the
// sheet-derived swing damage (10 + rawPhysDamage) plus the melee range /
// cooldown constants. tick_player_melee reads THIS component instead of
// recomputing from the sheet, and sync_player_entity_position refreshes
// the damage each tick so a mid-subworld level-up or gear change lands on
// the next swing. The NPC actor loop still never drives it: is_player_side
// makes the player non-hostile-to-itself, so it is skipped as an attacker
// there — the sole trigger stays the input-driven tick_player_melee.
const int maxHp = gs_ ? std::max(1, gs_->player.combatStats.maxHp) : 1;
const int curHp = gs_
? std::clamp(gs_->player.combatStats.currentHp, 0, maxHp)
: maxHp;
reg.emplace<ecs::Health>(e, ecs::Health{curHp, maxHp});
reg.emplace<ecs::BodyRadius>(e, ecs::BodyRadius{kPlayerBodyRadius});
// The strike: the ONE assembly (macro/anatomy.h hand_strike_fields) from
// the sheet and the weapon actually in hand on the SQUAD entity — gear is
// macro state, the body is its projection. Refreshed each tick beside the
// pace, so drawing a dagger changes the next swing, not the next descent.
const ecs::BodyEquipment* eqp = nullptr;
if (const entt::entity sq = player_squad_entity(*ecs_); sq != entt::null)
eqp = reg.try_get<ecs::BodyEquipment>(sq);
// The EFFECTIVE sheet swings and paces (phase 4): the ring's +STR is in
// the blow, the sustained haste's +SPD is in the step.
const CharacterSheet effBody = gs_
? player_effective_sheet(*ecs_, gs_->player) : CharacterSheet{};
const StrikeFields hs = gs_
? hand_strike_fields(effBody.attributes,
effBody.skills,
eqp ? &eqp->gear : nullptr)
: StrikeFields{kFistDice, DamageType::Blunt, 0, 100, 0};
// Speed: a walking man's, from the ONE scale every body is stated on
// (macro/movement_cost.h). Refreshed each tick beside the damage, so a
// hasted or burdened player's body says what it can actually do.
const float playerPace = march_speed(kHumanMarchMult)
* (gs_ ? float(calculate_derived(effBody.attributes,
effBody.skills).moveSpeedPct)
/ 100.0f
: 1.0f);
reg.emplace<ecs::Combat>(
e, ecs::Combat{hs.dice, hs.flatAdd, hs.multPct, hs.luck,
std::uint8_t(hs.dmgType), playerPace,
kPlayerMeleeRange, kPlayerMeleeCooldown, 0u,
ecs::Combat::Melee});
reg.emplace<ecs::SubworldTag>(e);
// First honest point-light emitter (Inc 4): a warm carried lantern. Gathered
// by the renderer through the universal view<Position, LightEmitter,
// SubworldTag>, so possessing another body (which moves PlayerTag but leaves
// this hero husk's components) simply stops lighting from here and starts
// from whatever the possessed body carries — no special-case anywhere.
reg.emplace<ecs::LightEmitter>(
e, ecs::LightEmitter{0.0f, kPlayerLightHeightM, 0.0f,
kPlayerLightR, kPlayerLightG, kPlayerLightB,
kPlayerLightRadiusM, kPlayerLightIntensity});
}
void SubworldEngine::rebuild_prop_cache() {
if (!ecs_) return;
auto& reg = ecs_->reg;
// The interactive shortlist: doors and stairs among tens of thousands of
// trees, so the per-frame aim scan (and the HUD prompt that runs it) walks
// a handful of records instead of the whole composite.
interactProps_.clear();
for (const Structure& s : mgr_.structures()) {
if (structure_interact(s.kind) != InteractId::None) {
interactProps_.push_back(s);
}
}
for (entt::entity e : propLights_) {
if (reg.valid(e)) reg.destroy(e);
}
propLights_.clear();
// A lit prop's flame is a body like any other: Position + LightEmitter +
// SubworldTag is exactly what the renderer's light gather asks for, so a
// lantern needs no renderer code of its own. It carries nothing else — no
// health, no AI, no sprite — so no other system can see it.
for (const Structure& s : mgr_.structures()) {
if (!structure_is_lit(s.kind)) continue;
const StructureKindRow& row = structure_kind_row(s.kind);
const float seatM = renderer3dVk_.sample_height_m(s.x, s.y);
const entt::entity e = reg.create();
reg.emplace<ecs::Position>(e, s.x, s.y, seatM);
reg.emplace<ecs::SubworldTag>(e);
reg.emplace<ecs::LightEmitter>(e, ecs::LightEmitter{
0.0f, row.lightHeightM, 0.0f,
float((row.lightRgb >> 16) & 0xFFu) / 255.0f,
float((row.lightRgb >> 8) & 0xFFu) / 255.0f,
float( row.lightRgb & 0xFFu) / 255.0f,
// A tile IS a metre in this world (the renderer's kTileMeters is
// 1), so the row's reach in tiles is its reach in metres.
row.lightRadiusTiles,
1.0f});
propLights_.push_back(e);
}
}
void SubworldEngine::pull_player_entity_to_scalars() {
if (!ecs_) return;
auto& reg = ecs_->reg;
// Entity Position is authoritative (Inc 5a); copy it onto the scalar mirror.
auto pv = reg.view<ecs::PlayerTag, ecs::Position>();
for (auto e : pv) {
const auto& p = pv.get<ecs::Position>(e);
playerX_ = p.x;
playerY_ = p.y;
break; // exactly one PlayerTag flag is live at a time
}
}
void SubworldEngine::push_scalars_to_player_entity() {
if (!ecs_) return;
auto& reg = ecs_->reg;
// Fold a scalar change (a seam rewrap, or a move_player/set_player_pos edit)
// back onto the authoritative entity Position. Assignment, so it is a no-op
// when already equal and idempotent w.r.t. the seam rebase that likewise
// shifts the SubworldTag-tagged player entity by the same ∓cell amount.
auto pv = reg.view<ecs::PlayerTag, ecs::Position>();
for (auto e : pv) {
auto& p = pv.get<ecs::Position>(e);
p.x = playerX_;
p.y = playerY_;
p.z = playerZ_;
break;
}
}
void SubworldEngine::sync_player_entity_position() {
if (!ecs_) return;
auto& reg = ecs_->reg;
// Inc 5a: the player entity's Position is AUTHORITATIVE; propagate it onto the
// scalar mirror that every legacy reader (camera / melee origin / proximity /
// seam / HUD) still uses, so a possession that hopped the flag to a body at a
// different Position is followed by all of them from the next tick.
//
// Inc 5c (D3 body-native): only the HERO body is macro-driven. The hero body
// carries no NPCKind — that is the discriminator possess_entity maintains. For
// it, HP stays MACRO-authoritative (pull combatStats -> Health here; combat
// mutates it in place; reconcile pushes it back onto currentHp at tick end)
// and outgoing melee damage tracks the sheet so a mid-subworld level-up / gear
// change lands on the next swing. A POSSESSED foreign body (has NPCKind) is
// left entirely alone here: it fights with its OWN Health + Combat, and
// gs.player is frozen as the preserved revert target.
auto pv = reg.view<ecs::PlayerTag, ecs::Position>();
for (auto e : pv) {
const auto& p = pv.get<ecs::Position>(e);
playerX_ = p.x;
playerY_ = p.y;
playerZ_ = p.z;
if (gs_ && !reg.all_of<ecs::NPCKind>(e)) {
if (auto* h = reg.try_get<ecs::Health>(e)) {
const int maxHp = std::max(1, gs_->player.combatStats.maxHp);
h->maxHp = float(maxHp);
h->hp = float(std::clamp(
gs_->player.combatStats.currentHp, 0, maxHp));
}
if (auto* c = reg.try_get<ecs::Combat>(e)) {