Compare commits

...

15 Commits

Author SHA1 Message Date
Luis Gonzalez 86575dd5bc Fix co-op join disconnect: gate client go-in-game on subscene load
A remote/cold client added NetworkStreamInGame the instant it got a
NetworkId, before the gameplay subscene's ghost prefabs finished
streaming. The server's first ghost snapshot then hit a client that
couldn't resolve those prefabs -> "ghost ... ENTITY_NOT_FOUND" ->
forced disconnect ("nothing loads"). On loopback / fast LAN the
go-in-game handshake beats the ~0.4s entity-subscene stream. The host
never hit it (it shares the server's loaded subscene); every remote
join did -- MPPM virtual players and real networked clients alike.

GoInGameClientSystem now gates full-client go-in-game on the gameplay
subscene being loaded (HasSingleton<PlayerSpawner>); thin clients skip
the gate. Verified cross-process in a standalone build over loopback:
join ghost-prefab errors 15->0, disconnects 1->0, client stays
connected and resolves all server ghosts.

Also adds a -mhost / -mjoin <ip> command-line hook to MainMenuController
for headless co-op testing (drives WorldLauncher.StartSession, no UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:07:27 -07:00
Luis Gonzalez 29e90a5008 First-run onboarding: contextual coach-marks + How-to-Play card + dev replay toggle
Teaches the deep, interlocking loop — especially the inverted win condition
(you win by CLEARING EXPEDITIONS, not by surviving base sieges; DR-042/DR-043).

- OnboardingSystem: client-only observe-only PresentationSystemGroup overlay
  (own UIDocument @ sortingOrder 60), soft-gated 10-beat coach-mark sequence
  with a world-space ▶ pointer; never mutates sim / never destroys a ghost.
- OnboardingStepMath: pure, unit-tested step machine (snapshot + IsSatisfied +
  scheme-aware prompts + pointer kinds + persisted-mask helpers).
- HowToPlayPanel: tabbed reference card (Controls / The Loop / Build / Threats /
  Win-Lose), reachable from the main menu and the pause overlay.
- Per-client client-local state in GameSettings (TutorialHints + OnboardingMask
  bitmask, additive) — a Join client keeps its own; a host save-wipe never
  re-teaches. Settings toggle + menu "Replay Tutorial".
- Dev "Force Each Launch" toggle (GameSettings.ForceOnboardingEachLaunch):
  SettingsService.Boot wipes the mask + forces hints on in-memory every launch
  so the tutorial always replays fresh.
- HudSystem suppresses its own location hint while onboarding is active
  (single prompt voice), via OnboardingState + [UpdateAfter(OnboardingSystem)].

Validated green: 20/20 EditMode; Play smoke confirmed overlay render, clean
U+25B6 pointer glyph, no system sort-cycle, and the force-wipe end-to-end.

Docs: DR-043 + session log; reusable lesson archived in the build-gotchas note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:18:22 -07:00
Luis Gonzalez 3bb9999173 Tidy project settings: keep App UI define, untrack machine-local prefs
ProjectSettings.asset: add APP_UI_EDITOR_ONLY scripting define (from
com.unity.dt.app-ui 1.3.6) so it stays shared across machines.
Untrack + gitignore machine-local editor state that only churns per
machine/editor: ProjectSettings/VirtualProjectsConfig.json (MPPM) and
ProjectSettings/Packages/com.unity.probuilder/Settings.json (ProBuilder
UI prefs). Files stay on disk, regenerated locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:17:02 -07:00
Luis Gonzalez 07b18c8525 Move project to Unity 6.5.1 (editor patch)
ProjectVersion 6000.5.0f1 -> 6000.5.1f1; package re-resolve on the patch
(cinemachine 3.1.6->3.1.7, probuilder 6.0.9->6.1.2 + transitive bumps in
packages-lock). Core DOTS/Netcode stack unchanged (entities/netcode/
physics 6.5.0, URP 17.5.0). CLAUDE.md stack line updated to match.
Validated: clean compile, 390/390 EditMode tests, clean netcode boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:10:39 -07:00
Luis Gonzalez ec6abb8574 Drop dead Git LFS assets with never-uploaded objects
Remove 158 LFS-tracked files whose objects never reached origin
(incomplete push) and so couldn't be fetched on a fresh checkout:
Rukhanka Samples~/ demo assets (Unity-ignored), editor inspector
icons + RukhankaAnimation.pdf, and 2 doc screenshots. All editor-only
or Unity-ignored; verified non-essential (clean compile, 390/390 tests,
clean netcode boot without them). Each asset removed with its .meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:06:25 -07:00
kronic 8f96b520d6 Deferred feel items: true enemy body hit-flash, co-op remote swing arcs, near-impact strike beep
Clears the three follow-ups deferred by the combat-overhaul pass (c3b53cef2) + the
DR-041 "needs its own ShaderGraph slice" note. All client-only, observe-only presentation
(PresentationSystemGroup; no sim mutation, no [GhostField], no server work).

- Item 1: EnemyHitFlashSystem flashes the ACTUAL enemy body by driving the stock
  Entities-Graphics URPMaterialPropertyBaseColor override on the Rukhanka render-entity
  LEG children (root has no MaterialMeshInfo) -- NO ShaderGraph edit, no new component type.
  Lerp white->BodyFlashColor on a Health-decrease edge, decay back to white. Verified on
  screen (the AnimatedLitShader honors the per-instance _BaseColor override).
- Item 2: per-remote-player slash-arc pool in CombatFeedbackSystem, edge-detected from the
  replicated MeleeCombo on interpolated teammates (.WithDisabled<GhostOwnerIsLocal>());
  BuildSlashMesh -> BuildSlashInto(mesh,...) refactor; local player keeps _slashMr.
- Item 3: once-per-windup near-impact strike beep folded into the danger-cone loop, gated
  to a resolved local player.
- 9 new FeelConfig knobs (+ ResetDefaults).

390/390 EditMode, clean compile, zero Play exceptions. 3-lens adversarial review
(wf_8a998c6c-af9) -- no critical/major; fixed 4 minors: spurious beep at base origin before
the local player resolves, frozen tint if BodyFlashEnabled toggles off mid-flash, render-child
capture with no recovery, OnDestroy GO symmetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:51:58 -07:00
kronic c3b53cef28 Combat feel pass: enemy hit-flash, melee connect cue, kill pop, gamepad rumble, footsteps, damage-number tiering
Implements the "what's missing" feel backlog from the combat overhaul investigation (wf_c6c87dc5-9c3). All
client-only, observe-only (PresentationSystemGroup), no sim/netcode change, rollback-safe; new FeelConfig knobs
default-stamped on play-enter.

- ENEMY HIT-FLASH (#1 missing): on the enemy Health-decrease edge, a bright body-scaled particle puff in the
  previously-unused FeelConfig.HitFlashColor (via a new EmitColored helper using per-emit startColor) -- the staple
  "I connected" read. (A true material body-flash needs an AnimatedLitShader emission property + Entities Graphics
  MaterialProperty; the puff is the asset-free version.)
- MELEE CONNECT-vs-WHIFF: on the local swing, a client-side MeleeConeMath.InCone overlap over the cached enemy
  snapshot -> immediate connect read (brightens the slash arc, hit-spark at the nearest enemy, a meaty low "thunk"
  SFX, extra FOV punch) before the authoritative server spark arrives. Whiffs stay dim.
- KILL POP: a colored flash burst + rumble on enemy death (on top of the existing burst/shake/FOV).
- GAMEPAD RUMBLE: new RumbleUtil (auto-stopping pulse, gamepad-only, stops on focus-loss, reset on play-enter)
  pulsed on local hit-taken / hit-dealt / kill, gated on AimPresentation.Scheme==Gamepad.
- FOOTSTEPS: edge-detect local locomotion from the position delta -> soft step SFX at a cadence while moving.
- DAMAGE-NUMBER TIERING: SpawnNumber scales font + life by hit magnitude (vs HitStopRefDamage) so heavy hits read.

390/390 EditMode, clean compile + Play (no exceptions; per-frame footstep/rumble-tick paths verified). On-screen
feel is the operator's eyes. Deferred (focused follow-ups): true material body hit-flash (ShaderGraph), co-op
remote-swing rendering, near-impact telegraph beep (clip reserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:09:07 -07:00
kronic ca38c2b16d Melee clarity: the swing-arc now SWEEPS across + ramps per combo step (reads as a directional escalating cleave)
Operator: "the melee ability needs visual clarity, on what it does." The cleave is instant (kept — operator chose
instant + VFX), and the arc already matched the exact cone range/half-angle — but it popped whole then faded
(read as a flash) and all 3 combo steps looked byte-identical.

Client-only, observe-only (PresentationSystemGroup), no sim/netcode change, rollback-safe by construction:
- SWEEP: BuildSlashMesh takes a reveal fraction + sweep sign; UpdateSlash rebuilds the crescent each frame so the
  blade wipes across the arc over the first ~60% of life, then holds + fades. Leading edge is brightest. Sweep
  direction alternates per combo step -> reads as alternating strikes.
- PER-STEP RAMP: TriggerSlash takes step + comboLen; tint/brightness/life ramp per link so the chain visibly
  builds to the warm-HDR finisher (steps were indistinguishable before). Facing is already snapped at the swing
  edge.

Compiles clean, no runtime exceptions (per-frame 33-vert rebuild is negligible). The on-screen feel is the
operator's eyes. Deferred to a feel follow-up (they share the enemy-cache / damage-edge code path): connect-vs-
whiff flash, co-op remote-swing rendering, and enemy hit-flash. Investigation: wf_c6c87dc5-9c3 (melee lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:13:17 -07:00
kronic 09183cc139 Turrets: 40 Ore + per-base cap of 6 + fit-one-cell textured model (fix cheap/spam/massive/untextured)
Operator: turrets were "super duper cheap", spammable "unlimited", "spaced weirdly", "massive and not textured".
All four were real and independently rooted (placement grid was actually fine).

- Cost: TurretCostOre 10 -> 40 (authoring default + the serialized Gameplay subscene value, which overrides the
  code default). A node yields 30 Ore, so a turret is now ~1.3 nodes instead of 1/3 of one.
- Cap: new Tuning.TurretCap=6, enforced server-authoritatively in BuildPlaceSystem (count live Base turrets while
  building the occupancy set; reject placement at the cap, same-tick-safe). Was unlimited.
- Model: the 1.6x Synty ballista (~5m on a 1m cell, clipping neighbours) scaled to 0.8 to fit one cell; the C5
  BoxCollider shrunk to match (0.8x1.2x0.8, center y 0.6); all 6 sub-renderers swapped off the flat untextured
  teal Mat_StructureOwned_Cyan to the Synty atlas PolygonFantasyKingdom_Mat_01_A (textured). Play-verified
  TurretCost=40 Ore / cap=6 baked; no exceptions.

Also fixes 3 EditMode tests that pinned the old dash knobs (the prior tuning commit changed iframe 12->14 /
cooldown 45->36 but I committed it without re-running tests): DashSystemTests now derives the expected dash speed
from TuningConfig.Defaults() (robust to future tuning) + asserts now+14/+36; TuningConfigTests pins the new
defaults. 390/390 EditMode green.

Investigation: wf_c6c87dc5-9c3 (turret lane). Operator fork: 40 Ore + cap 6 (stricter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:07:57 -07:00
kronic 1b704ca0b9 Combat tuning: lift sluggish chasers toward kiteable band + snappier dash (research-backed)
Deep-research-backed ratios (GDKeys, gamedeveloper.com twin-stick study, Hades/RoR, critpoints i-frames):
enemies should sit ~0.5-0.85x of player move speed (kiteable but pressing), telegraphs >= ~250ms.

- Enemy move speed (on the RIGGED prefabs the directors actually spawn - verified the live ZoneEnemy/Wave
  rosters at runtime: Grunt=EnemyWerewolf, Charger=EnemyChargerMuscle, Spitter=EnemySpitter, Swarmer=
  EnemySwarmerUndead): Grunt 3.0->4.2 (0.70x base), Charger walk 2.6->3.0, Spitter 2.8->3.0. The 0.43-0.50x
  cluster was trivially out-walked by the 6.9 Ranger; lifted to credible pressure while still kiteable.
  Swarmer kept at 6.5 (intentional surround/rush). Telegraph windups unchanged (already research-aligned).
- Dash (live TuningConfig defaults): IFrameWindowTicks 12->14 (0.20->0.23s, covers a reacted telegraph),
  DashCooldownTicks 45->36 (0.75->0.60s, horde-kiter cadence). Dash distance/arc unchanged.

Play-verified the baked rosters: Grunt 4.2 / Charger 3 / Spitter 3 / Swarmer 6.5; dash 14/36. 390/390 EditMode.
All are live-tunable (dash) or one re-bake (enemy speeds). Investigation: wf_c6c87dc5-9c3 (tuning lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:55:42 -07:00
kronic 3c1b5c44cd Fix: dying on an expedition soft-bricked the player — respawn now resets RegionTag to Base
PlayerRespawnSystem teleported a recovered player to base coords but never reset its server-only RegionTag
(every other region-mover flips RegionTag + Position together). So dying ON an expedition left you at base
still tagged Expedition: GhostRelevancy hid all base ghosts from you, base enemies ignored you, and the
expedition field/zone-director kept counting you as "still out there" (waves never stopped). No self-recovery.

- PlayerRespawnSystem: add RefRW<RegionTag> to the recovery query + set Region=Base alongside the reposition.
- Harden: drop the hard RequireForUpdate<PlayerSpawner> (a transiently-missing spawner could strand dead
  players downed forever) -> TryGetSingleton with a BaseAnchor fallback, early-return only if both are absent.
- PlayerRespawnSystemTests: add RegionTag to the harness + a regression (expedition death -> respawn at base
  with RegionTag reset to Base). 390/390 EditMode.

Investigation: combat-overhaul workflow wf_c6c87dc5-9c3 (death lane). Base-death case was already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:44:33 -07:00
kronic 8596cc74b1 Docs: DR-042 Phase C build record (legibility C5-C7 complete) + Backlog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:27:55 -07:00
kronic bd8458853b DR-042 Phase C (legibility, part 2): walls block enemies (C5) — restore the fortress fantasy
Player-built structures now physically block enemies (husks walked straight through walls before). Dedicated
"Structure" physics layer (slot 9) so the player passes its own walls while enemies are stopped:

- New WorldCollisionConfig.StructureMask, baked from the "Structure" layer in WorldCollisionAuthoring (mirrors
  EnvironmentMask). EnemyAISystem ORs it into the movement sweep filter (CollidesWith = envMask | structMask) —
  no new system, same 1-2 SphereCasts per enemy.
- Wall/Turret/Pylon prefabs get a cell-sized BoxCollider on the Structure layer (Wall's existing one relayered).
- Physics matrix: Default x Structure unchecked, so the kinematic player CC (Default) passes its own walls while
  the enemy's explicit cast still hits them. Despawn frees collision for free (collider dies with the entity).

Play-verified baked filters: StructMask=512; structure colliders BelongsTo=512, CollidesWith=0xFFFFFFFE
(includes Environment for the enemy cast, EXCLUDES bit 0 so the player passes). 389/389 EditMode, no exceptions.
Server-only/static colliders -> deterministic, no client divergence. SaveData stays v5.

Phase C complete (C5-C7). A visual fun-gate (husk stops at wall, player walks through) is the operator's eyes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:26:56 -07:00
kronic 419debad74 DR-042 Phase C (legibility, part 1): expedition objective HUD, Aether button, cold-start seed, Biomass sink, palette declutter
Scoping/design-gated (wf_7c5a555e-136). Fixes "the base reads as inert after Phase A":

- C7b objective readout: new replicated ExpeditionObjective{[GhostField] byte State, short Remaining} on the
  untagged CycleDirector ghost (cross-region safe). Sole writer ZoneEnemyDirectorSystem, written ABOVE its
  early-returns (snapshot-above-early-return) so the HUD never freezes stale. Play-verified it replicates
  server->client.
- C7a gate prompt + C7b HUD readout: HudSystem shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS - N
  remaining" / "CLEARED - return to claim", below the siege/overrun overrides.
- C6a Aether upgrade button: un-gated BuildSendSystem.UpgradeAbility (was #if UNITY_EDITOR); HudSystem adds a
  MenuUi.Button with live affordability tint (the only Aether sink was U-key only).
- C6c cold-start seed: CycleDirectorSpawnSystem seeds Tuning.StartingOre (50) into the ledger on a NEW game only
  (born-correct, pre-Playback), killing the silent turret-before-fabricator deadlock. Play-verified seededOre=50.
- C6b Biomass sink: Wall cost Ore->Biomass (the dead currency now has a home). Play-verified WallCostRes=Biomass.
- C6d palette declutter: hide dead Pylon/Harvester/Conveyor from the build palette + trimmed their dev hotkeys
  (catalog/prefabs stay baked, code-intact per DR-020).

389/389 EditMode + clean netcode Play smoke (ghost re-hash OK, no exceptions). SaveData stays v5.
C5 (walls block enemies) is the remaining Phase C item, sequenced separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:18:17 -07:00
kronic ed65770cc9 DR-042 Phase A: expedition-driven win — move win-driver off base-siege survival, kill the AFK path
Design-review-gated (wf_ebef4e81-dba, GREEN-WITH-CHANGES). The win-driver moves
from "survive N base sieges" to "clear N expeditions". The review overturned the
literal plan: credit on RETURN, not at the clear edge (clear-edge crediting arms
the undefended final base siege -> uncontestable terminal Loss).

- ExpeditionGateSystem: now the sole production writer of GoalProgress.Charge —
  a clamped +1 per cleared expedition folded into the existing once-per-epoch
  reward block, reusing the LastRewardedEpoch latch (Ore + Charge share fate) +
  a SaveRequest checkpoint. No new latch, no new GhostField, no ordering change.
- CyclePhaseSystem: deleted the survived-siege +1 (the AFK win path). Victory
  latch unchanged; GoalReached still arms the final base siege at cap.
- CycleDirectorAuthoring + CycleDirector.prefab: ScheduleEnabled baked OFF
  (retaliation-only). A serialized prefab bool ignores the C# field initializer,
  so the value is flipped in the prefab, not just the code default.
- Tests: re-pointed CyclePhaseSystemTests + EndgameWinLoseTests survived-siege
  assertions; extended ExpeditionGateRewardTests (+1, no-double-credit, clamp).
  389/389 EditMode green; clean netcode Play smoke (no sort-cycle, Schedule=0).

SaveData stays v5. Docs: DR-042 build record + forks resolved, CLAUDE.md
base-loop line, Backlog (A done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:49:27 -07:00
228 changed files with 2056 additions and 1159 deletions
+4
View File
@@ -127,3 +127,7 @@ InitTestScene*.unity*
# Scratch / working areas — local screenshots + personal session notes (keep on disk, out of git) # Scratch / working areas — local screenshots + personal session notes (keep on disk, out of git)
/_visual_scratch/ /_visual_scratch/
/Docs/Vault/07_Sessions/User Sessions/ /Docs/Vault/07_Sessions/User Sessions/
# Machine-local editor state (regenerated per machine; untracked 2026-06-28)
/ProjectSettings/VirtualProjectsConfig.json
/ProjectSettings/Packages/com.unity.probuilder/Settings.json
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33f3329aa1b6538d7c6ce29cf5ee32240ae9748448063cc3924e79b8cd720b1c
size 167807
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 04f85d1ea79e74f48945ec9f95fb0f34
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
+1 -1
View File
@@ -89,6 +89,6 @@ MonoBehaviour:
SiegeSizeBase: 5 SiegeSizeBase: 5
SiegeSizePerResource: 0 SiegeSizePerResource: 0
SiegeTimeoutTicks: 3600 SiegeTimeoutTicks: 3600
ScheduleEnabled: 1 ScheduleEnabled: 0
ScheduleIntervalTicks: 2700 ScheduleIntervalTicks: 2700
ScheduleSizePerWave: 1 ScheduleSizePerWave: 1
@@ -1816,7 +1816,7 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 45 MaxHealth: 45
HitRadius: 0.8 HitRadius: 0.8
MoveSpeed: 2.6 MoveSpeed: 3
AttackRange: 1.7 AttackRange: 1.7
AttackDamage: 14 AttackDamage: 14
AttackCooldownTicks: 48 AttackCooldownTicks: 48
+1 -1
View File
@@ -873,7 +873,7 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 28 MaxHealth: 28
HitRadius: 1 HitRadius: 1
MoveSpeed: 2.8 MoveSpeed: 3
AttackRange: 1.8 AttackRange: 1.8
AttackDamage: 8 AttackDamage: 8
AttackCooldownTicks: 66 AttackCooldownTicks: 66
+1 -1
View File
@@ -927,7 +927,7 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 30 MaxHealth: 30
HitRadius: 0.7 HitRadius: 0.7
MoveSpeed: 3 MoveSpeed: 4.2
AttackRange: 1.6 AttackRange: 1.6
AttackDamage: 5 AttackDamage: 5
AttackCooldownTicks: 48 AttackCooldownTicks: 48
+23 -1
View File
@@ -102,7 +102,8 @@ GameObject:
- component: {fileID: 9053853372340598254} - component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220} - component: {fileID: 6834786618115927220}
- component: {fileID: 7685488391646220227} - component: {fileID: 7685488391646220227}
m_Layer: 0 - component: {fileID: 1225369404710843925}
m_Layer: 9
m_Name: Pylon m_Name: Pylon
m_TagString: Untagged m_TagString: Untagged
m_Icon: {fileID: 0} m_Icon: {fileID: 0}
@@ -177,3 +178,24 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureAuthoring
Kind: 6 Kind: 6
MaxHp: 150 MaxHp: 150
--- !u!65 &1225369404710843925
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1.6, y: 2, z: 1.6}
m_Center: {x: 0, y: 1, z: 0}
+31 -9
View File
@@ -28,7 +28,7 @@ Transform:
serializedVersion: 2 serializedVersion: 2
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1.6, y: 1.6, z: 1.6} m_LocalScale: {x: 0.8, y: 0.8, z: 0.8}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: m_Children:
- {fileID: 8624793677999475166} - {fileID: 8624793677999475166}
@@ -67,7 +67,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
@@ -157,7 +157,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
@@ -248,7 +248,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
@@ -342,7 +342,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
@@ -378,7 +378,8 @@ GameObject:
- component: {fileID: 9053853372340598254} - component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220} - component: {fileID: 6834786618115927220}
- component: {fileID: 1794795016809289889} - component: {fileID: 1794795016809289889}
m_Layer: 0 - component: {fileID: 9049467567705961987}
m_Layer: 9
m_Name: Turret m_Name: Turret
m_TagString: Untagged m_TagString: Untagged
m_Icon: {fileID: 0} m_Icon: {fileID: 0}
@@ -455,6 +456,27 @@ MonoBehaviour:
CooldownTicks: 30 CooldownTicks: 30
Damage: 12 Damage: 12
MaxHp: 120 MaxHp: 120
--- !u!65 &9049467567705961987
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 0.8, y: 1.2, z: 0.8}
m_Center: {x: 0, y: 0.6, z: 0}
--- !u!1 &4051895978514069616 --- !u!1 &4051895978514069616
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -521,7 +543,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
@@ -611,7 +633,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
@@ -701,7 +723,7 @@ MeshRenderer:
m_RenderingLayerMask: 1 m_RenderingLayerMask: 1
m_RendererPriority: 0 m_RendererPriority: 0
m_Materials: m_Materials:
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo: m_StaticBatchInfo:
firstSubMesh: 0 firstSubMesh: 0
subMeshCount: 0 subMeshCount: 0
+1 -1
View File
@@ -103,7 +103,7 @@ GameObject:
- component: {fileID: 6834786618115927220} - component: {fileID: 6834786618115927220}
- component: {fileID: 8793146551006314905} - component: {fileID: 8793146551006314905}
- component: {fileID: 7779358222264100756} - component: {fileID: 7779358222264100756}
m_Layer: 0 m_Layer: 9
m_Name: Wall m_Name: Wall
m_TagString: Untagged m_TagString: Untagged
m_Icon: {fileID: 0} m_Icon: {fileID: 0}
@@ -18,7 +18,7 @@ namespace ProjectM.Authoring
public GameObject TurretPrefab; public GameObject TurretPrefab;
[Tooltip("Ore cost to build a turret.")] [Tooltip("Ore cost to build a turret.")]
[Min(0)] public int TurretCostOre = 10; [Min(0)] public int TurretCostOre = 40; // DR-042 combat pass: was 10 (~1/3 node) -> 40 (~1.3 nodes); a real investment
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")] [Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
public GameObject WallPrefab; public GameObject WallPrefab;
@@ -68,7 +68,7 @@ namespace ProjectM.Authoring
{ {
Type = StructureType.Wall, Type = StructureType.Wall,
Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic), Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic),
CostResourceId = ResourceId.Ore, CostResourceId = ResourceId.Biomass, // DR-042 C6b: walls cost Biomass (the dead currency's only sink)
CostAmount = authoring.WallCostOre, CostAmount = authoring.WallCostOre,
}); });
} }
@@ -30,9 +30,9 @@ namespace ProjectM.Authoring
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")] [Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
public uint SiegeTimeoutTicks = 3600; public uint SiegeTimeoutTicks = 3600;
[Header("Threat — scheduled base sieges")] [Header("Threat — scheduled base sieges (DR-042: DISABLED — reserved/inert hook)")]
[Tooltip("A timed cadence arms a base siege even without an expedition trip (keeps the base loop stakeful).")] [Tooltip("DR-042: OFF. A blind timed cadence was the AFK win path (auto-armed sieges the SiegeTimeout auto-cleared). The win-driver is now expedition clears; base sieges are post-expedition retaliation only. Code path kept as a config-inert reserved hook.")]
public bool ScheduleEnabled = true; public bool ScheduleEnabled = false;
[Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")] [Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
public uint ScheduleIntervalTicks = 2700; public uint ScheduleIntervalTicks = 2700;
@@ -59,7 +59,7 @@ namespace ProjectM.Authoring
}); });
AddComponent<ResourceLedger>(entity); AddComponent<ResourceLedger>(entity);
AddBuffer<StorageEntry>(entity); AddBuffer<StorageEntry>(entity);
AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // END-2: 4 survived sieges -> the final siege (the 5th) AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // DR-042: 4 expedition clears -> the climactic final siege
// END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full; // END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full;
// CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue. // CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue.
AddComponent(entity, new CoreIntegrity AddComponent(entity, new CoreIntegrity
@@ -73,6 +73,11 @@ namespace ProjectM.Authoring
// CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue. // CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue.
AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress }); AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress });
// DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout).
// Born Idle; ZoneEnemyDirectorSystem is the sole writer. New [GhostField] component -> re-hashes the
// runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity.
AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 });
AddComponent(entity, new ThreatConfig AddComponent(entity, new ThreatConfig
{ {
@@ -15,6 +15,9 @@ namespace ProjectM.Authoring
{ {
[Tooltip("Name of the Unity layer carrying the static world colliders (boundary ring + landmarks).")] [Tooltip("Name of the Unity layer carrying the static world colliders (boundary ring + landmarks).")]
public string EnvironmentLayerName = "Environment"; public string EnvironmentLayerName = "Environment";
[Tooltip("DR-042 C5: Unity layer carrying player-built structure colliders (Wall/Turret/Pylon) that block enemies.")]
public string StructureLayerName = "Structure";
private class WorldCollisionBaker : Baker<WorldCollisionAuthoring> private class WorldCollisionBaker : Baker<WorldCollisionAuthoring>
{ {
@@ -23,7 +26,9 @@ namespace ProjectM.Authoring
int layer = LayerMask.NameToLayer(authoring.EnvironmentLayerName); int layer = LayerMask.NameToLayer(authoring.EnvironmentLayerName);
uint mask = layer >= 0 ? 1u << layer : 0u; uint mask = layer >= 0 ? 1u << layer : 0u;
var entity = GetEntity(TransformUsageFlags.None); var entity = GetEntity(TransformUsageFlags.None);
AddComponent(entity, new WorldCollisionConfig { EnvironmentMask = mask }); int structLayer = LayerMask.NameToLayer(authoring.StructureLayerName);
uint structMask = structLayer >= 0 ? 1u << structLayer : 0u;
AddComponent(entity, new WorldCollisionConfig { EnvironmentMask = mask, StructureMask = structMask });
} }
} }
} }
@@ -15,7 +15,7 @@ namespace ProjectM.Client
/// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads /// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads
/// <see cref="BuildPaletteState.Active"/>), so the place-click never also fires. Build mode is suspended while /// <see cref="BuildPaletteState.Active"/>), so the place-click never also fires. Build mode is suspended while
/// the pause overlay is open, and the frame a palette button changes the selection never also places. /// the pause overlay is open, and the frame a palette button changes the selection never also places.
/// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/N/H/F/C place at the local player's cell. /// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/F place at the local player's cell.
/// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for /// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for
/// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the /// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the
/// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively. /// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively.
@@ -30,10 +30,9 @@ namespace ProjectM.Client
{ {
(UnityEngine.InputSystem.Key.B, StructureType.Turret), (UnityEngine.InputSystem.Key.B, StructureType.Turret),
(UnityEngine.InputSystem.Key.V, StructureType.Wall), (UnityEngine.InputSystem.Key.V, StructureType.Wall),
(UnityEngine.InputSystem.Key.N, StructureType.Pylon),
(UnityEngine.InputSystem.Key.H, StructureType.Harvester),
(UnityEngine.InputSystem.Key.F, StructureType.Fabricator), (UnityEngine.InputSystem.Key.F, StructureType.Fabricator),
(UnityEngine.InputSystem.Key.C, StructureType.Conveyor), // DR-042 C6d: Pylon/Harvester/Conveyor are dead (unwired automation) — dropped from the hotkey fallback
// to match the hidden build palette; their PlaceStructure execute_code statics remain for dev.
}; };
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
@@ -41,11 +40,16 @@ namespace ProjectM.Client
Material _ghostMat; Material _ghostMat;
byte _lastSelected; // skip placing on the frame a palette click changes the selection byte _lastSelected; // skip placing on the frame a palette click changes the selection
// DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the
// execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain.
static int s_PendingUpgrades = 0;
/// <summary>Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade.</summary>
public static void UpgradeAbility() => s_PendingUpgrades++;
#if UNITY_EDITOR #if UNITY_EDITOR
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; } struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild = static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild =
new System.Collections.Generic.Queue<PendingBuild>(); new System.Collections.Generic.Queue<PendingBuild>();
static int s_PendingUpgrades = 0;
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary> /// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) => public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) =>
@@ -69,8 +73,6 @@ namespace ProjectM.Client
/// <summary>EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).</summary> /// <summary>EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).</summary>
public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction); public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction);
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
public static void UpgradeAbility() => s_PendingUpgrades++;
#endif #endif
protected override void OnCreate() protected override void OnCreate()
@@ -115,17 +117,19 @@ namespace ProjectM.Client
SendUpgrade(connection); SendUpgrade(connection);
} }
// DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility);
// only the execute_code PLACE drain stays editor-gated.
while (s_PendingUpgrades > 0)
{
s_PendingUpgrades--;
SendUpgrade(connection);
}
#if UNITY_EDITOR #if UNITY_EDITOR
while (s_PendingBuild.Count > 0) while (s_PendingBuild.Count > 0)
{ {
var b = s_PendingBuild.Dequeue(); var b = s_PendingBuild.Dequeue();
SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction); SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
} }
while (s_PendingUpgrades > 0)
{
s_PendingUpgrades--;
SendUpgrade(connection);
}
#endif #endif
} }
@@ -29,6 +29,17 @@ namespace ProjectM.Client
[BurstCompile] [BurstCompile]
public void OnUpdate(ref SystemState state) public void OnUpdate(ref SystemState state)
{ {
// A FULL client must not go in-game until the gameplay subscene's ghost prefabs have streamed in.
// Otherwise the server's first ghost snapshot arrives before the client can resolve those prefabs
// ("ghost ... ENTITY_NOT_FOUND" -> the server disconnects the connection -> "nothing loads"). On
// loopback / fast LAN the connect+go-in-game handshake easily beats the ~0.5s entity-subscene stream.
// PlayerSpawner is a subscene-baked singleton that co-loads with the ghost prefabs, so its presence
// is a sound "subscene ready" gate. Thin clients never instantiate ghosts (and don't stream the
// subscene), so they skip the gate and connect immediately.
bool isThinClient = (state.WorldUnmanaged.Flags & WorldFlags.GameThinClient) == WorldFlags.GameThinClient;
if (!isThinClient && !SystemAPI.HasSingleton<PlayerSpawner>())
return;
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (_, connection) in foreach (var (_, connection) in
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0861914135cacf948ae2adfd7f7d6870
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
/// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
/// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
/// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
/// </summary>
public static class OnboardingState
{
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
public static bool Active;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnPlayEnter() => Active = false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e9b3bb074eef1c40b90591e85b90e32
@@ -0,0 +1,131 @@
using ProjectM.Simulation;
namespace ProjectM.Client
{
/// <summary>
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
/// spatial-cue kind, and the persisted-mask helpers.
///
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
/// an already-built base satisfies them on entry and skips straight past.
/// </summary>
public static class OnboardingStepMath
{
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
public const byte Move = 1;
public const byte Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet)
public const byte Build = 3; // open palette + place a Turret
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
public const byte Gate = 5; // reach the Expedition Gate
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
public const byte Return = 7; // walk back to base to bank +1 charge
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
public const byte Done = 9; // closing beat
public const byte StepCount = 10;
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
public const float WelcomeSeconds = 5f;
public const float MoveThreshold = 3f; // accumulated player movement (world units)
public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built
public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises
public const float DoneSeconds = 6f; // closing beat lingers before going dormant
// ---- spatial-cue kinds the System resolves to a live world target ----
public const byte PointerNone = 0;
public const byte PointerOreNode = 1;
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition)
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
public struct Snapshot
{
public float StepElapsed; // seconds the current step has been shown
public float MoveDistance; // accumulated player movement since the Move step began
public int OreNow; // shared-ledger Ore right now
public int OreBaseline; // ledger Ore captured when the Mine step began
public int TurretCount; // live Turret structures (absolute)
public int FabricatorCount; // live Fabricator structures (absolute)
public bool OnExpedition; // local player is in the expedition region
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
public byte Phase; // CycleState.Phase (Calm/Siege)
}
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
public static bool IsSatisfied(byte step, in Snapshot s)
{
switch (step)
{
case Welcome: return s.StepElapsed >= WelcomeSeconds;
case Move: return s.MoveDistance >= MoveThreshold;
case Mine: return s.OreNow > s.OreBaseline;
case Build: return s.TurretCount >= 1;
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
case Done: return s.StepElapsed >= DoneSeconds;
default: return true;
}
}
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
public static byte PointerKind(byte step)
{
switch (step)
{
case Mine: return PointerOreNode;
case Gate: return PointerBaseGate;
case Return: return PointerExpeditionGate;
default: return PointerNone;
}
}
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
public static string Prompt(byte step, bool gamepad)
{
string move = gamepad ? "Left Stick" : "WASD";
string attack = gamepad ? "RT" : "LMB";
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
switch (step)
{
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
case Move: return move + " — Move";
case Mine: return attack + " — Attack the glowing Ore to mine it";
case Build: return build + " — open Build, then place a Turret by your Core";
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
case Gate: return "Reach the Expedition Gate — clearing it charges the Engine";
case Clear: return "Clear the zone — defeat every enemy";
case Return: return "Return through the gate — bank your clear (+1 Engine charge)";
case Defend: return "Defend the Core! — hold the line through the siege";
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
default: return "";
}
}
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
/// <summary>All steps complete (the sequence is dormant).</summary>
public static bool AllComplete(int mask)
{
int all = (1 << StepCount) - 1;
return (mask & all) == all;
}
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
public static byte FirstIncomplete(int mask)
{
for (byte i = 0; i < StepCount; i++)
if ((mask & (1 << i)) == 0) return i;
return Done;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c264496096436e74ebba163a7a5d2205
@@ -0,0 +1,321 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation <see cref="SystemBase"/> in
/// <see cref="PresentationSystemGroup"/> (same shape/constraints as <see cref="HudSystem"/>: never mutates the
/// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument
/// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center
/// coach-mark prompt plus a world-space directional pointer.
///
/// The sequence is PER-CLIENT and client-local: progress lives in <see cref="GameSettings.OnboardingMask"/>
/// (via <see cref="SettingsService"/>), keyed to THIS player's own first-encounter — so a veteran host sees
/// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in
/// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the
/// pure rules + auto-suppress (absolute count checks) live in <see cref="OnboardingStepMath"/>.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class OnboardingSystem : SystemBase
{
const float ExpeditionRegionXMin = 500f; // player x past this = the +1000 expedition region (mirrors HudSystem)
GameObject _go;
UIDocument _doc;
bool _built;
Label _prompt;
Label _pointer;
// step machine (in-memory; persisted to the mask on each completion)
bool _maskLoaded;
int _mask;
byte _step;
bool _stepInit;
float _stepElapsed;
float _moveAccum;
float3 _lastPos;
int _oreBaseline;
bool _sawSiege;
protected override void OnStartRunning()
{
if (_go != null) return;
_go = new GameObject("~Onboarding");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 60; // above HUD (50), below pause (100)
}
protected override void OnDestroy()
{
OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression)
if (_go != null) Object.Destroy(_go);
}
protected override void OnUpdate()
{
if (_doc == null) return;
var root = _doc.rootVisualElement;
if (root == null) return;
if (!_built) { BuildTree(root); _built = true; }
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
// ---- local player presence + position ----
bool havePlayer = false; float3 playerPos = default;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{ havePlayer = true; playerPos = lt.ValueRO.Position; break; }
var settings = SettingsService.Current;
if (!_maskLoaded)
{
_mask = settings.OnboardingMask;
_step = OnboardingStepMath.FirstIncomplete(_mask);
_stepInit = false;
_maskLoaded = true;
}
bool hintsOn = settings.TutorialHints != 0;
// Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice.
if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer)
{
OnboardingState.Active = false;
root.style.display = DisplayStyle.None;
return;
}
// ---- remaining observable state ----
int ore = LedgerOre();
CountStructures(out int turrets, out int fabs);
byte phase = CyclePhase.Calm;
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
byte objState = ExpeditionObjectiveState.Idle;
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj)) objState = obj.State;
bool onExp = playerPos.x > ExpeditionRegionXMin;
// ---- per-step entry init (baselines) ----
if (!_stepInit)
{
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
_oreBaseline = ore; _lastPos = playerPos;
_stepInit = true;
}
// ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/
// Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ----
if (!PauseMenuController.Open)
{
_stepElapsed += dt;
if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
var snap = new OnboardingStepMath.Snapshot
{
StepElapsed = _stepElapsed,
MoveDistance = _moveAccum,
OreNow = ore,
OreBaseline = _oreBaseline,
TurretCount = turrets,
FabricatorCount = fabs,
OnExpedition = onExp,
ObjectiveState = objState,
SawSiege = _sawSiege,
Phase = phase,
};
// The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see
// AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing.
bool skip = (_step == OnboardingStepMath.Welcome || _step == OnboardingStepMath.Done) && AnyInputPressed();
if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
{
_mask |= (1 << _step);
Persist(_mask);
_step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame
_stepInit = false;
if (OnboardingStepMath.AllComplete(_mask))
{
OnboardingState.Active = false;
root.style.display = DisplayStyle.None;
return;
}
}
}
_lastPos = playerPos;
// ---- show the current step ----
OnboardingState.Active = true;
root.style.display = DisplayStyle.Flex;
bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
_prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
UpdatePointer(_step, playerPos);
}
// ---- state gathering helpers ----
int LedgerOre()
{
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var e))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(e);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
}
return 0;
}
void CountStructures(out int turrets, out int fabs)
{
turrets = 0; fabs = 0;
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
{
byte t = ps.ValueRO.Type;
if (t == StructureType.Turret) turrets++;
else if (t == StructureType.Fabricator) fabs++;
}
}
void Persist(int mask)
{
var s = SettingsService.Current;
s.OnboardingMask = mask;
SettingsService.Save(s); // atomic write; ~once per completed step
}
static bool AnyInputPressed()
{
var kb = UnityEngine.InputSystem.Keyboard.current;
// any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing).
if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.wasPressedThisFrame) return true;
var ms = UnityEngine.InputSystem.Mouse.current;
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
var gp = UnityEngine.InputSystem.Gamepad.current;
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
return false;
}
// ---- world-space pointer ----
bool ResolveTarget(byte kind, float3 playerPos, out float3 target)
{
target = default;
if (kind == OnboardingStepMath.PointerOreNode)
{
float best = float.MaxValue; bool found = false;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ResourceNode>())
{
float d = math.distancesq(lt.ValueRO.Position, playerPos);
if (d < best) { best = d; target = lt.ValueRO.Position; found = true; }
}
return found;
}
// base gate (go) lives in the base region; expedition gate (return) lives past the region split.
bool wantBase = kind == OnboardingStepMath.PointerBaseGate;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ExpeditionGate>())
{
var p = lt.ValueRO.Position;
if ((p.x < ExpeditionRegionXMin) == wantBase) { target = p; return true; }
}
return false;
}
void UpdatePointer(byte step, float3 playerPos)
{
byte kind = OnboardingStepMath.PointerKind(step);
var cam = Camera.main;
var root = _doc.rootVisualElement;
if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target))
{ _pointer.style.display = DisplayStyle.None; return; }
float pw = root.layout.width, ph = root.layout.height;
if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; }
Vector3 sp = cam.WorldToScreenPoint((Vector3)target);
bool behind = sp.z < 0f;
float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw;
float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph;
if (behind) { px = pw - px; py = ph - py; }
const float margin = 64f;
bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin;
float cx = pw * 0.5f, cy = ph * 0.5f;
float dx = px - cx, dy = py - cy;
float len = Mathf.Sqrt(dx * dx + dy * dy);
if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; }
float ndx = dx / len, ndy = dy / len;
float ax, ay;
if (off)
{
// intersect the center→target ray with the margin rectangle (edge arrow)
float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx);
float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy);
float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty));
ax = cx + ndx * tt; ay = cy + ndy * tt;
}
else { ax = px; ay = py - 44f; } // float just above the on-screen target
float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0°
_pointer.style.left = ax - 15f;
_pointer.style.top = ay - 18f;
_pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle)));
_pointer.style.display = DisplayStyle.Flex;
}
// ---- UITK construction ----
void BuildTree(VisualElement root)
{
root.style.position = Position.Absolute;
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
root.pickingMode = PickingMode.Ignore; // never eat world clicks
var panel = new VisualElement();
panel.style.position = Position.Absolute;
panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0;
panel.style.flexDirection = FlexDirection.Row;
panel.style.justifyContent = Justify.Center;
panel.style.alignItems = Align.Center;
panel.pickingMode = PickingMode.Ignore;
var chip = new VisualElement();
chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f);
chip.style.paddingLeft = 22; chip.style.paddingRight = 22;
chip.style.paddingTop = 10; chip.style.paddingBottom = 10;
chip.style.maxWidth = 920;
chip.pickingMode = PickingMode.Ignore;
MenuUi.Round(chip, 8);
MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1);
_prompt = new Label(string.Empty);
_prompt.style.color = MenuUi.TextCol;
_prompt.style.fontSize = 18;
_prompt.style.unityFontStyleAndWeight = FontStyle.Bold;
_prompt.style.unityTextAlign = TextAnchor.MiddleCenter;
_prompt.style.whiteSpace = WhiteSpace.Normal;
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBody(_prompt.style);
chip.Add(_prompt);
panel.Add(chip);
root.Add(panel);
_pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target)
_pointer.style.position = Position.Absolute;
_pointer.style.fontSize = 30;
_pointer.style.color = MenuUi.Accent;
_pointer.style.unityFontStyleAndWeight = FontStyle.Bold;
_pointer.pickingMode = PickingMode.Ignore;
_pointer.style.display = DisplayStyle.None;
root.Add(_pointer);
root.style.display = DisplayStyle.None;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4828f5a68386fa4da379dcddbf629de
@@ -60,6 +60,8 @@ namespace ProjectM.Client
Color _slashTint; Color _slashTint;
float _slashAge, _slashLife; float _slashAge, _slashLife;
bool _slashActive; bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
Material _dangerMat; Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new(); readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new(); readonly HashSet<Entity> _dangerSeen = new();
@@ -77,6 +79,21 @@ namespace ProjectM.Client
Material _barBgMat, _barFillMat; Material _barBgMat, _barFillMat;
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone. // Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
readonly Dictionary<Entity, float> _pulseStart = new(); readonly Dictionary<Entity, float> _pulseStart = new();
// Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup).
readonly Dictionary<Entity, uint> _strikeBeeped = new();
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
class RemoteSlash
{
public GameObject Go; public Mesh Mesh; public MeshRenderer Mr; public Material Mat;
public float Age, Life, Range, Half; public int SweepSign; public Color Tint;
public bool Active; public uint LastSwingTick; public bool Init;
}
readonly Dictionary<Entity, RemoteSlash> _remoteSlashes = new();
readonly HashSet<Entity> _remoteSeen = new();
readonly List<Entity> _remoteStale = new();
AudioClip _hitClip; AudioClip _hitClip;
AudioClip _deathClip; AudioClip _deathClip;
@@ -84,6 +101,8 @@ namespace ProjectM.Client
AudioClip _telegraphClip; AudioClip _telegraphClip;
AudioClip _dashClip; AudioClip _dashClip;
AudioClip _swingClip; AudioClip _swingClip;
AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
Entity _localPlayer = Entity.Null; Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick; uint _lastLocalFireTick;
@@ -106,6 +125,9 @@ namespace ProjectM.Client
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false); _telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false); _dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false); _swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
_meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect
_footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // (reserved) near-impact beep
} }
protected override void OnStartRunning() protected override void OnStartRunning()
@@ -145,6 +167,14 @@ namespace ProjectM.Client
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); } if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
foreach (var kv in _healthBars) foreach (var kv in _healthBars)
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo); if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
foreach (var kv in _remoteSlashes)
{
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
if (kv.Value.Mat != null) Object.Destroy(kv.Value.Mat);
if (kv.Value.Go != null) Object.Destroy(kv.Value.Go);
}
} }
protected override void OnUpdate() protected override void OnUpdate()
@@ -217,6 +247,8 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
if (isEnemy) if (isEnemy)
{ {
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size // MC-3: net-new player-dealt-hit camera punch — scales with the bite size
@@ -225,6 +257,10 @@ namespace ProjectM.Client
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage)); float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
// Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read.
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
} }
} }
@@ -266,6 +302,9 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
} }
_cache.Remove(_stale[i]); _cache.Remove(_stale[i]);
} }
@@ -329,18 +368,58 @@ namespace ProjectM.Client
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f; if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, finisher); // the arc IS the range telegraph (MC-4 visual clarity) // MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
float cosHalf = Mathf.Cos(slashHalf);
float2 fdir = new float2(face.x, face.z);
foreach (var kv in _cache)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos))
{
float d2 = math.distancesq(localPos, kv.Value.Pos);
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
}
}
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect
if (connected)
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume);
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
}
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
} }
_lastLocalSwingTick = mc.SwingStartTick; _lastLocalSwingTick = mc.SwingStartTick;
_swingTickInit = true; _swingTickInit = true;
} }
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
if (_localPlayer != Entity.Null)
{
Vector3 lp = (Vector3)localPos;
if (_footInit)
{
float sp = dt > 1e-4f ? new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude / dt : 0f;
_footTimer -= dt;
if (sp >= FeelConfig.FootstepMinSpeed && _footTimer <= 0f)
{
PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume);
_footTimer = FeelConfig.FootstepIntervalSec;
}
}
_lastFootPos = lp; _footInit = true;
}
RumbleUtil.Tick(); // auto-stop any elapsed gamepad rumble pulse
UpdateProjectileTrails(cfg); UpdateProjectileTrails(cfg);
PruneVfx(); PruneVfx();
AnimateNumbers(dt, cam); AnimateNumbers(dt, cam);
UpdateSlash(dt); UpdateSlash(dt);
UpdateEnemyDanger(); UpdateEnemyDanger(localPos);
UpdateRemoteSwings(dt);
UpdateHealthBars(dt, cam, localPos); UpdateHealthBars(dt, cam, localPos);
} }
@@ -352,6 +431,15 @@ namespace ProjectM.Client
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath; return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
} }
// Emit a colored particle burst at a position (per-emit startColor) — used for the enemy hit-flash + kill pop
// without a dedicated particle system (the unused FeelConfig.HitFlashColor finally lights enemies on a hit).
void EmitColored(ParticleSystem ps, Vector3 pos, int count, Color color)
{
if (ps == null || count <= 0) return;
var ep = new ParticleSystem.EmitParams { position = pos, startColor = color };
ps.Emit(ep, count);
}
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count) void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
{ {
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity); if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
@@ -495,12 +583,14 @@ namespace ProjectM.Client
fn.Active = true; fn.Active = true;
fn.Age = 0f; fn.Age = 0f;
fn.Life = 0.7f; float mag = Mathf.Clamp01(amount / Mathf.Max(1f, FeelConfig.HitStopRefDamage)); // big hits read bigger
fn.Life = Mathf.Lerp(0.6f, 0.95f, mag);
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString(); fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit) fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
fn.Tm.color = fn.BaseColor; fn.Tm.color = fn.BaseColor;
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f); fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
fn.Vel = new Vector3(0f, 2.2f, 0f); fn.Vel = new Vector3(0f, 2.2f, 0f);
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
fn.Tr.gameObject.SetActive(true); fn.Tr.gameObject.SetActive(true);
if (cam != null) fn.Tr.rotation = cam.transform.rotation; if (cam != null) fn.Tr.rotation = cam.transform.rotation;
} }
@@ -624,23 +714,27 @@ namespace ProjectM.Client
} }
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space. // Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
void BuildSlashMesh(float range, float halfAngle) // `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional.
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign)
{ {
const int seg = 16; const int seg = 16;
float r1 = Mathf.Max(0.4f, range); float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * 0.45f; float r0 = r1 * 0.45f;
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far edge
float aEnd = Mathf.Lerp(aStart, aFull, Mathf.Clamp01(reveal)); // current leading edge of the sweep
var verts = new Vector3[(seg + 1) * 2]; var verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2]; var cols = new Color[(seg + 1) * 2];
var uvs = new Vector2[(seg + 1) * 2]; var uvs = new Vector2[(seg + 1) * 2];
var tris = new int[seg * 6]; var tris = new int[seg * 6];
for (int i = 0; i <= seg; i++) for (int i = 0; i <= seg; i++)
{ {
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg); float a = Mathf.Lerp(aStart, aEnd, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a); float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0); verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1); verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
float across = 1f - Mathf.Abs(i / (float)seg * 2f - 1f); // 0 at edges, 1 at centre float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.4f + 0.6f * across)); // inner brighter cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.2f + 0.8f * lead)); // inner, brightest at the leading edge
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
uvs[i * 2] = new Vector2(0.5f, 0.5f); uvs[i * 2] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f); uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
@@ -651,27 +745,36 @@ namespace ProjectM.Client
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2; tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2; tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
} }
_slashMesh.Clear(); mesh.Clear();
_slashMesh.vertices = verts; mesh.vertices = verts;
_slashMesh.colors = cols; mesh.colors = cols;
_slashMesh.uv = uvs; mesh.uv = uvs;
_slashMesh.triangles = tris; mesh.triangles = tris;
_slashMesh.RecalculateBounds(); mesh.RecalculateBounds();
} }
// Flash a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS the // Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
// range telegraph (MC-4 visual clarity): the player sees exactly how far + how wide the cleave reaches. // the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, bool finisher) // directional, escalating cleave rather than a static flash.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
{ {
if (_slashMr == null || _slashMat == null) return; if (_slashMr == null || _slashMat == null) return;
BuildSlashMesh(range, halfAngle); bool finisher = step >= comboLen;
_slashRange = range; _slashHalf = halfAngle;
_slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes
BuildSlashInto(_slashMesh, range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward; Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
var tr = _slashMr.transform; var tr = _slashMr.transform;
tr.position = pos + Vector3.up * 0.12f; tr.position = pos + Vector3.up * 0.12f;
tr.rotation = Quaternion.LookRotation(f, Vector3.up); tr.rotation = Quaternion.LookRotation(f, Vector3.up);
tr.localScale = Vector3.one; tr.localScale = Vector3.one;
_slashTint = finisher ? new Color(3.2f, 2.3f, 0.7f) : new Color(1.6f, 2.4f, 3.2f); // finisher warm / light cool (HDR -> bloom) // Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before).
_slashLife = finisher ? 0.26f : 0.17f; float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f;
_slashTint = finisher
? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash
: Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step
if (connected) _slashTint *= 1.6f; // brighter arc on a confirmed bite (the immediate "you hit" read)
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t);
_slashAge = 0f; _slashAge = 0f;
_slashActive = true; _slashActive = true;
_slashMat.color = _slashTint; _slashMat.color = _slashTint;
@@ -684,14 +787,108 @@ namespace ProjectM.Client
_slashAge += dt; _slashAge += dt;
float u = _slashAge / Mathf.Max(1e-4f, _slashLife); float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; } if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
var c = _slashTint; c.a = 1f - u; _slashMat.color = c; // MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.12f); // travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign);
var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c;
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.10f);
}
// Remote teammates' melee cleave arcs (deferred-items pass, co-op readability): the local player's swing
// renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled
// slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client
// presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades.
void UpdateRemoteSwings(float dt)
{
if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return;
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
float finisherMult = tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
_remoteSeen.Clear();
foreach (var (xf, facing, mc, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<MeleeCombo>>()
.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().WithEntityAccess())
{
_remoteSeen.Add(entity);
if (!_remoteSlashes.TryGetValue(entity, out var rs)) { rs = CreateRemoteSlash(); _remoteSlashes[entity] = rs; }
uint swing = mc.ValueRO.SwingStartTick;
if (rs.Init && swing != 0 && swing != rs.LastSwingTick)
{
int step = math.max(1, (int)mc.ValueRO.Step);
bool finisher = step >= comboLen;
rs.Range = finisher ? baseRange * finisherMult : baseRange;
rs.Half = baseHalf;
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
rs.Life = finisher ? 0.26f : 0.18f;
rs.Age = 0f;
rs.Active = true;
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign);
rs.Mat.color = rs.Tint;
rs.Mr.enabled = true;
}
rs.LastSwingTick = swing;
rs.Init = true;
if (rs.Active)
{
rs.Age += dt;
float u = rs.Age / Mathf.Max(1e-4f, rs.Life);
if (u >= 1f) { rs.Active = false; rs.Mr.enabled = false; }
else
{
float2 fdir = facing.ValueRO.Direction;
Vector3 f = math.lengthsq(fdir) > 1e-6f ? new Vector3(fdir.x, 0f, fdir.y).normalized : Vector3.forward;
var tr = rs.Mr.transform;
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.12f;
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign);
var c = rs.Tint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; rs.Mat.color = c;
tr.localScale = Vector3.one * (1f + u * 0.10f);
}
}
}
if (_remoteSlashes.Count != _remoteSeen.Count)
{
_remoteStale.Clear();
foreach (var kv in _remoteSlashes) if (!_remoteSeen.Contains(kv.Key)) _remoteStale.Add(kv.Key);
for (int i = 0; i < _remoteStale.Count; i++)
{
var rs = _remoteSlashes[_remoteStale[i]];
if (rs.Mesh != null) Object.Destroy(rs.Mesh);
if (rs.Mat != null) Object.Destroy(rs.Mat);
if (rs.Go != null) Object.Destroy(rs.Go);
_remoteSlashes.Remove(_remoteStale[i]);
}
}
}
RemoteSlash CreateRemoteSlash()
{
var go = new GameObject("RemoteSlashArc");
go.transform.SetParent(_fxRoot, false);
var mesh = new Mesh { name = "RemoteSlashArc" };
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var mr = go.AddComponent<MeshRenderer>();
var mat = MakeParticleMaterial();
mat.name = "RemoteSlashArc";
mr.sharedMaterial = mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.enabled = false;
return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false };
} }
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger // Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE + // cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame. // WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
void UpdateEnemyDanger() void UpdateEnemyDanger(float3 localPos)
{ {
if (_fxRoot == null || _dangerMat == null) return; if (_fxRoot == null || _dangerMat == null) return;
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default; Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
@@ -721,6 +918,20 @@ namespace ProjectM.Client
// any windup length (fixes the Charger plateauing early under the old hard-coded 22). // any windup length (fixes the Charger plateauing early under the old hard-coded 22).
float windupDur = math.max(1f, tele.ValueRO.WindupTicks); float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
intensity = math.saturate(1f - remaining / windupDur); intensity = math.saturate(1f - remaining / windupDur);
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to
// enemies near the local player (the danger cone already proves it's winding up to strike).
if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks
&& (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until))
{
float3 ep = xf.ValueRO.Position;
if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq)
{
PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume);
_strikeBeeped[entity] = until;
}
}
} }
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost). // Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
@@ -778,6 +989,8 @@ namespace ProjectM.Client
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); } if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
_dangerZones.Remove(_dangerStale[i]); _dangerZones.Remove(_dangerStale[i]);
_pulseStart.Remove(_dangerStale[i]); _pulseStart.Remove(_dangerStale[i]);
_strikeBeeped.Remove(_dangerStale[i]);
} }
} }
} }
@@ -0,0 +1,125 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering; // URPMaterialPropertyBaseColor, MaterialMeshInfo (Entities Graphics)
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only TRUE BODY hit-flash for enemies (the focused follow-up DR-041 deferred as "its own ShaderGraph
/// slice"). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds the gameplay components
/// (<see cref="Health"/>, <see cref="EnemyTag"/>) while the visible meshes are LinkedEntityGroup CHILD render
/// entities (each with a <see cref="MaterialMeshInfo"/> + the AnimatedLitShader material, whose <c>_BaseColor</c>
/// is white). <see cref="CombatFeedbackSystem"/>'s colored particle puff is the asset-free stand-in; THIS system
/// flashes the actual body by driving the built-in Entities-Graphics per-instance override
/// <see cref="URPMaterialPropertyBaseColor"/> (a registered <c>[MaterialProperty("_BaseColor")]</c>) on those
/// render children: on an enemy <see cref="Health"/>-decrease edge it lerps <c>_BaseColor</c> toward
/// <see cref="FeelConfig.BodyFlashColor"/> and decays back to white. No new component type, NO ShaderGraph edit,
/// no server work, no <c>[GhostField]</c> — observe-only client presentation, so it is rollback-irrelevant.
/// The render children gain <see cref="URPMaterialPropertyBaseColor"/> lazily (added once per enemy via ECB);
/// white is the baked rest value (every enemy uses the same AnimatedLitShader/Synty-atlas convention), so
/// <c>Flash==0</c> restores the untouched look and the override is never visible at rest.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class EnemyHitFlashSystem : SystemBase
{
class FlashEntry
{
public readonly List<Entity> RenderKids = new();
public float LastHp;
public float Flash; // 1 on a fresh hit, decays to 0
public bool Settled; // wrote the final white frame after a flash ended (skips per-frame writes at rest)
}
readonly Dictionary<Entity, FlashEntry> _tracked = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
static readonly float4 White = new float4(1f, 1f, 1f, 1f);
protected override void OnUpdate()
{
if (!FeelConfig.BodyFlashEnabled) { RestoreAllToRest(); return; } // settle any mid-flash body back to white before bailing
float dt = SystemAPI.Time.DeltaTime;
EntityManager.CompleteDependencyBeforeRO<Health>();
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
_seen.Clear();
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
foreach (var (health, entity) in
SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag, LinkedEntityGroup>().WithEntityAccess())
{
_seen.Add(entity);
if (_tracked.ContainsKey(entity)) continue;
var entry = new FlashEntry { LastHp = health.ValueRO.Current, Flash = 0f, Settled = true };
var leg = EntityManager.GetBuffer<LinkedEntityGroup>(entity);
for (int i = 0; i < leg.Length; i++)
{
var c = leg[i].Value;
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
entry.RenderKids.Add(c);
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
}
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
}
ecb.Playback(EntityManager);
ecb.Dispose();
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
var bc = FeelConfig.BodyFlashColor;
float4 peak = new float4(bc.r, bc.g, bc.b, bc.a);
float decay = dt / math.max(0.01f, FeelConfig.BodyFlashDurationSec);
foreach (var kv in _tracked)
{
var entity = kv.Key;
var entry = kv.Value;
if (!_seen.Contains(entity)) continue; // despawned -> pruned below
float cur = EntityManager.GetComponentData<Health>(entity).Current;
if (cur < entry.LastHp - 0.001f) { entry.Flash = 1f; entry.Settled = false; }
entry.LastHp = cur;
if (entry.Flash <= 0f)
{
if (!entry.Settled) { WriteColor(entry, White); entry.Settled = true; } // settle to baked white once
continue;
}
entry.Flash = math.max(0f, entry.Flash - decay);
WriteColor(entry, math.lerp(White, peak, entry.Flash));
}
// Prune despawned enemies (their render children die with them; just drop the managed entry).
if (_tracked.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++) _tracked.Remove(_stale[i]);
}
}
// Settle every tracked enemy's body back to its baked white rest color and stop tracking — invoked when
// BodyFlashEnabled is toggled OFF mid-flash so no body is left frozen at an overdriven tint (re-tracked on re-enable).
void RestoreAllToRest()
{
foreach (var kv in _tracked) WriteColor(kv.Value, White);
_tracked.Clear();
}
void WriteColor(FlashEntry entry, float4 col)
{
for (int i = 0; i < entry.RenderKids.Count; i++)
{
var c = entry.RenderKids[i];
if (EntityManager.Exists(c) && EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
EntityManager.SetComponentData(c, new URPMaterialPropertyBaseColor { Value = col });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 03cbda5f0bedbe14fbf680d92db148fe
@@ -109,6 +109,53 @@ namespace ProjectM.Client
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary> /// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
public static bool DashHitSuppress; public static bool DashHitSuppress;
// ---- Combat feel pass (2026-06): connect cue, hit-flash, kill pop, footsteps, rumble, telegraph beep ----
/// <summary>Hit-flash puff density (a colored particle burst in HitFlashColor on an enemy damage edge).</summary>
public static int HitFlashBurstCount;
/// <summary>Extra FOV punch (deg) when the LOCAL melee cleave is confirmed to connect (vs a whiff).</summary>
public static float MeleeConnectFovKick;
/// <summary>Volume of the meaty melee connect "thunk".</summary>
public static float MeleeConnectVolume;
/// <summary>Kill-pop colored flash density on an enemy death.</summary>
public static int KillFlashBurstCount;
/// <summary>Soft footstep SFX volume.</summary>
public static float FootstepVolume;
/// <summary>Seconds between footsteps while the local player is moving.</summary>
public static float FootstepIntervalSec;
/// <summary>Local player speed (u/s) above which footsteps play.</summary>
public static float FootstepMinSpeed;
/// <summary>Master gate for gamepad rumble (no-op on KBM).</summary>
public static bool RumbleEnabled;
/// <summary>Rumble strength on a local hit taken / dealt.</summary>
public static float RumbleHit;
/// <summary>Rumble strength on a kill.</summary>
public static float RumbleKill;
/// <summary>Rumble strength on dash / local death (the heaviest).</summary>
public static float RumbleHeavy;
/// <summary>Seconds a rumble pulse lasts before it auto-stops.</summary>
public static float RumbleDurationSec;
// ---- Deferred-items pass (2026-06): true body hit-flash, remote co-op swings, near-impact strike beep ----
/// <summary>Master gate for the enemy material BODY hit-flash (drives URPMaterialPropertyBaseColor on render children).</summary>
public static bool BodyFlashEnabled;
/// <summary>Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back).</summary>
public static Color BodyFlashColor;
/// <summary>Seconds the body flash decays from peak back to the baked white base.</summary>
public static float BodyFlashDurationSec;
/// <summary>Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability).</summary>
public static bool RemoteSwingEnabled;
/// <summary>Tint of a remote teammate's slash arc (cooler/friendlier than the local warm arc).</summary>
public static Color RemoteSlashColor;
/// <summary>Master gate for the near-impact \"dodge NOW\" strike beep on a winding-up enemy.</summary>
public static bool StrikeBeepEnabled;
/// <summary>Volume of the near-impact strike beep.</summary>
public static float StrikeBeepVolume;
/// <summary>Ticks before the strike lands that the beep fires (the dodge-reaction lead).</summary>
public static int StrikeBeepLeadTicks;
/// <summary>Squared world-distance from the local player beyond which the strike beep is suppressed (avoids a distant cacophony).</summary>
public static float StrikeBeepMaxDistSq;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults() public static void ResetDefaults()
{ {
@@ -161,6 +208,32 @@ namespace ProjectM.Client
DashSfxVolume = 0.55f; DashSfxVolume = 0.55f;
DashShimmerPerFrame = 2; DashShimmerPerFrame = 2;
DashHitSuppress = true; DashHitSuppress = true;
// Combat feel pass (2026-06)
HitFlashBurstCount = 16;
MeleeConnectFovKick = 0.8f;
MeleeConnectVolume = 0.55f;
KillFlashBurstCount = 20;
FootstepVolume = 0.16f;
FootstepIntervalSec = 0.32f;
FootstepMinSpeed = 1.5f;
RumbleEnabled = true;
RumbleHit = 0.25f;
RumbleKill = 0.45f;
RumbleHeavy = 0.6f;
RumbleDurationSec = 0.12f;
// Deferred-items pass (2026-06)
BodyFlashEnabled = true;
BodyFlashColor = new Color(3.2f, 2.8f, 2.2f, 1f); // hot near-white overdrive (multiplies the Synty atlas base map)
BodyFlashDurationSec = 0.16f;
RemoteSwingEnabled = true;
RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc
StrikeBeepEnabled = true;
StrikeBeepVolume = 0.40f;
StrikeBeepLeadTicks = 8;
StrikeBeepMaxDistSq = 225f; // 15 m
} }
} }
} }
@@ -22,6 +22,7 @@ namespace ProjectM.Client
/// </summary> /// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))] [UpdateInGroup(typeof(PresentationSystemGroup))]
[UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
public partial class HudSystem : SystemBase public partial class HudSystem : SystemBase
{ {
// ---- palette (Aether language; Synty white skins are tinted into these) ---- // ---- palette (Aether language; Synty white skins are tinted into these) ----
@@ -67,6 +68,8 @@ namespace ProjectM.Client
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side). // END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
VisualElement _runBanner; VisualElement _runBanner;
Label _runBannerText, _runBannerSub; Label _runBannerText, _runBannerSub;
// DR-042 C6a: the Aether ability-upgrade button (was U-key only) + its live affordability tint.
Button _upgradeBtn;
readonly List<VisualElement> _pips = new(); readonly List<VisualElement> _pips = new();
@@ -181,6 +184,28 @@ namespace ProjectM.Client
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
: finalSiege ? new Color(1f, 0.3f, 0.25f) : finalSiege ? new Color(1f, 0.3f, 0.25f)
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
// DR-042 C7 (gate prompt) + C7b (objective readout): the expedition is the win-driver, so signpost it.
// Reads the REPLICATED ExpeditionObjective summary (cross-region safe). Lower priority than the siege /
// cold-turret / overrun overrides below, which still win.
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj))
{
if (onExpedition)
{
if (obj.State == ExpeditionObjectiveState.Cleared)
{ _locationText.text = "ZONE CLEARED - return to base to claim"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
else if (obj.State == ExpeditionObjectiveState.Active)
{ _locationText.text = "CLEAR THE ZONE - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
}
else if (!siege)
{
if (obj.State == ExpeditionObjectiveState.Cleared)
{ _locationText.text = "EXPEDITION CLEARED - return to claim your reward"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
else if (obj.State == ExpeditionObjectiveState.Active)
{ _locationText.text = "EXPEDITION IN PROGRESS - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
else
{ _locationText.text = "GO TO THE EXPEDITION GATE - clear a sortie to advance the Engine"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); }
}
}
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ---- // ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal)) if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
@@ -230,6 +255,9 @@ namespace ProjectM.Client
_oreNum.text = ore.ToString(); _oreNum.text = ore.ToString();
_bioNum.text = bio.ToString(); _bioNum.text = bio.ToString();
_chargeNum.text = charge.ToString(); _chargeNum.text = charge.ToString();
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
if (_upgradeBtn != null)
_upgradeBtn.style.opacity = aether >= Tuning.AbilityUpgradeCostAmount ? 1f : 0.5f;
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one // EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
// broken turret): a dry base during a siege tells the player to build a Fabricator. // broken turret): a dry base during a siege tells the player to build a Fabricator.
if (siege && charge == 0 && !onExpedition) if (siege && charge == 0 && !onExpedition)
@@ -263,6 +291,9 @@ namespace ProjectM.Client
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover"; _locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
_locationText.style.color = new Color(1f, 0.3f, 0.25f); _locationText.style.color = new Color(1f, 0.3f, 0.25f);
} }
// First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
// location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
if (OnboardingState.Active) _locationText.text = "";
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ---- // ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress) if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{ {
@@ -446,12 +477,18 @@ namespace ProjectM.Client
} }
} }
// DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette
// (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI.
static bool IsPaletteType(byte type) =>
type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor;
void UpdatePalette(int aether, int ore, int bio, bool onExpedition) void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
{ {
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE)) if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
{ {
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE); var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
for (int i = 0; i < cat.Length; i++) for (int i = 0; i < cat.Length; i++)
if (IsPaletteType(cat[i].Type))
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId); AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
_paletteBuilt = true; _paletteBuilt = true;
} }
@@ -809,6 +846,11 @@ namespace ProjectM.Client
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22)); strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20)); strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon) strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
// DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key
// only). The Button element handles its own picking even though the HUD root Ignores clicks.
_upgradeBtn = MenuUi.Button("UPGRADE DMG (" + Tuning.AbilityUpgradeCostAmount + " AETHER)", BuildSendSystem.UpgradeAbility);
_upgradeBtn.style.marginLeft = 18;
strip.Add(_upgradeBtn);
root.Add(strip); root.Add(strip);
} }
@@ -0,0 +1,54 @@
using UnityEngine;
using UnityEngine.InputSystem;
namespace ProjectM.Client
{
/// <summary>
/// Gamepad rumble for combat feel — a static bridge (mirrors <see cref="FeelConfig"/> / <see cref="AimPresentation"/>).
/// <see cref="Pulse"/> sets the motors and stamps a stop time; <see cref="Tick"/> (called once per frame from
/// <c>CombatFeedbackSystem</c>) stops them when the pulse elapses OR the app loses focus, so a rumble never sticks.
/// A no-op when no pad is connected; the CALLER gates to the Gamepad scheme. Statics survive fast-enter-playmode
/// reloads, so <see cref="ResetState"/> re-arms clean on play-enter and stops any leaked motor (the AimPresentation
/// reset idiom). Presentation-only, main-thread, never touches the simulation.
/// </summary>
public static class RumbleUtil
{
static float s_StopTime;
static bool s_Active;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetState()
{
s_Active = false;
s_StopTime = 0f;
Stop();
}
/// <summary>Pulse both motors at the given low/high strengths for durSec, then auto-stop. No-op without a pad.</summary>
public static void Pulse(float low, float high, float durSec)
{
var pad = Gamepad.current;
if (pad == null) return;
pad.SetMotorSpeeds(Mathf.Clamp01(low), Mathf.Clamp01(high));
s_StopTime = Time.unscaledTime + Mathf.Max(0.02f, durSec);
s_Active = true;
}
/// <summary>Call once per frame: stops the motors when the pulse elapses or focus is lost.</summary>
public static void Tick()
{
if (!s_Active) return;
if (!Application.isFocused || Time.unscaledTime >= s_StopTime)
{
Stop();
s_Active = false;
}
}
static void Stop()
{
var pad = Gamepad.current;
if (pad != null) pad.ResetHaptics();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2f7005e66cfc2ce4d825ebad3cdc9eac
@@ -12,7 +12,7 @@ namespace ProjectM.Client
[Serializable] [Serializable]
public struct GameSettings public struct GameSettings
{ {
public const int CurrentVersion = 1; public const int CurrentVersion = 2;
public int Version; public int Version;
@@ -30,6 +30,13 @@ namespace ProjectM.Client
public float Music; public float Music;
public float Sfx; public float Sfx;
// ---- Onboarding (client-local first-run state; NEVER replicated — a Join client keeps its own,
// unlike the host-only SaveData) ----
public int TutorialHints; // 0 = first-run coach-marks off, 1 = on
public int OnboardingMask; // bitmask of completed coach-mark steps (0 = nothing seen; bit i = step i done)
public int ForceOnboardingEachLaunch; // DEV: 1 = wipe OnboardingMask + force hints on at every launch so the
// first-run coach-marks always replay fresh (additive; 0-default off)
/// <summary>Sensible defaults derived from the current display + active quality level.</summary> /// <summary>Sensible defaults derived from the current display + active quality level.</summary>
public static GameSettings Defaults() public static GameSettings Defaults()
{ {
@@ -47,6 +54,9 @@ namespace ProjectM.Client
Master = 1f, Master = 1f,
Music = 1f, Music = 1f,
Sfx = 1f, Sfx = 1f,
TutorialHints = 1,
OnboardingMask = 0,
ForceOnboardingEachLaunch = 0,
}; };
} }
@@ -65,6 +75,9 @@ namespace ProjectM.Client
s.Master = Mathf.Clamp01(s.Master); s.Master = Mathf.Clamp01(s.Master);
s.Music = Mathf.Clamp01(s.Music); s.Music = Mathf.Clamp01(s.Music);
s.Sfx = Mathf.Clamp01(s.Sfx); s.Sfx = Mathf.Clamp01(s.Sfx);
s.TutorialHints = s.TutorialHints != 0 ? 1 : 0;
s.ForceOnboardingEachLaunch = s.ForceOnboardingEachLaunch != 0 ? 1 : 0;
// OnboardingMask is an opaque bitmask — deliberately NOT clamped.
return s; return s;
} }
} }
@@ -22,6 +22,18 @@ namespace ProjectM.Client
static void Boot() static void Boot()
{ {
Load(); Load();
// DEV convenience ("Force Each Launch", Settings → Onboarding): wipe the persisted completed-step mask at
// EVERY boot — each editor Play-enter / each built-player launch runs this hook — so the first-run
// coach-marks always replay from the top, and force hints on so they actually show. In-memory only (the
// wipe is NOT written back to disk); OnboardingSystem re-persists progress as the player advances, and the
// next launch wipes it again. Toggle it off to return to normal once-only first-run behaviour.
if (Current.ForceOnboardingEachLaunch != 0)
{
var s = Current;
s.OnboardingMask = 0;
s.TutorialHints = 1;
Current = s;
}
Apply(Current); Apply(Current);
} }
@@ -103,14 +115,21 @@ namespace ProjectM.Client
// Additive-only as the schema grows (never throws on an unknown version). // Additive-only as the schema grows (never throws on an unknown version).
static GameSettings Migrate(GameSettings old) static GameSettings Migrate(GameSettings old)
{ {
var def = GameSettings.Defaults(); // Preserve EVERY recognized field from the old save (Load() Clamps the result, fixing any 0/garbage),
if (old.ResWidth > 0) def.ResWidth = old.ResWidth; // and seed ONLY the genuinely-new fields — a v1 file deserializes those to 0. Migrating from a fresh
if (old.ResHeight > 0) def.ResHeight = old.ResHeight; // Defaults() instead would silently reset graphics fields (display mode / quality / v-sync / fps cap /
if (old.Master > 0f) def.Master = old.Master; // refresh rate) that v1 already carried — a regression the version bump would otherwise surface.
if (old.Music > 0f) def.Music = old.Music; var s = old;
if (old.Sfx > 0f) def.Sfx = old.Sfx; if (old.Version < 2)
def.Version = GameSettings.CurrentVersion; {
return def; // v1 had no onboarding fields. An existing settings.json ⇒ a returning player who already played a
// pre-onboarding build, so mark every coach-mark step complete (dormant); hints stay on so
// "Replay Tutorial" (which clears OnboardingMask) still re-arms.
s.TutorialHints = 1;
s.OnboardingMask = int.MaxValue;
}
s.Version = GameSettings.CurrentVersion;
return s;
} }
} }
} }
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using ProjectM.Simulation;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// The replayable "How to Play" reference card (UI Toolkit), built on <see cref="MenuUi"/> like
/// <see cref="SettingsScreen"/> and reachable from BOTH the main menu and the in-game pause overlay. Tabbed,
/// one glanceable page each: Controls (for the chosen class), The Loop (the annotated win-condition diagram —
/// the single highest-value page, since the inverted goal "clear expeditions to win" is the #1 new-player
/// confusion), Build &amp; Economy, Threats, Win/Lose. Static + stateless: <see cref="Build"/> returns a fresh
/// full-screen panel each call; the caller owns its lifetime (RemoveFromHierarchy on close).
/// </summary>
public static class HowToPlayPanel
{
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" };
public static VisualElement Build(Action onClose)
{
var root = MenuUi.FullScreenRoot(true);
var card = MenuUi.Card("HOW TO PLAY");
card.style.minWidth = 640;
card.style.maxWidth = 780;
root.Add(card);
var tabBar = new VisualElement();
tabBar.style.flexDirection = FlexDirection.Row;
tabBar.style.justifyContent = Justify.Center;
tabBar.style.marginBottom = 12;
card.Add(tabBar);
var content = new VisualElement();
content.style.minHeight = 230;
card.Add(content);
var tabButtons = new List<Button>();
void Show(int idx)
{
content.Clear();
BuildTab(content, idx);
for (int i = 0; i < tabButtons.Count; i++)
tabButtons[i].style.backgroundColor = i == idx
? new Color(0.16f, 0.34f, 0.42f, 1f)
: new Color(0.16f, 0.20f, 0.27f, 1f);
}
for (int i = 0; i < Tabs.Length; i++)
{
int idx = i;
var b = MenuUi.Button(Tabs[i], () => Show(idx));
b.style.height = 32; b.style.fontSize = 13;
b.style.marginLeft = 3; b.style.marginRight = 3;
b.style.flexGrow = 1;
tabButtons.Add(b);
tabBar.Add(b);
}
card.Add(MenuUi.Button("Back", () => onClose?.Invoke()));
Show(0);
return root;
}
static void BuildTab(VisualElement c, int idx)
{
switch (idx)
{
case 0: // Controls (chosen class)
bool ranger = WorldLauncher.SelectedClass == (byte)CharacterId.Ranger;
Head(c, ranger ? "CLASS: Ranger (ranged anchor)" : "CLASS: Warrior (melee anchor)");
Body(c, "Move — WASD / Left Stick");
Body(c, "Aim — Mouse cursor / Right Stick");
Body(c, "Attack — LMB / RT (your primary verb)");
Body(c, "Build menu — Tab / Y");
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
Body(c, "Deposit at base — G");
Body(c, "Inventory — I");
Body(c, "Pause — Esc");
break;
case 1: // The Loop
Head(c, "THE LOOP — expeditions are how you win");
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
break;
case 2: // Build & Economy
Head(c, "RESOURCES & BUILDING");
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
Body(c, "Aether — spend it on UPGRADE DMG to boost your damage.");
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
break;
case 3: // Threats
Head(c, "THREATS");
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
Body(c, "Expeditions throw waves at you — clear them all to bank the win.");
Body(c, "Enemy variety scales the deeper you push.");
break;
default: // Win / Lose
Head(c, "WIN / LOSE");
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
Body(c, "LOSE — the Engine Core falls during the final siege.");
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
break;
}
}
static void Head(VisualElement c, string t)
{
var l = new Label(t);
l.style.color = MenuUi.Accent;
l.style.fontSize = 16;
l.style.unityFontStyleAndWeight = FontStyle.Bold;
l.style.marginBottom = 8;
l.style.whiteSpace = WhiteSpace.Normal;
var th = HudTheme.Get();
if (th != null) th.ApplyDisplay(l.style);
c.Add(l);
}
static void Body(VisualElement c, string t)
{
var l = new Label(t);
l.style.color = MenuUi.TextCol;
l.style.fontSize = 14;
l.style.marginBottom = 5;
l.style.whiteSpace = WhiteSpace.Normal;
var th = HudTheme.Get();
if (th != null) th.ApplyBody(l.style);
c.Add(l);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fec1f60cab08999419a195c68923634e
@@ -18,8 +18,10 @@ namespace ProjectM.Client
UIDocument _doc; UIDocument _doc;
VisualElement _mainPanel; VisualElement _mainPanel;
VisualElement _settingsPanel; VisualElement _settingsPanel;
VisualElement _howToPanel;
TextField _ipField; TextField _ipField;
Label _classLabel; Label _classLabel;
bool _autoStarted;
void Awake() void Awake()
{ {
@@ -31,10 +33,12 @@ namespace ProjectM.Client
// The menu owns the cursor. // The menu owns the cursor.
UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.lockState = CursorLockMode.None;
UnityEngine.Cursor.visible = true; UnityEngine.Cursor.visible = true;
_autoStarted = TryAutoStartFromCommandLine();
} }
void OnEnable() void OnEnable()
{ {
if (_autoStarted) return; // headless CLI co-op session; don't build the menu UI
if (_doc == null) _doc = GetComponent<UIDocument>(); if (_doc == null) _doc = GetComponent<UIDocument>();
var root = _doc.rootVisualElement; var root = _doc.rootVisualElement;
if (root == null) return; if (root == null) return;
@@ -42,6 +46,28 @@ namespace ProjectM.Client
BuildMain(root); BuildMain(root);
} }
/// <summary>
/// Dev/automation hook: a player launched with <c>-mhost</c> auto-hosts; <c>-mjoin &lt;ip&gt;</c> auto-joins
/// (ip optional, defaults to loopback). Lets two standalone builds form a co-op session headlessly for
/// testing — the same <see cref="WorldLauncher.StartSession"/> path the menu buttons use, no UI clicks.
/// No effect when neither arg is present. Returns true if a session was started.
/// </summary>
static bool TryAutoStartFromCommandLine()
{
var args = System.Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-mhost") { WorldLauncher.StartSession(SessionMode.Host, "", false); return true; }
if (args[i] == "-mjoin")
{
string ip = (i + 1 < args.Length && !args[i + 1].StartsWith("-")) ? args[i + 1] : "127.0.0.1";
WorldLauncher.StartSession(SessionMode.Join, ip, false);
return true;
}
}
return false;
}
static void EnsureMenuWorld() static void EnsureMenuWorld()
{ {
var w = World.DefaultGameObjectInjectionWorld; var w = World.DefaultGameObjectInjectionWorld;
@@ -79,6 +105,21 @@ namespace ProjectM.Client
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false))); card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
card.Add(MenuUi.Button("Settings", ShowSettings)); card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
// Re-arm the first-run coach-marks (clears the client-local completed-step mask). The next session
// replays them; the How-to-Play card stays available regardless.
Button replayBtn = null;
replayBtn = MenuUi.Button("Replay Tutorial", () =>
{
var s = SettingsService.Current;
s.OnboardingMask = 0;
s.TutorialHints = 1;
SettingsService.Save(s);
if (replayBtn != null) replayBtn.text = "Tutorial armed ✓";
});
card.Add(replayBtn);
card.Add(MenuUi.Button("Quit", Quit)); card.Add(MenuUi.Button("Quit", Quit));
_mainPanel.Add(card); _mainPanel.Add(card);
@@ -112,6 +153,19 @@ namespace ProjectM.Client
_mainPanel.style.display = DisplayStyle.Flex; _mainPanel.style.display = DisplayStyle.Flex;
} }
void ShowHowToPlay()
{
_mainPanel.style.display = DisplayStyle.None;
_howToPanel = HowToPlayPanel.Build(HideHowToPlay);
_doc.rootVisualElement.Add(_howToPanel);
}
void HideHowToPlay()
{
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
_mainPanel.style.display = DisplayStyle.Flex;
}
static void Quit() static void Quit()
{ {
#if UNITY_EDITOR #if UNITY_EDITOR
@@ -16,6 +16,7 @@ namespace ProjectM.Client
VisualElement _root; VisualElement _root;
VisualElement _pausePanel; VisualElement _pausePanel;
VisualElement _settingsPanel; VisualElement _settingsPanel;
VisualElement _howToPanel;
bool _open; bool _open;
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary> /// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
public static bool Open; public static bool Open;
@@ -49,6 +50,7 @@ namespace ProjectM.Client
var card = MenuUi.Card("PAUSED"); var card = MenuUi.Card("PAUSED");
card.Add(MenuUi.Button("Resume", () => SetOpen(false))); card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
card.Add(MenuUi.Button("Settings", ShowSettings)); card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu)); card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
card.Add(MenuUi.Button("Quit to Desktop", Quit)); card.Add(MenuUi.Button("Quit to Desktop", Quit));
_pausePanel.Add(card); _pausePanel.Add(card);
@@ -61,6 +63,7 @@ namespace ProjectM.Client
Open = open; Open = open;
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None; if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; } if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
if (!open && _howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; } if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
} }
@@ -75,6 +78,17 @@ namespace ProjectM.Client
_root.Add(_settingsPanel); _root.Add(_settingsPanel);
} }
void ShowHowToPlay()
{
_pausePanel.style.display = DisplayStyle.None;
_howToPanel = HowToPlayPanel.Build(() =>
{
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
_pausePanel.style.display = DisplayStyle.Flex;
});
_root.Add(_howToPanel);
}
static void Quit() static void Quit()
{ {
#if UNITY_EDITOR #if UNITY_EDITOR
@@ -59,6 +59,19 @@ namespace ProjectM.Client
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; })); card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; })); card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
// ---------------- Onboarding ----------------
card.Add(MenuUi.Caption("— ONBOARDING —"));
string[] onoff = { "Off", "On" };
card.Add(CycleRow("Tutorial Hints",
() => onoff[Mathf.Clamp(working.TutorialHints, 0, 1)],
dir => working.TutorialHints = Wrap(working.TutorialHints + dir, 2)));
// DEV: forces the first-run coach-marks to replay fresh on every launch (wipes the completed-step mask at
// each boot — see SettingsService.Boot). Off = normal once-only first-run behaviour.
card.Add(CycleRow("Force Each Launch (Dev)",
() => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)],
dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2)));
// ---------------- Buttons ---------------- // ---------------- Buttons ----------------
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working)); var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f); apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
@@ -50,10 +50,14 @@ namespace ProjectM.Server
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>()); var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>()); var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
// Derive occupancy from the live structure set (authoritative source of truth). // Derive occupancy from the live structure set (authoritative); also count turrets for the per-base cap.
var occupied = new NativeHashSet<int2>(64, Allocator.Temp); var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
int turretCount = 0;
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>()) foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
{
occupied.Add(ps.ValueRO.Cell); occupied.Add(ps.ValueRO.Cell);
if (ps.ValueRO.Type == StructureType.Turret) turretCount++;
}
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -67,7 +71,9 @@ namespace ProjectM.Server
for (int i = 0; i < catalog.Length; i++) for (int i = 0; i < catalog.Length; i++)
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; } if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null // DR-042 combat pass: cap turrets per base (server-authoritative) so they can't be spammed.
bool turretCapOk = req.StructureType != StructureType.Turret || turretCount < Tuning.TurretCap;
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null && turretCapOk
&& BuildPlacementMath.CanPlace(anchor, occupied, cell)) && BuildPlacementMath.CanPlace(anchor, occupied, cell))
{ {
var entry = catalog[entryIdx]; var entry = catalog[entryIdx];
@@ -81,6 +87,7 @@ namespace ProjectM.Server
// Commit IN-PLACE so a second same-tick request sees the spend + reservation. // Commit IN-PLACE so a second same-tick request sees the spend + reservation.
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount); StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
occupied.Add(cell); occupied.Add(cell);
if (req.StructureType == StructureType.Turret) turretCount++; // keep same-tick turret requests under the cap
var structure = ecb.Instantiate(entry.Prefab); var structure = ecb.Instantiate(entry.Prefab);
var xform = m_TransformLookup[entry.Prefab]; var xform = m_TransformLookup[entry.Prefab];
@@ -100,8 +100,9 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics); bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u; uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = envMask, GroupIndex = 0 }; uint sweepMask = envMask | worldCol.StructureMask; // DR-042 C5: also collide enemies against player-built walls
bool sweep = havePhysics && envMask != 0u; var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
bool sweep = havePhysics && sweepMask != 0u;
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
foreach (var (xform, stats, cooldown, knockback, windup, region) in foreach (var (xform, stats, cooldown, knockback, windup, region) in
@@ -26,7 +26,6 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<PlayerSpawner>();
} }
[BurstCompile] [BurstCompile]
@@ -37,14 +36,20 @@ namespace ProjectM.Server
return; return;
uint now = serverTick.TickIndexForValidTick; uint now = serverTick.TickIndexForValidTick;
var spawner = SystemAPI.GetSingleton<PlayerSpawner>(); // Resilient spawn reference: prefer the BaseAnchor plot center, fall back to the PlayerSpawner. NEVER
float3 center = spawner.SpawnPoint; // hard-require PlayerSpawner (a transiently-missing singleton must not strand dead players downed forever).
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor)) bool haveSpawner = SystemAPI.TryGetSingleton<PlayerSpawner>(out var spawner);
bool haveAnchor = SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor);
if (!haveSpawner && !haveAnchor)
return; // no spawn reference at all this tick — re-try next tick rather than mis-place
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
center = BaseGridMath.PlotCenter(baseAnchor); center = BaseGridMath.PlotCenter(baseAnchor);
foreach (var (health, respawn, invuln, xform, owner, eff) in foreach (var (health, respawn, invuln, xform, region, owner, eff) in
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>, SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>() RefRW<RegionTag>, RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
.WithAll<PlayerTag>()) .WithAll<PlayerTag>())
{ {
if (health.ValueRO.Current > 0f) if (health.ValueRO.Current > 0f)
@@ -66,8 +71,12 @@ namespace ProjectM.Server
health.ValueRW.Current = maxHealth; health.ValueRW.Current = maxHealth;
float3 pos = center + PlayerSpawnMath.SpawnOffset( float3 pos = center + PlayerSpawnMath.SpawnOffset(
owner.ValueRO.NetworkId, spawner.SpawnRingRadius, spawner.RingSlots); owner.ValueRO.NetworkId, ringRadius, ringSlots);
xform.ValueRW.Position = pos; xform.ValueRW.Position = pos;
// Death fix: respawn is at BASE, so the player's server-only RegionTag MUST return to Base too
// (every other mover flips RegionTag + Position together). Dying on an expedition otherwise leaves
// you at base coords still tagged Expedition -> RegionRelevancy hides all base ghosts (soft-brick).
region.ValueRW.Region = RegionId.Base;
// Grant brief post-respawn damage immunity so the swarm can't instantly re-kill. // Grant brief post-respawn damage immunity so the swarm can't instantly re-kill.
invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks)); invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks));
@@ -20,6 +20,10 @@ namespace ProjectM.Server
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once — /// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
/// the gate's once-per-epoch Ore reward reads that on the player's return. /// the gate's once-per-epoch Ore reward reads that on the player's return.
/// ///
/// DR-042 C7b: it ALSO writes the replicated <see cref="ExpeditionObjective"/> summary every tick, ABOVE the
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
/// never freezes stale even when nobody is out.
///
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself /// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the /// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
/// v1 plan first sketched) would close a CyclePhase-&gt;Field-&gt;Zone-&gt;CyclePhase sort cycle that throws at Play /// v1 plan first sketched) would close a CyclePhase-&gt;Field-&gt;Zone-&gt;CyclePhase sort cycle that throws at Play
@@ -51,26 +55,55 @@ namespace ProjectM.Server
return; return;
uint now = serverTick.TickIndexForValidTick; uint now = serverTick.TickIndexForValidTick;
// Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem). // Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
int expeditionPlayers = 0; int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>()) foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition) if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++; expeditionPlayers++;
if (expeditionPlayers == 0)
return; // nobody out there: the field manager owns teardown, we do nothing
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>(); var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity); var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity); var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>(); var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity); var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity); var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
int epoch = runtime.ExpeditionEpoch; int epoch = runtime.ExpeditionEpoch;
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
if (SystemAPI.HasComponent<ExpeditionObjective>(cycleEntity))
{
byte objState;
short objRemaining;
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
objRemaining = 0;
}
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (expeditionPlayers == 0)
return; // nobody out there: the field manager owns teardown, the spawner does nothing
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base // MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts. // siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
var bands = new MixBands var bands = new MixBands
@@ -94,8 +127,6 @@ namespace ProjectM.Server
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
} }
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
if (zs.RemainingToSpawn > 0) if (zs.RemainingToSpawn > 0)
{ {
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap. // Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
@@ -63,6 +63,8 @@ namespace ProjectM.Server
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal }); ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
bool restoredLedger = false;
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker). // ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity)) if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
{ {
@@ -79,6 +81,7 @@ namespace ProjectM.Server
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity); var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
var destLedger = ecb.SetBuffer<StorageEntry>(director); var destLedger = ecb.SetBuffer<StorageEntry>(director);
SaveApply.WriteLedger(srcLedger, destLedger); SaveApply.WriteLedger(srcLedger, destLedger);
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
// END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a // END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a
// persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full. // persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full.
@@ -99,6 +102,12 @@ namespace ProjectM.Server
ecb.DestroyEntity(pendingEntity); ecb.DestroyEntity(pendingEntity);
} }
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold
// deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended
// BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker).
if (!restoredLedger)
ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre });
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint. // Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
ecb.AddComponent(director, new SaveRequest { Pending = 0 }); ecb.AddComponent(director, new SaveRequest { Pending = 0 });
} }
@@ -159,18 +159,12 @@ namespace ProjectM.Server
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity)) if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
} }
else if (SystemAPI.HasComponent<GoalProgress>(cycleEntity)) // DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win
{ // path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win-
// Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the // driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by
// increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem // ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward
// only READS this edge to arm the final siege. // (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity); // above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target.
goal.Charge = math.min(goal.Charge + 1, goal.Target);
SystemAPI.SetComponent(cycleEntity, goal);
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
} }
} }
@@ -83,19 +83,35 @@ namespace ProjectM.Server
SystemAPI.SetComponent(threatEntity, threat); SystemAPI.SetComponent(threatEntity, threat);
} }
// Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was // Once-per-epoch zone-clear reward: a returner BANKS flat Ore to the shared ledger AND advances the
// actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op // long-arc win meter (DR-042 — EXPEDITION CLEARS, not survived base sieges, are the win-driver:
// returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2). // CyclePhaseSystem no longer credits Charge, so this is the sole PRODUCTION writer of GoalProgress.Charge).
if (SystemAPI.HasSingleton<CycleState>() // Resolved ONCE here (not per-returner) so two same-tick co-op returns pay exactly once (DR-040 BLOCKER 4)
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir) // and gate re-entry before a clear can't farm (MINOR 2). Ore + Charge share the SAME LastRewardedEpoch
&& SystemAPI.HasSingleton<ResourceLedger>()) // latch so they always share fate (never one without the other). The Charge credit is guarded
// independently of the ledger so it still lands in ledger-less worlds.
if (SystemAPI.HasSingleton<CycleState>())
{ {
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>(); var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity); var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch) if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
if (SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
&& SystemAPI.HasSingleton<ResourceLedger>())
{ {
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>()); var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre); StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
}
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
// +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer).
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge = math.min(goal.Charge + 1, goal.Target);
SystemAPI.SetComponent(cycleEntity, goal);
}
// Checkpoint the hard-won clear (replaces the deleted survived-siege autosave in CyclePhaseSystem).
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch; runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
SystemAPI.SetComponent(cycleEntity, runtime); SystemAPI.SetComponent(cycleEntity, runtime);
} }
@@ -65,9 +65,9 @@ namespace ProjectM.Simulation
public static TuningConfig Defaults() => new TuningConfig public static TuningConfig Defaults() => new TuningConfig
{ {
DashDistance = 4.0f, DashDistance = 4.0f,
IFrameWindowTicks = 12f, IFrameWindowTicks = 14f, // tune: was 12 (0.20s) -> 0.23s, i-frames better cover a reacted telegraph
RecoverTailTicks = 9f, RecoverTailTicks = 9f,
DashCooldownTicks = 45f, DashCooldownTicks = 36f, // tune: was 45 (0.75s) -> 0.60s, snappier horde-kiter cadence
DashSharpness = 200f, DashSharpness = 200f,
ChargerWindupTicks = 30f, ChargerWindupTicks = 30f,
ChargerLungeSpeed = 16f, ChargerLungeSpeed = 16f,
@@ -63,6 +63,17 @@ namespace ProjectM.Simulation
/// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining.</summary> /// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining.</summary>
public const int TurretChargeCostPerShot = 1; public const int TurretChargeCostPerShot = 1;
/// <summary>Max turrets buildable per base (server-enforced in BuildPlaceSystem; the client preview goes red at
/// the cap). Turrets are a deliberate fortress investment, not spammable — paired with the 40-Ore build cost.</summary>
public const int TurretCap = 6;
// ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ----
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps
/// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
public const int StartingOre = 50;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary> /// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
@@ -83,4 +83,29 @@ namespace ProjectM.Simulation
/// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty-&gt;occupied epoch bump. The reward fires only on a REAL clear.</summary> /// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty-&gt;occupied epoch bump. The reward fires only on a REAL clear.</summary>
public byte ClearedThisEpoch; public byte ClearedThisEpoch;
} }
/// <summary>
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost
/// (alongside <see cref="CycleState"/> / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
/// </summary>
public struct ExpeditionObjective : IComponentData
{
/// <summary>0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim).</summary>
[GhostField] public byte State;
/// <summary>Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared.</summary>
[GhostField] public short Remaining;
}
/// <summary>State constants for <see cref="ExpeditionObjective.State"/> (byte, not enum — Burst/serialization).</summary>
public static class ExpeditionObjectiveState
{
public const byte Idle = 0;
public const byte Active = 1;
public const byte Cleared = 2;
}
} }
@@ -6,8 +6,10 @@ namespace ProjectM.Simulation
/// <summary> /// <summary>
/// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in /// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in
/// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless /// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless
/// of region. SINGLE writer: <c>CyclePhaseSystem</c> increments <see cref="Charge"/> on each completed /// of region. Sole PRODUCTION writer (DR-042): <c>ExpeditionGateSystem</c> increments <see cref="Charge"/> by
/// cycle (Build -&gt; Expedition). The HUD observes it for a progress bar. /// one per cleared EXPEDITION (on the player's return). <c>GoalReachedSystem</c> only READS the Charge==Target
/// edge to arm the climactic final siege. (<c>DebugCommandReceiveSystem</c> is a manual dev-op writer.) The HUD
/// observes it for a progress bar.
/// </summary> /// </summary>
public struct GoalProgress : IComponentData public struct GoalProgress : IComponentData
{ {
@@ -11,7 +11,14 @@ namespace ProjectM.Simulation
/// </summary> /// </summary>
public struct WorldCollisionConfig : IComponentData public struct WorldCollisionConfig : IComponentData
{ {
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u &lt;&lt; layerIndex</c>).</summary>
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u &lt;&lt; layerIndex</c>).</summary> /// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u &lt;&lt; layerIndex</c>).</summary>
public uint EnvironmentMask; public uint EnvironmentMask;
/// <summary>DR-042 C5: BelongsTo bitmask of the "Structure" physics layer (player-built Wall/Turret/Pylon
/// colliders). OR'd into the enemy-movement sweep filter so husks collide-and-slide against walls. 0 if the
/// layer is absent (feature inert). The layer matrix excludes Structure&times;the player layer so the player CC
/// passes through its own walls.</summary>
public uint StructureMask;
} }
} }
+1 -1
View File
@@ -894,7 +894,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureCatalogAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureCatalogAuthoring
TurretPrefab: {fileID: 3885353946372160549, guid: 5459c9edea89bd94fa6f5043ae00eb40, type: 3} TurretPrefab: {fileID: 3885353946372160549, guid: 5459c9edea89bd94fa6f5043ae00eb40, type: 3}
TurretCostOre: 10 TurretCostOre: 40
WallPrefab: {fileID: 3885353946372160549, guid: 1e321aea244cc484f99c1cdd68cb01c4, type: 3} WallPrefab: {fileID: 3885353946372160549, guid: 1e321aea244cc484f99c1cdd68cb01c4, type: 3}
WallCostOre: 4 WallCostOre: 4
PylonPrefab: {fileID: 3885353946372160549, guid: 7d0637ef90f120a4c9e2ba637dfc00af, type: 3} PylonPrefab: {fileID: 3885353946372160549, guid: 7d0637ef90f120a4c9e2ba637dfc00af, type: 3}
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
/// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase /// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase
/// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so /// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so
/// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds /// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once; /// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm WITHOUT charging the goal (DR-042: expedition clears drive the win);
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math. /// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
/// </summary> /// </summary>
public class CyclePhaseSystemTests public class CyclePhaseSystemTests
@@ -99,7 +99,7 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once() public void Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal()
{ {
var (world, group) = MakeWorld("SiegeClears", serverTick: 200); var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
using (world) using (world)
@@ -114,8 +114,8 @@ namespace ProjectM.Tests
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase, Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"A cleared siege returns to Calm."); "A cleared siege returns to Calm.");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge, Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
"One goal charge accrues per siege survived (single writer)."); "DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed).");
} }
} }
@@ -35,8 +35,8 @@ namespace ProjectM.Tests
return (world, group); return (world, group);
} }
// Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s. // Dash speed derived from the live knobs: DashDistance / (IFrameWindowTicks/60). Tracks TuningConfig.Defaults().
const float ExpectedDashSpeed = 20f; static readonly float ExpectedDashSpeed = TuningConfig.Defaults().DashDistance / (TuningConfig.Defaults().IFrameWindowTicks / 60f);
static Entity MakeDasher(EntityManager em, float2 facing) static Entity MakeDasher(EntityManager em, float2 facing)
{ {
@@ -69,7 +69,7 @@ namespace ProjectM.Tests
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity; var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0."); Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0."); Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20)."); Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (derived from the dash knobs).");
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"i-frame window raises GroundedMovementSharpness to ~200 (the blink)."); "i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
} }
@@ -133,9 +133,9 @@ namespace ProjectM.Tests
var ds = em.GetComponentData<DashState>(e); var ds = em.GetComponentData<DashState>(e);
Assert.AreEqual(100u, ds.StartTick, "StartTick = now."); Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12."); Assert.AreEqual(114u, ds.IFrameUntilTick, "IFrameUntilTick = now + 14.");
Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9."); Assert.AreEqual(123u, ds.RecoverUntilTick, "RecoverUntilTick = now + 14 + 9.");
Assert.AreEqual(145u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 45."); Assert.AreEqual(136u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 36.");
} }
} }
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
/// <see cref="ThreatDirectorSystem"/> SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a /// <see cref="ThreatDirectorSystem"/> SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a
/// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/ /// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/
/// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger /// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger
/// final siege EXACTLY once and CyclePhaseSystem enters it once; Charge clamps to Target; a survived final siege /// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege
/// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1 /// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1
/// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft /// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft
/// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the /// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the
@@ -127,21 +127,25 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Charge_Clamps_To_Target_On_Survived_Siege() public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final()
{ {
var (world, group) = MakeWorld("End2Clamp", serverTick: 200); var (world, group) = MakeWorld("End2Clamp", serverTick: 200);
using (world) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
// Charge already AT Target, a NORMAL (non-final) siege is survived -> Charge must not exceed Target. // DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final.
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4,
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared
group.Update(); group.Update();
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge, Assert.AreEqual(3, em.GetComponentData<GoalProgress>(dir).Charge,
"Charge clamps at Target on a survived siege (min(Charge+1, Target)); it never runs away."); "a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress).");
Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData<RunPhase>(dir).Value,
"the final siege is NOT armed by a survived siege near the cap.");
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
"nothing is armed (the cap is only crossed by an expedition clear).");
} }
} }
@@ -314,13 +318,13 @@ namespace ProjectM.Tests
public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler() public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler()
{ {
// M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the // M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the
// Charge edge (3 -> 4 via a survived siege), then prove a DUE scheduled source can't stomp the armed final // Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final
// siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave. // siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave.
var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200); var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200);
using (world) using (world)
{ {
var em = world.EntityManager; var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4, var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100; var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100;
em.SetComponentData(dir, cfg); em.SetComponentData(dir, cfg);
@@ -328,9 +332,10 @@ namespace ProjectM.Tests
var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick
int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27 int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27
// Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Charge 3->4, Calm) -> GoalReached (arm). // Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm).
group.Update(); group.Update();
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge, "the survived siege reaches the cap."); Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
"Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042).");
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value, Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
"GoalReached flips FinalDefense the same tick the Charge edge is crossed."); "GoalReached flips FinalDefense the same tick the Charge edge is crossed.");
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
@@ -10,9 +10,11 @@ namespace ProjectM.Tests
{ {
/// <summary> /// <summary>
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into /// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger /// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4 + DR-042). A returning player banks flat Ore to the
/// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same /// shared ledger AND advances the long-arc win meter (GoalProgress.Charge — DR-042: EXPEDITION CLEARS, not
/// epoch (the co-op same-tick / gate-re-entry de-dup). /// survived sieges, are the win-driver) IFF this epoch's expedition wave was actually cleared and not yet
/// rewarded — and never twice for the same epoch (the co-op same-tick / gate-re-entry de-dup; Ore + Charge
/// share the one LastRewardedEpoch latch so they always share fate).
/// </summary> /// </summary>
public class ExpeditionGateRewardTests public class ExpeditionGateRewardTests
{ {
@@ -26,13 +28,15 @@ namespace ProjectM.Tests
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager; var em = world.EntityManager;
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state. // CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state + goal meter.
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState)); var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger),
typeof(ThreatState), typeof(GoalProgress));
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm }); em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
em.SetComponentData(cyc, new CycleRuntime em.SetComponentData(cyc, new CycleRuntime
{ {
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch, ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
}); });
em.SetComponentData(cyc, new GoalProgress { Charge = 0, Target = 4 });
em.AddBuffer<StorageEntry>(cyc); em.AddBuffer<StorageEntry>(cyc);
// Zone-enemy director singleton (only RewardOre matters to the reward fold). // Zone-enemy director singleton (only RewardOre matters to the reward fold).
@@ -68,7 +72,7 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Cleared_Return_Banks_Ore_Once() public void Cleared_Return_Banks_Ore_And_Charge_Once()
{ {
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0); var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
using (world) using (world)
@@ -79,6 +83,8 @@ namespace ProjectM.Tests
group.Update(); // player walks the gate back to base -> reward group.Update(); // player walks the gate back to base -> reward
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger"); Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
"DR-042: a cleared return also advances the win meter by one (the new win-driver).");
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded"); Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
// Force a second same-epoch return (the player is back in the expedition at the gate). // Force a second same-epoch return (the player is back in the expedition at the gate).
@@ -88,6 +94,26 @@ namespace ProjectM.Tests
group.Update(); // returns again, but the epoch was already rewarded group.Update(); // returns again, but the epoch was already rewarded
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)"); Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
"the same epoch never double-credits the win meter either (shared LastRewardedEpoch latch).");
}
}
[Test]
public void Cleared_Return_Clamps_Charge_At_Target()
{
// DR-042: the win credit clamps at Target (min(Charge+1, Target)) — a cleared return at the cap never overshoots.
var (world, group, cyc) = MakeWorld("GateRewardClamp", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
em.SetComponentData(cyc, new GoalProgress { Charge = 4, Target = 4 }); // already at the cap
MakeExpeditionPlayerAtGate(em);
group.Update();
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(cyc).Charge,
"a cleared return at the cap clamps at Target (never overshoots).");
} }
} }
@@ -103,6 +129,8 @@ namespace ProjectM.Tests
group.Update(); group.Update();
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)"); Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cyc).Charge,
"an uncleared return advances neither Ore nor the win meter.");
} }
} }
} }
@@ -0,0 +1,211 @@
using NUnit.Framework;
using ProjectM.Client;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-logic coverage for the first-run onboarding step machine (<see cref="OnboardingStepMath"/>) — the
/// testable core of the client-only <c>OnboardingSystem</c>. No World/ECS needed: each case builds a
/// <see cref="OnboardingStepMath.Snapshot"/> and asserts the deterministic advance rule, the mask helpers,
/// the scheme-aware prompts, and the pointer kinds.
/// </summary>
public class OnboardingStepTests
{
static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot();
// ---- mask helpers (resume point + dormant detection) ----
[Test]
public void FirstIncomplete_EmptyMask_IsWelcome()
=> Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0));
[Test]
public void FirstIncomplete_SkipsCompletedPrefix()
{
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
}
[Test]
public void AllComplete_TrueForFullMaskAndMigrationSentinel()
{
Assert.IsFalse(OnboardingStepMath.AllComplete(0));
int full = (1 << OnboardingStepMath.StepCount) - 1;
Assert.IsTrue(OnboardingStepMath.AllComplete(full));
Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done
}
// ---- per-step completion rules ----
[Test]
public void Welcome_AdvancesOnTimer()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
s.StepElapsed = OnboardingStepMath.WelcomeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
}
[Test]
public void Move_AdvancesAfterThreshold()
{
var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
s.MoveDistance = OnboardingStepMath.MoveThreshold;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
}
[Test]
public void Mine_AdvancesOnlyWhenLedgerOreRises()
{
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
s.OreNow = 51;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
}
[Test]
public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase()
{
var s = Empty(); s.TurretCount = 0;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
}
[Test]
public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout()
{
var none = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none));
var built = Empty(); built.FabricatorCount = 1;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built));
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut));
}
[Test]
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
{
var s = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
var onExp = Empty(); onExp.OnExpedition = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp));
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
}
[Test]
public void Clear_AdvancesOnClearedObjective()
{
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
}
[Test]
public void Return_AdvancesOnLeavingExpedition()
{
var s = Empty(); s.OnExpedition = true;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
s.OnExpedition = false;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
}
[Test]
public void Defend_WaitsForSiegeEndButTimesOutWithoutOne()
{
var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid));
var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived));
var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege));
}
[Test]
public void Done_LingersThenCompletes()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
s.StepElapsed = OnboardingStepMath.DoneSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
}
// ---- prompts (scheme-aware, never empty) ----
[Test]
public void Prompts_NonEmptyForEveryStep()
{
for (byte i = 0; i < OnboardingStepMath.StepCount; i++)
{
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i);
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i);
}
}
[Test]
public void Prompts_AreSchemeAware()
{
StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false));
StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false));
StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true));
}
// ---- pointer kinds ----
[Test]
public void PointerKinds_MatchSpatialStepsOnly()
{
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
}
}
/// <summary>
/// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check
/// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is
/// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke).
/// </summary>
public class OnboardingSettingsTests
{
[Test]
public void Defaults_TutorialOn_MaskEmpty()
{
var d = GameSettings.Defaults();
Assert.AreEqual(1, d.TutorialHints);
Assert.AreEqual(0, d.OnboardingMask);
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
}
[Test]
public void Clamped_NormalizesHints_PreservesMask()
{
var s = GameSettings.Defaults();
s.TutorialHints = 5; // out of the 0/1 range
s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched
var c = s.Clamped();
Assert.AreEqual(1, c.TutorialHints);
Assert.AreEqual(0x55, c.OnboardingMask);
}
[Test]
public void Defaults_ForceEachLaunch_Off()
=> Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch);
[Test]
public void Clamped_NormalizesForceEachLaunchToBool()
{
var s = GameSettings.Defaults();
s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag
Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch);
s.ForceOnboardingEachLaunch = 0;
Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b7226d166f601d43add545e1532c3e1
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth }); em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth });
em.AddComponent<PlayerTag>(e); em.AddComponent<PlayerTag>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e; return e;
} }
@@ -103,5 +104,27 @@ namespace ProjectM.Tests
Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched."); Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched.");
} }
} }
[Test]
public void Expedition_Death_Respawns_At_Base_And_Resets_RegionTag()
{
// Death-fix regression: a player who dies ON an expedition (RegionTag=Expedition) must respawn at base
// AND have its RegionTag reset to Base, or it soft-bricks (RegionRelevancy hides all base ghosts).
var (world, group) = MakeWorld("RespawnExpedition", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 160,
delayTicks: 60, invulnTicks: 120, pos: new float3(1005, 1, 3), networkId: 1);
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); // died out on the sortie
group.Update();
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
"Death fix: respawn resets RegionTag to Base (else the player is stranded tagged Expedition).");
Assert.Less(em.GetComponentData<LocalTransform>(player).Position.x, 100f,
"And is repositioned from the expedition (x~1005) back to the base ring.");
}
}
} }
} }
@@ -24,9 +24,9 @@ namespace ProjectM.Tests
{ {
var d = TuningConfig.Defaults(); var d = TuningConfig.Defaults();
Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance"); Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance");
Assert.AreEqual(12f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks"); Assert.AreEqual(14f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks");
Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks"); Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks");
Assert.AreEqual(45f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks"); Assert.AreEqual(36f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks");
Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness"); Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness");
Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks"); Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks");
Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed"); Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed");
+2 -2
View File
@@ -11,7 +11,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs. - **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
- Condensation history: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive). - Condensation history: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive).
## Stack — Unity 6.5.0 (`6000.5.0f1`, stable) as of 2026-06-17 ## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27
| Package | Version | Notes | | Package | Version | Notes |
|---|---|---| |---|---|---|
@@ -143,7 +143,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. - `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above). - **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
- **Core loop is base-local ★ (DR-031):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner` singleton; `SetComponent`-override Region+Ore — Add throws; Ore-only). Scheduled base sieges via `ThreatDirectorSystem`'s reserved **Schedule** source (`ScheduleEnabled`/`Interval`/`SizePerWave` on `CycleDirectorAuthoring`) need NO expedition trip; `ExpeditionFieldSystem` teardown region-filtered Expedition-only (else it wipes the base field). The now-dormant expedition still lives at `base+(1000,0,0)`, hidden per-connection via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]]. - **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]].
## DOTS / ECS conventions (authoritative summary) ## DOTS / ECS conventions (authoritative summary)
+2 -2
View File
@@ -29,9 +29,9 @@ Last decluttered 2026-06-08 (removed all shipped `[x]` items; their context is p
A 5-subsystem loop evaluation found the loop has **two conflicting win-models bolted together**: the only path to victory is "survive 4 base sieges" (passively/AFK-reachable — scheduled sieges auto-arm + a 60 s timeout auto-clears them), while the **expedition (the stated combat spine, where all the new enemy variety lives) advances nothing toward winning.** Root cause: the END-1/END-2 base-siege win is a leftover from the superseded jam slice ([[DR-035_End_Of_Month_Slice_Adoption]]/[[DR-036_END2_Final_Siege_Win_Lose]]) that DR-037 never retired. **Operator chose (2026-06-24): commit to the expedition-driven vision.** Build order: A 5-subsystem loop evaluation found the loop has **two conflicting win-models bolted together**: the only path to victory is "survive 4 base sieges" (passively/AFK-reachable — scheduled sieges auto-arm + a 60 s timeout auto-clears them), while the **expedition (the stated combat spine, where all the new enemy variety lives) advances nothing toward winning.** Root cause: the END-1/END-2 base-siege win is a leftover from the superseded jam slice ([[DR-035_End_Of_Month_Slice_Adoption]]/[[DR-036_END2_Final_Siege_Win_Lose]]) that DR-037 never retired. **Operator chose (2026-06-24): commit to the expedition-driven vision.** Build order:
- **A — Coherence core (first):** move the win-driver from *base sieges**expedition clears* (`GoalProgress.Charge` +1 on a cleared sortie); **kill the AFK win** (disable the blind scheduled siege as a progression source); final beat reached through the spine. *Netcode-touching → design-review first.* - **A — Coherence core — ✅ BUILT + validated (2026-06-25):** win-driver moved *base siegesexpedition clears* `ExpeditionGateSystem` is now the sole production writer of `GoalProgress.Charge` (+1/clear, credited **on RETURN** — the review overturned credit-on-clear, which would arm the undefended final siege → uncontestable Loss; credit-on-return keeps the player home). **AFK win killed** (`ScheduleEnabled` baked OFF, retaliation-only; survived sieges no longer credit). Final beat = the END-2 final **base** siege (operator-locked). 389/389 EditMode + clean Play smoke (no sort-cycle, `ScheduleEnabled=0`). Design-review-gated (`wf_ebef4e81-dba`). Fun-gate playtest pending. *Next: **C**.*
- **B — Retaliation connect:** post-expedition siege becomes THE base-siege source (fix the interference) — defending what you built becomes a *consequence* of sortieing, not the goal. *Netcode-touching → design-review.* - **B — Retaliation connect:** post-expedition siege becomes THE base-siege source (fix the interference) — defending what you built becomes a *consequence* of sortieing, not the goal. *Netcode-touching → design-review.*
- **C — Legibility fixes:** walls actually block (structures on the enemy collision filter); Aether-upgrade HUD button + cost; Biomass sink (or cut); cold-start ledger seed; hide dead Harvester/Conveyor/Pylon; expedition objective UI + gate prompt; reward scales with depth. - **C — Legibility fixes — ✅ BUILT + validated (2026-06-25):** walls now block enemies (dedicated `Structure` physics layer; player passes own walls — Play-verified baked filters); Aether-upgrade HUD button + affordability tint; Biomass sink (Wall cost → Biomass, operator fork); cold-start Ore seed (kills the turret-before-fabricator deadlock); dead Harvester/Conveyor/Pylon hidden from palette + hotkeys; **expedition objective readout** (new replicated `ExpeditionObjective` GhostField) + gate prompt. Reward-depth scaling deferred (operator fork). Scoping/design-gated (`wf_7c5a555e-136`); 389/389 EditMode + Play smokes. *Visual fun-gate pending. Next: B (retaliation polish) → D.*
- **D — Persistent meta (= Slice 4):** SaveData v6 + between-runs growth (above). - **D — Persistent meta (= Slice 4):** SaveData v6 + between-runs growth (above).
**Consolidation:** [[DR-036_END2_Final_Siege_Win_Lose]]'s "survive-4-base-sieges" win is **superseded** (win-driver → expedition clears; the charge-cadence "siege-survived-only" lock is reversed); [[DR-034_END1_Losable_Core]]'s Core stays but as a **consequence of the retaliation siege**, not the win-gate. The health-bar fill bug-fix (sprite-less `Image.Filled` ignores `fillAmount` → size the RectTransform) rides the first commit. **Consolidation:** [[DR-036_END2_Final_Siege_Win_Lose]]'s "survive-4-base-sieges" win is **superseded** (win-driver → expedition clears; the charge-cadence "siege-survived-only" lock is reversed); [[DR-034_END1_Losable_Core]]'s Core stays but as a **consequence of the retaliation siege**, not the win-gate. The health-bar fill bug-fix (sprite-less `Image.Filled` ignores `fillAmount` → size the RectTransform) rides the first commit.
@@ -0,0 +1,35 @@
---
title: Deferred Combat-Feel Items — Body Hit-Flash + Remote Co-op Swings + Strike Beep — Build
date: 2026-06-27
tags: [session, combat, juice, presentation, rukhanka, entities-graphics, netcode, co-op]
permalink: gamevault/07-sessions/2026/2026-06-27-deferred-feel-items
---
# Deferred combat-feel items — Build session
Cleared the three focused follow-ups that the combat-overhaul pass had explicitly deferred (named in commit `c3b53cef2` + the [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] "needs its own ShaderGraph slice" note). All three are **client-only, observe-only presentation** (PresentationSystemGroup; no sim mutation, no `[GhostField]`, no server work, rollback-irrelevant).
## What shipped
- **Item 1 — TRUE body hit-flash (resolves the DR-041 deferral).** New `EnemyHitFlashSystem` (client `SystemBase`, PresentationSystemGroup). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds gameplay components (`Health`, `EnemyTag`); the visible meshes are `LinkedEntityGroup` CHILD render entities (each with `Unity.Rendering.MaterialMeshInfo` + the `Shader Graphs/AnimatedLitShader` material, `_BaseColor` baked white). The system drives the **built-in Entities-Graphics per-instance override `Unity.Rendering.URPMaterialPropertyBaseColor`** (`[MaterialProperty("_BaseColor")]`) on those children: a `Health`-decrease edge lerps `_BaseColor` toward `FeelConfig.BodyFlashColor` (HDR near-white) and decays back to white. **No ShaderGraph edit, no new component type** — the original deferral assumed a custom `_Flash*` graph property was required; the reusable shortcut is the stock EG `URP*MaterialProperty*` components, which the deformation shader already honors.
- **Item 2 — co-op REMOTE swing arcs.** `CombatFeedbackSystem` now renders each remote teammate's melee cleave: a per-remote-player pooled `RemoteSlash` (Mesh+Material+GO), edge-detected from the **replicated `MeleeCombo.SwingStartTick`** on interpolated teammates (`.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>()`), reusing the refactored `BuildSlashInto(mesh, …)` sweep. The local player keeps its dedicated `_slashMr`. (`MeleeCombo` replicates to non-owners — `PlayerAnimationDriveSystem.RemoteDriveJob` already relies on this for teammate attack anim.)
- **Item 3 — near-impact strike beep.** Folded into the existing enemy danger-cone loop (which already computes `remaining` ticks to impact): a once-per-windup `_strikeBeepClip` "dodge NOW" cue at `StrikeBeepLeadTicks` (default 8 ≈ 130 ms) before the strike lands, distance-gated to the local player.
- 9 new `FeelConfig` knobs (+ `ResetDefaults`) covering all three.
## How it went (verify ladder)
- Drove the whole thing off an **empirical Play probe** of a live Husk: confirmed render entities are LEG children (not the root), shader = `AnimatedLitShader`, `_BaseColor` = white, and the children do **not** ship `URPMaterialPropertyBaseColor` until added.
- **Override-works proof:** the unfocused editor kept disposing the netcode worlds mid-Play (known hazard) and server-spawned test husks were culled (spawned outside the director's bookkeeping), so I proved the mechanism on the **local player** (same `AnimatedLitShader`, persistent, centered): tinted its render children via the override → captured the Game view → **body rendered red**. Same shader ⇒ the enemy flash works. Separately confirmed `EnemyHitFlashSystem` attaches the override to all 4 enemy render children at runtime.
- **390/390 EditMode**, clean compile, zero Play exceptions with all three paths live.
## Post-impl adversarial review (`wf_8a998c6c-af9`)
3 lenses (ECS/Entities-Graphics correctness · lifecycle/leaks/rollback · netcode-read edge cases). **No critical/major.** Fixed 4 real minors:
- **[FIXED] Strike beep could fire spuriously at base origin** — the "no local player ⇒ `localPos`=`float3.zero` ⇒ distance gate suppresses" assumption is FALSE: origin IS the base, so base-siege enemies within 15 m would beep before the local player ghost resolves (co-op join / save-load mid-siege). Gated the beep on `_localPlayer != Entity.Null`.
- **[FIXED] `BodyFlashEnabled` toggled off mid-flash froze an enemy tinted** (reachable via the FeelConfig tuning toggle). Added `RestoreAllToRest()` on the disable edge (settle white + clear; re-tracked on re-enable).
- **[FIXED] `RenderKids` captured once with no recovery** — don't finalize tracking until render children exist (retry next frame if Rukhanka child setup lags ghost instantiation).
- **[FIXED] `OnDestroy` symmetry** — also destroy the pooled remote-slash `GO`.
- **[NOTED, no change]** hardcoded white-restore (moot — every enemy uses the Synty-atlas white-`_BaseColor` convention; documented in the system); runtime `AddComponent` fragmentation (bounded, once/child); committed-Charger lunge relies on the cone not the beep (intended).
## Gotchas worth remembering
- **Material-driven body flash on Rukhanka/EG = drive a stock `URP*MaterialProperty*` component on the render-entity CHILDREN, not a custom ShaderGraph property.** The render entities are `LinkedEntityGroup` children with `MaterialMeshInfo` (the root has none); `_BaseColor` bakes white, so flash-toward-color / decay-to-white needs no per-material rest capture. Add the override at runtime (once/child) from a client observe-only system; settle to white at rest so the override is invisible when idle.
- **To prove an EG per-instance override is honored by a deformation shader without a stable enemy:** tint the **local player** (same material) and screenshot — the player is persistent + centered, unlike server-spawned test enemies which get culled and unlike the worlds which the unfocused editor disposes.
See [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] (item-1 origin) · [[DR-022_Animation_Pipeline_Rukhanka_Synty]] (render-entity structure) · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (danger-cone the beep folds into).
@@ -0,0 +1,33 @@
---
title: First-Run Onboarding — design decision-tree + offline build (Unity GPU-crash session)
date: 2026-06-28
tags: [session, onboarding, ux, hud, client-only, presentation, dots-dev]
permalink: gamevault/07-sessions/2026/2026-06-28-first-run-onboarding
---
# First-run onboarding — session
`/dots-dev` session on the operator's brief: *"This game needs an onboarding style type of thing, plan something that makes sense."* Full decision: [[DR-043_First_Run_Onboarding]].
## How it went
1. **Ground** — 5-agent read-only fan-out (`wf_670a0cdf-832`) mapped the onboarding-relevant surfaces (HUD/UITK, controls, the macro loop, economy/build, frontend lifecycle) + an exhaustive search confirming **no onboarding/tutorial/help exists anywhere**. (2 of 5 mappers returned degenerate stubs; the 3 working ones triangulated the rest, so no re-run was needed.)
2. **Forks** — operator asked to drive it **decision-tree style**. Two rounds of `AskUserQuestion` (3 + 4 forks) locked the 7 design decisions (table in DR-043). A genre-precedent research pass (`wf_f41c8423-68b`, NN/g + CHI-2012 + DRG/Riftbreaker/CotL/Hades/Helldivers/Remnant) backed every recommendation; the operator chose the recommended option on all 7.
3. **Build** — mid-session the editor began **crashing randomly**. Diagnosed from `Editor.log`: empty managed stack + faulting `dxgi.dll`, GPU = **RTX 5060 Ti, driver 32.0.16.1062** → a **GPU/driver (TDR) fault, not project code** (recurring `Unity.exe.*.dmp`). Operator chose "peek at the crash, then code." Pivoted to building the whole feature **via the filesystem** (decoupled from the unstable bridge), with a single `refresh_unity force` deferred to when the editor is back.
4. **Static review in lieu of a compiler** — 3-lens adversarial review (`wf_d804925a-f7b`) verified every symbol against source: **Lens 1 compile/API = clean PASS**. Fixed 1 major (the v1→v2 migration dropped returning players' graphics settings) + 4 minors (Esc/pause copy + self-skip, pause-freeze for timed beats, static reset on teardown, same-frame HUD suppression ordering).
## What shipped (code-complete, NOT yet validated)
Contextual coach-marks (`OnboardingSystem` — client-only observe-only, own UIDocument @ sortingOrder 60) running the full first lap soft-gated, a world-space `▶` pointer, a tiny welcome strip naming the inverted win goal, a tabbed **How-to-Play** card (menu + pause), a Settings **Tutorial Hints** toggle + **Replay Tutorial**, all per-client via a client-local `GameSettings.OnboardingMask` (v1→v2 additive). Pure logic in `OnboardingStepMath` with `OnboardingStepTests`. **Zero netcode/replication/bake surface.** Files in DR-043.
## Validation — DONE (2026-06-29, editor stable)
**Green.** `refresh_unity force` → console clean → **20/20 EditMode** (incl. 2 new `ForceOnboardingEachLaunch` cases) → Play smoke proved the new dev toggle (seeded a veteran `int.MaxValue` mask + `force=1` → Boot wiped it → tutorial replayed from Welcome), confirmed **no system sort-cycle** from `HudSystem`'s `[UpdateAfter(OnboardingSystem)]`, and verified the `▶` pointer renders as a clean **U+25B6 triangle** (not tofu) + HUD hint suppression. Also shipped this session: a dev **Force Each Launch** onboarding toggle (`GameSettings.ForceOnboardingEachLaunch` + `SettingsService.Boot` per-launch wipe + a Settings cycle row). The deferred CLAUDE.md gotcha was parked in `_Meta/CLAUDE_Build_Gotchas_Archive.md` (file at cap; one-time pattern doesn't earn inline space). Full detail in [[DR-043_First_Run_Onboarding]].
### Original plan (for the record — now executed)
Not compiled/tested/Play-run yet. When the editor is stable: `refresh_unity scope=all mode=force` (the 5 new `.cs` have no `.meta`) → `read_console` clean → `run_tests` EditMode `ProjectM.Tests.EditMode` → Play smoke (welcome+step1 on a fresh client; dormant when hints off / mask full; per-client; no sort-cycle from the new `[UpdateAfter]`) → L3 screenshots (strip / pointer / card; confirm the `▶` glyph renders). Then add the deferred **CLAUDE.md** gotcha line (first-run flags → client-local `GameSettings`, not host `SaveData`) once green.
## Gotchas worth remembering
- **Unity random/idle crashes on this machine = the RTX 5060 Ti driver (`dxgi.dll`), not code.** Fix path: DDU + NVIDIA Studio driver / disable HAGS / `-force-d3d11`. (Native memory: `gpu-crash-dxgi-driver`.)
- **When the editor is unstable, write Assets `.cs` via the filesystem + one `refresh_unity force` later** instead of per-edit MCP `create_script` — the bridge dies with the editor; a static adversarial review can stand in for the compiler.
- **A version bump on an additive settings/save struct re-activates the migration path for every existing file** — migrate by carrying forward the old value and seeding only new fields, never by rebuilding from `Defaults()` (else you silently reset untouched fields). Caught by the post-impl review.
## Next-session intent
~~Get the editor stable (driver), run the verify ladder for DR-043~~ — **done 2026-06-29** (green). Remaining: the **operator fun-gate** — a real first-run playthrough to feel whether the welcome framing lands, the pointers read, and it stays un-naggy (and to confirm the Welcome→Move→Mine→Build→Fabricator→Gate→Clear→Return→Defend→Done cascade paces well with actual input). The dev **Force Each Launch** toggle (Settings → Onboarding) makes that repeatable.
@@ -75,10 +75,34 @@ The game has **two disconnected win-models that fight each other**:
- **[[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]:** this DR is the concrete loop the redirect implied. Slice 4 (Persistent Meta) is **re-framed**: the loop-coherence work (AC) comes first; the meta/SaveData-v6 (D) is the final phase. - **[[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]:** this DR is the concrete loop the redirect implied. Slice 4 (Persistent Meta) is **re-framed**: the loop-coherence work (AC) comes first; the meta/SaveData-v6 (D) is the final phase.
- **Roadmap:** [[Path_to_Fun]] (Path A/B) was already historical post-DR-037; this is the current operative loop plan. The committed list is in [[Backlog]] under the Co-op Roguelite Redirect. - **Roadmap:** [[Path_to_Fun]] (Path A/B) was already historical post-DR-037; this is the current operative loop plan. The committed list is in [[Backlog]] under the Co-op Roguelite Redirect.
## Open forks (operator, lock before/at the design review) ## Open forks — RESOLVED (operator, 2026-06-25)
- **Final beat:** a **final expedition** (the climax IS a deep sortie — purest expression of "expedition = spine") vs a **final base siege** (a defense climax, reuses END-2's final-siege machinery). *Recommend present both at the A-phase review.* - **Final beat:** **final base siege** (defense climax, reuses END-2's final-siege machinery) *operator-chosen*. With credit-on-RETURN the capping return arms the climactic siege while the player is freshly home, so the build pillar pays off at the finish. (A *final expedition* was the alternative; deferred.)
- **Do base sieges stay in v1 at all,** or does retaliation become a later layer (B) so the first coherent build is purely sortie→reward→escalate? *(The recommendation keeps a light retaliation siege so the build pillar isn't orphaned.)* - **Base sieges stay in v1** as **post-expedition retaliation only** (the build pillar isn't orphaned). The blind scheduled source is disabled.
- Expedition reward shape + depth scaling; whether a soft-loss should cost `GoalProgress` (give the run real downside). - Expedition reward shape + depth scaling, and whether a soft-loss costs `GoalProgress` — deferred to phase C/tuning.
## Status ## Status
Accepted + locked (direction). Build pending — phase A first, design-review-gated. Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]]. **Phase A — BUILT + validated (2026-06-25).** Design-review-gated (`wf_ebef4e81-dba`, GREEN-WITH-CHANGES). Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]].
### Phase A build record — what shipped (and the design correction)
The review overturned the literal A1 sketch. **The win credit fires on the player's RETURN, not at the expedition-clear edge.** Crediting at the clear edge (while the player is still out in the expedition) would arm the climactic final *base* siege with nobody home — and `SiegeTimeout` is disabled in the final — so the undefended Core would breach into an **uncontestable terminal Loss**. Credit-on-return guarantees the player is teleported home the same tick the final arms.
The implementation **relocates** the single writer of `GoalProgress.Charge` (it does not add a second writer):
- **`ExpeditionGateSystem`** is now the sole *production* writer of `GoalProgress.Charge`: folded a clamped `+1` (`math.min(Charge+1, Target)`) + a `SaveRequest` checkpoint into the **existing** once-per-epoch reward block, reusing the same `LastRewardedEpoch` latch that gates the Ore reward (Ore + Charge share fate; co-op/re-entry de-dup is free). No new latch field, **no new `[GhostField]`**, no ghost re-hash. The credit is guarded independently of the ledger so it still lands in ledger-less worlds.
- **`CyclePhaseSystem`** no longer credits Charge on a survived base siege (the AFK win path deleted); the final-siege Victory latch is unchanged. `GoalReachedSystem` still arms the climactic final siege at `Charge>=Target`.
- **`ScheduleEnabled` baked OFF** (both the `CycleDirectorAuthoring` code default *and* the serialized `CycleDirector.prefab` value — the code default alone does not flip an already-serialized prefab field; Play-verified `ScheduleEnabled=0` at runtime). The schedule code path is kept as a config-inert reserved hook; `ScheduleSizePerWave` left non-zero (the final-siege size formula still reads it). Retaliation (`PostExpeditionEnabled`) stays on.
- **No system-ordering change** → no sort-cycle risk (Gate is already `[UpdateBefore(CyclePhaseSystem)]`, so the credit lands before `GoalReachedSystem` reads the edge same-tick). SaveData stays **v5**; legacy v5 saves load as-is (their old siege-era Charge reads as clears — accepted for single-slot dev saves).
- **Validation:** 389/389 EditMode (re-pointed `CyclePhaseSystemTests` + `EndgameWinLoseTests` survived-siege assertions; extended `ExpeditionGateRewardTests` with the +1, no-double-credit, and clamp cases) + a clean netcode Play smoke (world boots, no sort-cycle, `ScheduleEnabled=0`, no exceptions). **Fun-gate playtest still pending** (the base reads as inert until you walk to the gate — the gate-prompt UI is phase C, accepted caveat).
### Phase C build record — BUILT + validated (2026-06-25)
Scoping/design-gated (`wf_7c5a555e-136`). Legibility cleanup that makes the loop self-explain. Shipped in two commits:
- **C7b objective readout** — new replicated `ExpeditionObjective{[GhostField] byte State, short Remaining}` on the untagged CycleDirector ghost (cross-region safe). Sole writer `ZoneEnemyDirectorSystem`, written ABOVE its early-returns (snapshot-above-early-return). Play-verified it replicates server→client.
- **C7a gate prompt + HUD readout** — `HudSystem` shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS — N remaining" / "CLEARED — return to claim" (below the siege/overrun overrides).
- **C6a Aether upgrade button** — un-gated `BuildSendSystem.UpgradeAbility`; `HudSystem` button + affordability tint (was U-key only).
- **C6c cold-start seed** — `CycleDirectorSpawnSystem` seeds `Tuning.StartingOre`(50) on a NEW game only (born-correct, pre-Playback). Kills the silent turret-before-fabricator deadlock. Play-verified `seededOre=50`.
- **C6b Biomass sink** — Wall cost Ore→Biomass (the dead currency now has a home). Play-verified.
- **C6d palette declutter** — dead Pylon/Harvester/Conveyor hidden from the palette + dev hotkeys (code-intact).
- **C5 walls block enemies** — dedicated `Structure` physics layer (slot 9) + `WorldCollisionConfig.StructureMask`; `EnemyAISystem` ORs it into the sweep filter; Wall/Turret/Pylon get cell-sized colliders on the Structure layer; matrix `Default×Structure` unchecked so the player CC passes its own walls while enemies are stopped. **Play-verified baked filters:** `StructMask=512`, structure `BelongsTo=512 CollidesWith=0xFFFFFFFE` (excludes the player/Default bit). Server-only/static → deterministic, despawn frees collision for free.
389/389 EditMode + clean Play smokes throughout. SaveData stays **v5**. The Biomass-for-walls choice + defer-reward-scaling were operator forks (2026-06-25). Open: a **visual fun-gate** (husk stops at wall / player walks through; objective readout reads clearly) and the optional gate-direction arrow (deferred).
### Next phases: **B** (retaliation polish — mostly satisfied by A2 already; tune the post-expedition siege feel) → **D** (Slice 4 persistent meta, SaveData v6).
@@ -0,0 +1,71 @@
---
id: DR-043
title: First-Run Onboarding — contextual coach-marks + replayable How-to-Play card
status: accepted
date: 2026-06-28
tags:
- decision
- design
- onboarding
- ux
- hud
- client-only
- presentation
permalink: gamevault/07-sessions/decisions/dr-043-first-run-onboarding
---
# DR-043 — First-Run Onboarding
> Operator (2026-06-28): *"This game needs an onboarding style type of thing, plan something that makes sense."* The game teaches NOTHING today (only a "Tab/Y — BUILD" discovery chip + a one-line expedition objective readout) yet has a deep, interlocking stack to learn: twin-stick combat → mine/economy → build palette → defend-the-Core sieges → walk-the-gate expeditions → an **inverted win condition** (you win by CLEARING EXPEDITIONS — [[DR-042_Loop_Reshape_Expedition_Driven]] — not by surviving base sieges). Designed via a 7-fork decision-tree (operator-locked) backed by a genre-precedent research pass, then built through the `/dots-dev` ladder.
## The problem
A new player is dropped cold into the full loop with no scaffolding, and the win condition is **counter-intuitive** — base defense *feels* like the goal, so without explicit framing players read it as tower-defense and never discover that expeditions are the win (the "Don't Starve / Hades II — no framing" failure the research flagged). Nothing in the build addresses this.
## Locked design — 7 forks (decision-tree, operator-chosen)
| Fork | Decision |
|---|---|
| **Style** | Contextual just-in-time coach-marks **+** a replayable How-to-Play reference card (not a guided rail, not a static-only screen) |
| **Scope** | The **full first lap** — through one expedition clear AND the retaliation siege (stopping earlier hides the win) |
| **Pacing** | **Soft-gated** objectives (a step shows until its action is done; never physically blocks) + auto-suppress |
| **Guidance** | Text prompt **+ one world-space pointer** per step (off-screen edge-arrow for navigation; text for conceptual beats) |
| **Welcome** | A **tiny non-modal welcome strip** on first spawn (names the inverted goal) → then silent coach-marks |
| **Reference card** | **Tabbed** (Controls · The Loop · Build & Economy · Threats · Win/Lose), reachable from **menu + pause** |
| **Opt-out** | Settings **toggle** + **auto-suppress** (a taught action already done never fires) + **replayable** from the menu + a dev **Force Each Launch** toggle (wipes the mask every boot — see below) |
| **Co-op** | **Per-client**, keyed to each player's own first-encounter; flags in **client-local `GameSettings`**, NOT the host-only `SaveData` |
Research backing (3-agent genre-precedent pass, run `wf_f41c8423-68b`): NN/g pull-not-push / one-thing-at-a-time / advance-by-doing; CHI-2012 (context-sensitive > forced); shipped analogs Riftbreaker / Cult of the Lamb / DRG / Hades / Helldivers 2 / Remnant 2 all teach **one complete lap then release**, optional+replayable, per-client. The annotated loop diagram is the single highest-value asset (answers the #1 confusion "how do the pillars connect").
## Architecture — pure client-side presentation, ZERO netcode surface
- **`OnboardingSystem`** — client-only observe-only `SystemBase` in `PresentationSystemGroup` (same constraints as `HudSystem`: never mutates sim, never destroys a ghost, reads replicated state once/frame). Owns its own runtime UIDocument (sortingOrder **60** — above HUD 50, below pause 100, root `pickingMode=Ignore`). A bottom-center prompt chip + a world-space `▶` pointer.
- **`OnboardingStepMath`** — pure, engine-free step list + `Snapshot` + `IsSatisfied` + prompt copy + pointer kind + mask helpers (the unit-testable core; mirrors the `*Math` discipline).
- **`OnboardingState`** — static `Active` flag (HudSystem reads it to blank its own location hint → single prompt voice) + `[RuntimeInitializeOnLoadMethod]` reset-on-play-enter (stale-static rule).
- Progress persists per-client in **`GameSettings.OnboardingMask`** (a completed-step bitmask) + `TutorialHints` toggle (v1→v2 **additive**). Client-local JSON — a Join client keeps its own; a save wipe never re-teaches the host.
- **Dev replay-each-launch** (`GameSettings.ForceOnboardingEachLaunch`, added 2026-06-29): an additive **0-default** int (no version bump — a v2 file deserializes it to 0/off). When set, `SettingsService.Boot` (a `RuntimeInitializeOnLoadMethod` that runs on **every** editor Play-enter / built-player launch) wipes the mask + forces hints on **in-memory** (not written back — the system re-persists as the player advances, and the next launch wipes again). Surfaced as a "Force Each Launch (Dev)" cycle row in Settings → Onboarding. The menu "Replay Tutorial" remains the one-shot equivalent.
### The lap (10 beats; soft-gated; scheme-aware glyphs; auto-suppress via absolute counts)
Welcome(timed, names the goal) → Move → Mine (attack an Ore node — mining IS combat at the base since Calm has no enemies; pointer→nearest node) → Build a Turret → Fabricator (soft) → Reach the Gate (edge-arrow) → Clear the zone → Return (leave expedition) → Defend the siege (soft) → Done. Each step gated on the **local** player's own first-encounter; count-based steps (Build/Fabricator) test an **absolute** count so a join-client at a built base auto-skips.
## Wire/bake classification — **LOW blast radius**
No `[GhostField]`, no ghost prefab/hash change, **no subscene re-bake**, no RPC, no `GhostRelevancy`, no server-system ordering. Only client-local `GameSettings` v1→v2 (additive JSON). ⇒ no pre-code netcode design review needed; relied on the verify ladder + a post-impl static review instead.
## Build status — shipped + validated green (built 2026-06-28, validated 2026-06-29)
Built **2026-06-28**, written via the **filesystem** (not MCP `create_script`) because the Unity editor was crashing — a **GPU/driver fault** (`dxgi.dll`, RTX 5060 Ti, driver 32.0.16.1062; recurring `Unity.exe.*.dmp` — NOT project code; see the session log + native memory). The feature has **NOT yet been compiled / tested / Play-validated**.
In lieu of a compiler, a **3-lens adversarial static review** (run `wf_d804925a-f7b`) verified every symbol against the codebase: **Lens 1 (compile/API) = clean PASS** (SystemAPI-in-helpers pattern confirmed valid, the `PlayerInput`/InputSystem CS8377 gotcha respected, asmdef refs + UITK APIs all resolve). Findings fixed:
- **[MAJOR, fixed]** the v1→v2 bump activated the lossy `SettingsService.Migrate` (rebuilt from `Defaults()`, dropping returning players' display-mode/quality/v-sync/fps/refresh) → rewrote Migrate to carry forward all old fields and seed only the new ones (Load already Clamps).
- **[minor, fixed]** Welcome copy said "Esc: How to Play" but Esc opens Pause, and Esc (via `anyKey`) self-dismissed the framing → reworded to "Esc → Pause → How to Play" + excluded `escapeKey` from the message-beat skip.
- **[minor, fixed]** timed beats advanced behind the pause overlay → freeze accumulation/eval on `PauseMenuController.Open`.
- **[minor, fixed]** `OnboardingState.Active` not reset on system teardown → reset in `OnDestroy`; HudSystem now `[UpdateAfter(OnboardingSystem)]` for same-frame suppression.
- **[L3 watch]** the `▶` pointer glyph's font coverage — verify it renders (swap to a font-independent shape if it's a missing-glyph box).
**Validation (2026-06-29, editor stable):** `refresh_unity scope=all mode=force` imported the 5 new `.cs` + recompiled → `read_console` clean (1 unrelated AI-assistant warning) → **20/20 EditMode pass** (`OnboardingStepTests` + `OnboardingSettingsTests`, incl. 2 new `ForceOnboardingEachLaunch` cases). Play smoke: seeded a **veteran** mask (`int.MaxValue`) + `force=1` → Boot wiped it → the overlay replayed from Welcome (live `mask` observed back at 0, then 1 as Welcome auto-completed) — proving the dev toggle AND that `HudSystem`'s new `[UpdateAfter(OnboardingSystem)]` introduces **no system sort-cycle** (world booted clean). Captured the Move + Build + Gate prompt chips and the `▶` pointer — the glyph renders as a **clean U+25B6 triangle, not a tofu box** (the flagged L3 watch), and the HUD location hint is correctly suppressed during onboarding. (The `▶` step was forced via reflection on the live `OnboardingSystem` since input can't be injected in an MCP smoke.)
## Files
**New:** `Client/Onboarding/{OnboardingState, OnboardingStepMath, OnboardingSystem}.cs`, `Client/UI/HowToPlayPanel.cs`, `Tests/EditMode/OnboardingStepTests.cs`.
**Edited:** `Client/Settings/{GameSettings, SettingsService}.cs`, `Client/UI/{MainMenuController, PauseMenuController, SettingsScreen}.cs`, `Client/Presentation/HudSystem.cs`.
## Consequences / open
- **CLAUDE.md gotcha — parked in the archive, NOT inlined** (resolved 2026-06-29): the reusable lesson (*first-run / has-played / per-player UI flags → client-local `GameSettings`, never host `SaveData`* + the client-onboarding-overlay pattern + the dev force-each-launch wipe) is captured under a dated heading in `_Meta/CLAUDE_Build_Gotchas_Archive.md`. It stayed out of CLAUDE.md by design: the file is at **40884/40960 B** (76 B headroom) and this is a one-time client-overlay pattern, not a high-recurrence hazard, so by CLAUDE.md's own "hottest rules only" budget rule it doesn't earn inline space — evicting a hot rule to make room for a cold one would be a net loss.
- Tuning knobs (live): `OnboardingStepMath.{WelcomeSeconds, MoveThreshold, FabricatorSoftSeconds, DefendNoSiegeSeconds, DoneSeconds}`.
- Deferred (not built): contextual `?` deep-link from a coach-mark to the card page; target/HUD highlights (operator chose pointers only); a structured guided-tutorial variant.
- See [[2026-06-28_First_Run_Onboarding]] · [[DR-042_Loop_Reshape_Expedition_Driven]] · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (the discovery-chip/build-mode precedent) · [[DR-019_Frontend_Menu_Settings_Saves_Build]] (settings/save lifecycle).
@@ -384,3 +384,14 @@ Added the **EB-2 felt spend ★** bullet ([[DR-033_EB2_Felt_Spend_Charge_Economy
- **Resource-gated ability tiers / buffs ([[DR-026_Inventory_Equipment_Progression_Foundation]]):** dropped the "(replace/clear-by-SourceId → bounded buffer; folds into … both worlds)" mechanism phrasing → kept "reuse `StatModifier``StatRecomputeSystem``EffectiveAbilityStats`". (Note: this bullet's `GoalProgress.Charge` is the **goal-meter** charge, unrelated to EB-2's `ResourceId.Charge` turret ammo.) - **Resource-gated ability tiers / buffs ([[DR-026_Inventory_Equipment_Progression_Foundation]]):** dropped the "(replace/clear-by-SourceId → bounded buffer; folds into … both worlds)" mechanism phrasing → kept "reuse `StatModifier``StatRecomputeSystem``EffectiveAbilityStats`". (Note: this bullet's `GoalProgress.Charge` is the **goal-meter** charge, unrelated to EB-2's `ResourceId.Charge` turret ammo.)
- **PlacedStructure ([[DR-014_M6_Build_Structures_Automation_Foundation]]):** dropped the "(turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin)" + "Data-driven `StructureCatalog` buffer" asides → kept the field layout + the bake-the-tick-fields rule + the DERIVED-occupancy rule. - **PlacedStructure ([[DR-014_M6_Build_Structures_Automation_Foundation]]):** dropped the "(turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin)" + "Data-driven `StructureCatalog` buffer" asides → kept the field layout + the bake-the-tick-fields rule + the DERIVED-occupancy rule.
- **M7 Automation ([[DR-020_M7_Automation_Production_Chains]]):** dropped the "server-only `MachineInput`/`MachineOutput`" restatement and noted `Fabricator` is now LIVE on the palette via EB-2 while `Harvester`/`Conveyor` stay trimmed (code intact). - **M7 Automation ([[DR-020_M7_Automation_Production_Chains]]):** dropped the "server-only `MachineInput`/`MachineOutput`" restatement and noted `Fabricator` is now LIVE on the palette via EB-2 while `Harvester`/`Conveyor` stay trimmed (code intact).
## 2026-06-29 — First-run onboarding validated (DR-043); CLAUDE.md line kept archive-only (file at cap)
DR-043's first-run onboarding shipped + Play-validated green (20/20 EditMode; live Play: a "veteran" full mask wiped by the dev toggle → tutorial replayed from Welcome, the `▶` pointer = a clean U+25B6 triangle (not tofu), no system sort-cycle from `HudSystem`'s new `[UpdateAfter(OnboardingSystem)]`). The deferred CLAUDE.md gotcha is parked HERE, not inline — the file sits at 40884/40960 B (76 B headroom) and the lesson is a one-time client-overlay pattern, not a high-recurrence hazard, so by CLAUDE.md's own "hottest rules only" rule it doesn't earn inline space. The lesson:
- **First-run / "has-played" / per-player UI flags belong in client-local `GameSettings` (settings.json), NEVER the host-only `SaveData`** — in co-op a Join client never sees the host save, and a host save-wipe must not re-teach. Progress = a completed-step **bitmask** in settings. Additive field, **0-default → no version bump** (a missing field deserializes to 0 = the safe off/fresh default; bumping `CurrentVersion` instead re-activates the migration path for every existing file — see the 2026-06-28 migration regression below/in DR-043).
- **Onboarding overlay = a client-only observe-only `PresentationSystemGroup` `SystemBase`** (same constraints as `HudSystem`: never mutates sim, never destroys a ghost, reads replicated state once/frame) owning its own runtime `UIDocument` (sortingOrder 60 — above HUD 50, below pause 100, root `pickingMode=Ignore`). A static `OnboardingState.Active` (reset on `SubsystemRegistration`) lets `HudSystem` (`[UpdateAfter(OnboardingSystem)]`) blank its own location hint → a single prompt voice. Auto-suppress for veterans/co-op falls out of **ABSOLUTE count checks** (turret/fabricator count ≥1 satisfies on entry at an already-built base).
- **Dev "Force Each Launch" toggle:** `GameSettings.ForceOnboardingEachLaunch``SettingsService.Boot` (a `RuntimeInitializeOnLoadMethod` that runs each editor Play-enter / built-player launch) wipes the mask + forces hints on **IN-MEMORY** (NOT written back; the system re-persists progress as the player advances, and the next launch wipes again). Validated by seeding `OnboardingMask=int.MaxValue` (veteran) + `force=1` then Play → mask observed back at 0/fresh.
- **Forcing a specific step in a live Play smoke without input:** the step machine's `_step`/`_mask`/`_stepInit` are private — set them via reflection on `world.GetExistingSystemManaged(typeof(OnboardingSystem))`; remember an MCP `screenshot` can leave the editor **paused** (`EditorApplication.isPaused`), so the field write won't surface until you unpause. Pause + reflection-restyle the `_pointer` Label is also how to get a clean glyph capture (OnUpdate would otherwise overwrite the style next frame).
Net-zero: archive-only add (no CLAUDE.md bytes changed), so no inline condensation needed.
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b07c4f9006927403f888bfd692e356f5c9f108dc7a0854ab7f4c4aeb1778173
size 34272
@@ -1,121 +0,0 @@
fileFormatVersion: 2
guid: 9152f2aee957bdc488befd900ff896e4
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 298480
packageName: Rukhanka Animation System 2
packageVersion: 2.9.0
assetPath: Packages/com.rukhanka.animation/Rukhanka.Editor/BlobInspector/RukhankaLogoSmall.png
uploadId: 897522
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc93292b6018d1829c24285452ec78679a867efd9d6b40324c444b72297dced5
size 12205
@@ -1,121 +0,0 @@
fileFormatVersion: 2
guid: 656caeb2c35ed38498a80781843a5012
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 298480
packageName: Rukhanka Animation System 2
packageVersion: 2.9.0
assetPath: Packages/com.rukhanka.animation/Rukhanka.Editor/Editor Default Resources/Icons/Icon@64.png
uploadId: 897522
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:780d80089b9b292d0ca85f75c026a100d875bc6d0e8f30effbf538e0a2a4857d
size 454
@@ -1,121 +0,0 @@
fileFormatVersion: 2
guid: ba20972f4867ba540850834b8c8d5917
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: 4
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 298480
packageName: Rukhanka Animation System 2
packageVersion: 2.9.0
assetPath: Packages/com.rukhanka.animation/Rukhanka.Editor/Editor Default Resources/Icons/RukhankaWaybackMachine@16.png
uploadId: 897522
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9358080bc4a17744843f74b1a80ad823da9c03e1a7940721e20bd7ce57dc4167
size 8544454
@@ -1,14 +0,0 @@
fileFormatVersion: 2
guid: 5fc783efb879658498ef7e482d82d827
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 298480
packageName: Rukhanka Animation System 2
packageVersion: 2.9.0
assetPath: Packages/com.rukhanka.animation/RukhankaAnimation.pdf
uploadId: 897522
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d96647a6ccd2481ef10e55a558f37445cf201d08ebf2407871bfda16ce6400f5
size 188717
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23b485716a0c01e5916fd249cff3cada64455207ea7ebafe3507b1ccd1368e7d
size 1189806
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c199f0ba39a87b5d04706defb9cab8fb41d7c4c83590302ab38a2de6c246343f
size 1307632
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7332531fa42e4dcfefecbdfc3a785e1d438d0dcb02cc81c0be5fca8b3c7fcc0c
size 24783
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc4705fb590d10cd7a90ffda8b5e36b47f51bc091cccc0fcbd5ea307003b1058
size 850120
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c6b9d2fb65a9cab9036ac15ee8d544a00872c7d555510e7453104ce995477fb
size 358453
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab015b180e89496180a3e81a75160c9fe2b27095e6590d1f0125fcfa85bd787c
size 605520
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:855e05c72da761ccd2bfb2ec8e77599677d327d627ee51c0bf6a818ce958b7f1
size 5021936
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:855e05c72da761ccd2bfb2ec8e77599677d327d627ee51c0bf6a818ce958b7f1
size 5021936
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6fd9ca4e12a5c87b995151b1214bac19186ddc69ff7465b072b471c22a47183c
size 1511158
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70cc94914096603b9402d22318f437da5f6445764645c5d83e21f8d88a9a1a01
size 304186
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7333ca62e193315221bd6036c6c3ff4ed9cae9f48df4cb97283544e4ab1a6fff
size 5638820
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:119ef128b0b072af817fa40f3760a42f3e24567e1628365f0bd871a29932f8ec
size 1728684
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc2525420bc521c82a07888edd98e71ce7a69e7ed9ee9f848ef0193ea8c148ed
size 3648211
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:93a9b6681bbdc16fce9ab132fb64a7f762ef042db44decc33ecf9008a3b3d4cd
size 447944
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:791002915b96180ffdff610fb2ea1ce29ceb94c65d876fa113d44e4e9446e578
size 5379186
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b9e945ce8a75e3ee72c2a4c4746f22ace3124cb79e17e38ea76d1a32bb0b2bc
size 1394972
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:71dfa0d4e362d140946097330800f5c71cb271b73f7eff4ac8fbe803e5821d76
size 157930
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9637854833f9ade38bc287cb0deced884e815fb4a441a9f13bcefd8437b2974c
size 5006166
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46aa70383b0466b33e44046c5d12ab12b78263771dadc42cda7efeeedfbe0966
size 24284
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7da79d81c036cf09b706d773bfcb8826a88652a16f8a49ab2915be2d4464b6f8
size 1731728
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d2ce7f7d19d7a4b6ce0d582b35aee201268c28eb1791b8b7d0ce027e8ac97f29
size 4852912
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4cf429c935bc983e910d23a2340c4caa46462f89fce5fbfa5e7e1421eec8910b
size 1445344
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b97be7e143bf8356b7df4fb0a6d148991e6304e27d791d870d6afd0449869f9
size 965952
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b0fa0a29eda83665f2e3336b9e3690275a213724e251d6651292a6646aa732c
size 3179124
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d34261057263e698c4686a9cd8a38f54f9e5e891af8e142139fab4671b38fa16
size 250247
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:806c1312c9128a2d2b9c2fcd6aceb25785992a59c94ff2b0e2d4299cc0772ca3
size 3231404
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e7852510545bbae51964f12407e2f11ae4f9228acfc3394704d7370c8461cb7
size 270211

Some files were not shown because too many files have changed in this diff Show More