From eb530cc9fe327d817bcb73e3222ff7a5978f8200 Mon Sep 17 00:00:00 2001 From: hoainho Date: Sun, 18 Oct 2026 13:46:50 +0700 Subject: [PATCH] feat: add simulation-owned environmental skill impacts (#128) --- .../environment-impact/acceptance.json | 2344 +++++++++++++++++ .../environment-impact/after-settled.png | 3 + .../environment-impact/before-control.png | 3 + .../evidence/environment-impact/moving.png | 3 + .../evidence/environment-impact/reduced.png | 3 + docs/features/character-vfx-proposal.md | 14 +- docs/features/combat.md | 37 + src/content/levels/act-i.ts | 4 + src/content/solid-props.ts | 3 + src/game/combat/CombatEngine.ts | 124 +- src/game/combat/EnvironmentProps.ts | 210 ++ src/game/combat/geometry.ts | 25 + src/game/contracts.ts | 15 + src/rendering/EnvironmentPropVisuals.ts | 78 + src/rendering/GameRenderer.ts | 8 + src/rendering/NativeAssetLoader.ts | 72 +- src/rendering/NativeSolidColliders.ts | 13 +- tests/combat/environment-props.test.ts | 257 ++ tests/rendering/NativeSolidColliders.test.ts | 12 +- tests/rendering/glb-static-batch.ts | 2 +- 20 files changed, 3193 insertions(+), 37 deletions(-) create mode 100644 asset-source/playtests/evidence/environment-impact/acceptance.json create mode 100644 asset-source/playtests/evidence/environment-impact/after-settled.png create mode 100644 asset-source/playtests/evidence/environment-impact/before-control.png create mode 100644 asset-source/playtests/evidence/environment-impact/moving.png create mode 100644 asset-source/playtests/evidence/environment-impact/reduced.png create mode 100644 src/game/combat/EnvironmentProps.ts create mode 100644 src/rendering/EnvironmentPropVisuals.ts create mode 100644 tests/combat/environment-props.test.ts diff --git a/asset-source/playtests/evidence/environment-impact/acceptance.json b/asset-source/playtests/evidence/environment-impact/acceptance.json new file mode 100644 index 0000000..de7ca37 --- /dev/null +++ b/asset-source/playtests/evidence/environment-impact/acceptance.json @@ -0,0 +1,2344 @@ +{ + "schemaVersion": 1, + "issue": "https://github.com/img2threejs/wild-crown/issues/128", + "scope": "Finite native environmental-force replay; not commercial-game reconstruction, art/release certification or a real-time performance benchmark.", + "references": { + "commercialGames": [ + "Kena: Bridge of Spirits", + "Zenless Zone Zero" + ], + "commercialLimit": "Historical visual references only. Neither commercial binary was provided or decompiled with REA.", + "web": { + "url": "https://victorzakharov.github.io/last-stand/", + "evidenceId": "ev_716f05d40b19450555bdee7fc6455453c3def4661c9e6619f16faa717aaa6b34", + "appSha256": "2e0d47a68e9631845affc1a27e87613881014a0d241c5f676088706c49bbd3de", + "observed": "Shipped application/Three.js dependency graph, not a proven environmental-destruction algorithm." + } + }, + "sourceHashes": { + "src/content/levels/act-i.ts": "9fb0a213555df91691d6ef5d7fe4d6552bb38e7da3adec1685d1b71bc616ecc1", + "src/content/solid-props.ts": "424ad20a9bbfb68e37ffc31a2bf59675392d5e8ea31c1e496f373f6f9b075b6e", + "src/game/contracts.ts": "7cb62bf940c7f727bdcaf3edd72ef7a334fd9295dcc86f10f131ee1c0ccf9160", + "src/game/combat/CombatEngine.ts": "d9cd06c4b22cc11ffc253d514954f756b040bf0a666e1f281a7f8ac950deb6a4", + "src/game/combat/EnvironmentProps.ts": "f3c3965d1059d1d25e4940727297a0e9ae28008f5439cbc71150ecb177f4db0c", + "src/game/combat/geometry.ts": "81ce47edf1971332824f8cf39d04b4c1059f8d7f815fa770ca3f1e09ec5ce763", + "src/rendering/EnvironmentPropVisuals.ts": "6bf28c5fa02c11eecb5daad42a0ac6512d391e98f6c041e02d65adf1c376ef20", + "src/rendering/GameRenderer.ts": "28735a48266b9c4433730faf7a2889d78e7cb4650feafb9ce1362c9bd9c807ee", + "src/rendering/NativeAssetLoader.ts": "33f87d1575095cb7e40d228b85058340353abc290c2f4068bf0b1fcd70bc9741", + "src/rendering/NativeSolidColliders.ts": "dc175de1a82db586ecab2cc3b085c74da75c0315e4eecdab35460d995092c723" + }, + "checks": { + "all_replay_steps_completed": true, + "anticipation_has_no_damage": true, + "heavy_is_one_contact": true, + "lightning_is_separate_contact": true, + "actor_damage_unchanged": true, + "mana_unchanged": true, + "hit_stop_holds_world": true, + "unfrozen_motion_changes_pose": true, + "pause_without_hit_stop_holds_pose": true, + "loose_native_matrices_move": true, + "damage_reaches_native_material": true, + "settled_bodies_sleep": true, + "reduced_keeps_physical_damage": true, + "native_assets_loaded_without_missing_actor": true, + "reset_clears_local_state": true + }, + "tests": { + "command": "bun test tests/combat/environment-props.test.ts tests/combat/mana-skills.test.ts tests/combat/heartroot-skills.test.ts tests/combat/vesper-kit.test.ts tests/combat/sakda-kit.test.ts tests/combat/run-reset.test.ts tests/rendering/NativeSolidColliders.test.ts tests/effects/thunder-cleave.test.ts", + "passed": 143, + "failed": 0, + "expects": 1841, + "seconds": 15.51 + }, + "native": { + "composition": "Production CombatEngine, GameRenderer, StoryPresentation; canonical Greenwake native candidate manifest; deterministic fixed-step buttons; hostile AI disabled for paired replay.", + "viewport": { + "width": 1440, + "height": 900 + }, + "evidence": [ + { + "evidenceId": "ev_04ed380ad6782c83cb2aa35f8e77bb9bffbb90d49363bd0d6cc29bb85a960edf", + "durationMs": 46800, + "completeness": { + "equality_eligible": false, + "missing_sections": [ + "events" + ], + "status": "incomplete", + "truncated_sections": [] + }, + "limitations": [ + "Artifact identity is unavailable for this observation.", + "Event sequence records provider receipt order; simultaneous browser causality is not inferred.", + "Network content is retained only when selected; Playwright response-body bytes are API results and are not guaranteed to match wire bytes.", + "Request bytes are limited to what Playwright exposes; not_exposed does not establish body absence or multipart file coverage.", + "Network content reads settle within 5 seconds each and the observer does not request bodies from unfinished responses.", + "Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.", + "Storage values are hashed only after declared-secret redaction.", + "Shared-context network capture excludes requests without a known root or descendant Page frame.", + "CDP attachment cannot recover pre-attach events or guarantee launch-time context options.", + "Browser host platform and architecture were not observed." + ] + }, + { + "evidenceId": "ev_693b375a2f6a0b312d3e6f4482ce46d078139b582f4a3110ef65ea7252c1990c", + "durationMs": 6551, + "completeness": { + "equality_eligible": false, + "missing_sections": [ + "events" + ], + "status": "incomplete", + "truncated_sections": [] + }, + "limitations": [ + "Artifact identity is unavailable for this observation.", + "Event sequence records provider receipt order; simultaneous browser causality is not inferred.", + "Network content is retained only when selected; Playwright response-body bytes are API results and are not guaranteed to match wire bytes.", + "Request bytes are limited to what Playwright exposes; not_exposed does not establish body absence or multipart file coverage.", + "Network content reads settle within 5 seconds each and the observer does not request bodies from unfinished responses.", + "Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.", + "Storage values are hashed only after declared-secret redaction.", + "CDP attachment cannot recover pre-attach events or guarantee launch-time context options.", + "Browser host platform and architecture were not observed." + ] + } + ], + "controlAndImpact": [ + { + "step": "native-ready", + "status": "completed", + "data": { + "label": "native baseline", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 1, + "worldTick": 1, + "worldTimeSeconds": 0.016666666666666666, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 100, + "enemyHP": 1000, + "cueKinds": [], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "control-disabled", + "status": "completed", + "data": { + "label": "native baseline", + "environmentEnabled": false, + "reducedMotion": false, + "clock": { + "simulationTick": 1, + "worldTick": 1, + "worldTimeSeconds": 0.016666666666666666, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 100, + "enemyHP": 1000, + "cueKinds": [], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "control-charge", + "status": "completed", + "data": { + "label": "charged — no environmental damage yet", + "environmentEnabled": false, + "reducedMotion": false, + "clock": { + "simulationTick": 56, + "worldTick": 56, + "worldTimeSeconds": 0.9333333333333333, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy-charge", + "mana": 100, + "enemyHP": 1000, + "cueKinds": [], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "control-contact", + "status": "completed", + "data": { + "label": "real heavy contact", + "environmentEnabled": false, + "reducedMotion": false, + "clock": { + "simulationTick": 111, + "worldTick": 111, + "worldTimeSeconds": 1.8499999999999999, + "timeScale": 0, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "control-lightning", + "status": "completed", + "data": { + "label": "real delayed lightning", + "environmentEnabled": false, + "reducedMotion": false, + "clock": { + "simulationTick": 132, + "worldTick": 123, + "worldTimeSeconds": 2.05, + "timeScale": 0, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "environment-enabled", + "status": "completed", + "data": { + "label": "native baseline", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 1, + "worldTick": 1, + "worldTimeSeconds": 0.016666666666666666, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 100, + "enemyHP": 1000, + "cueKinds": [], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "environment-charge-no-damage", + "status": "completed", + "data": { + "label": "charged — no environmental damage yet", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 56, + "worldTick": 56, + "worldTimeSeconds": 0.9333333333333333, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy-charge", + "mana": 100, + "enemyHP": 1000, + "cueKinds": [], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + }, + { + "step": "environment-real-heavy-contact", + "status": "completed", + "data": { + "label": "real heavy contact", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 111, + "worldTick": 111, + "worldTimeSeconds": 1.8499999999999999, + "timeScale": 0, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.05, + "y": 0.01587278621865997, + "z": -2.451652801224673 + }, + "offset": { + "x": 0, + "y": 0.01587278621865997, + "z": -0.051652801224673084 + }, + "tiltX": -0.04820928114302791, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.15, + "y": 0.015191475920895295, + "z": -3.1497602726197713 + }, + "offset": { + "x": 0, + "y": 0.015191475920895295, + "z": -0.04976027261977123 + }, + "tiltX": -0.04644292111178616, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.25, + "y": 0.013973584904931058, + "z": -4.746377242019871 + }, + "offset": { + "x": 0, + "y": 0.013973584904931058, + "z": -0.04637724201987048 + }, + "tiltX": -0.04328542588521221, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0.10374286770820618, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-physical-motion", + "status": "completed", + "data": { + "label": "five additional fixed steps", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 116, + "worldTick": 111, + "worldTimeSeconds": 1.8499999999999999, + "timeScale": 0, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.05, + "y": 0.01587278621865997, + "z": -2.451652801224673 + }, + "offset": { + "x": 0, + "y": 0.01587278621865997, + "z": -0.051652801224673084 + }, + "tiltX": -0.04820928114302791, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.15, + "y": 0.015191475920895295, + "z": -3.1497602726197713 + }, + "offset": { + "x": 0, + "y": 0.015191475920895295, + "z": -0.04976027261977123 + }, + "tiltX": -0.04644292111178616, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.25, + "y": 0.013973584904931058, + "z": -4.746377242019871 + }, + "offset": { + "x": 0, + "y": 0.013973584904931058, + "z": -0.04637724201987048 + }, + "tiltX": -0.04328542588521221, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0.10374286770820618, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-pause-freeze", + "status": "completed", + "data": { + "label": "pause: physical state unchanged through 30 inputs", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 116, + "worldTick": 111, + "worldTimeSeconds": 1.8499999999999999, + "timeScale": 0, + "policy": "paused" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.05, + "y": 0.01587278621865997, + "z": -2.451652801224673 + }, + "offset": { + "x": 0, + "y": 0.01587278621865997, + "z": -0.051652801224673084 + }, + "tiltX": -0.04820928114302791, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.15, + "y": 0.015191475920895295, + "z": -3.1497602726197713 + }, + "offset": { + "x": 0, + "y": 0.015191475920895295, + "z": -0.04976027261977123 + }, + "tiltX": -0.04644292111178616, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.25, + "y": 0.013973584904931058, + "z": -4.746377242019871 + }, + "offset": { + "x": 0, + "y": 0.013973584904931058, + "z": -0.04637724201987048 + }, + "tiltX": -0.04328542588521221, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0.10374286770820618, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-delayed-lightning", + "status": "completed", + "data": { + "label": "real delayed lightning", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 132, + "worldTick": 123, + "worldTimeSeconds": 2.05, + "timeScale": 0, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.4275099509366477, + "tiltZ": 0.03713416261852295, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.183838659077873, + "y": 0.013981344730068838, + "z": -3.71884728468785 + }, + "offset": { + "x": 0.033838659077872535, + "y": 0.013981344730068838, + "z": -0.6188472846878499 + }, + "tiltX": -0.43067080776971284, + "tiltZ": -0.03158274847268168, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.254212284275713, + "y": 0.010597107583519038, + "z": -5.211791110641854 + }, + "offset": { + "x": 0.004212284275713074, + "y": 0.010597107583519038, + "z": -0.5117911106418536 + }, + "tiltX": -0.4187926787765929, + "tiltZ": -0.0039314653239988975, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0.671581506729126, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-settled-damage", + "status": "completed", + "data": { + "label": "settled — persistent local damage", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 312, + "worldTick": 294, + "worldTimeSeconds": 4.9, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.7124551525359696, + "tiltZ": 0.5570085291543584, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.501372287479114, + "y": 0, + "z": -4.2666871078706885 + }, + "offset": { + "x": 0.35137228747911387, + "y": 0, + "z": -1.1666871078706884 + }, + "tiltX": -0.9687178208962369, + "tiltZ": -0.47373790151512546, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.2823615360148075, + "y": 0, + "z": -5.413749506174329 + }, + "offset": { + "x": 0.03236153601480751, + "y": 0, + "z": -0.7137495061743291 + }, + "tiltX": -1.1638817547645908, + "tiltZ": -0.0589715658876784, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 1.242760181427002, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-reduced-readable", + "status": "completed", + "data": { + "label": "reduced setting — physical damage remains", + "environmentEnabled": true, + "reducedMotion": true, + "clock": { + "simulationTick": 312, + "worldTick": 294, + "worldTimeSeconds": 4.9, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.7124551525359696, + "tiltZ": 0.5570085291543584, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.501372287479114, + "y": 0, + "z": -4.2666871078706885 + }, + "offset": { + "x": 0.35137228747911387, + "y": 0, + "z": -1.1666871078706884 + }, + "tiltX": -0.9687178208962369, + "tiltZ": -0.47373790151512546, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.2823615360148075, + "y": 0, + "z": -5.413749506174329 + }, + "offset": { + "x": 0.03236153601480751, + "y": 0, + "z": -0.7137495061743291 + }, + "tiltX": -1.1638817547645908, + "tiltZ": -0.0589715658876784, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 1.242760181427002, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-native-visibility", + "status": "completed", + "data": { + "label": "native visibility preserves physical pose", + "environmentEnabled": true, + "reducedMotion": true, + "clock": { + "simulationTick": 312, + "worldTick": 294, + "worldTimeSeconds": 4.9, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.7124551525359696, + "tiltZ": 0.5570085291543584, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.501372287479114, + "y": 0, + "z": -4.2666871078706885 + }, + "offset": { + "x": 0.35137228747911387, + "y": 0, + "z": -1.1666871078706884 + }, + "tiltX": -0.9687178208962369, + "tiltZ": -0.47373790151512546, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.2823615360148075, + "y": 0, + "z": -5.413749506174329 + }, + "offset": { + "x": 0.03236153601480751, + "y": 0, + "z": -0.7137495061743291 + }, + "tiltX": -1.1638817547645908, + "tiltZ": -0.0589715658876784, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 1.242760181427002, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "environment-reset-clears-local-damage", + "status": "completed", + "data": { + "label": "native baseline", + "environmentEnabled": true, + "reducedMotion": true, + "clock": { + "simulationTick": 1, + "worldTick": 1, + "worldTimeSeconds": 0.016666666666666666, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 100, + "enemyHP": 1000, + "cueKinds": [], + "props": [], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936 + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785 + } + ], + "instanceMatrixDelta": 0, + "materialDamage": 0 + }, + "errors": [] + } + } + ], + "postRestFix": [ + { + "step": "scenario_start", + "status": "completed", + "data": { + "label": "pause: physical state unchanged through 30 inputs", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 121, + "worldTick": 112, + "worldTimeSeconds": 1.8666666666666667, + "timeScale": 0, + "policy": "paused" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.05, + "y": 0.029023350215097717, + "z": -2.5027064880644647 + }, + "offset": { + "x": 0, + "y": 0.029023350215097717, + "z": -0.10270648806446481 + }, + "tiltX": -0.09320461020985396, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.15, + "y": 0.02766072961956837, + "z": -3.198943382057386 + }, + "offset": { + "x": 0, + "y": 0.02766072961956837, + "z": -0.09894338205738595 + }, + "tiltX": -0.08978964748278659, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.25, + "y": 0.025224947587639893, + "z": -4.792216560206639 + }, + "offset": { + "x": 0, + "y": 0.025224947587639893, + "z": -0.09221656020663893 + }, + "tiltX": -0.08368515671141027, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -2.699719744009563, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -2.600803582260025, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785, + "sleeping": false, + "angularVelocity": { + "x": -2.4239838495718837, + "z": 0 + } + } + ], + "instanceMatrixDelta": 0.11296272277832031, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "current-unfrozen-motion", + "status": "completed", + "data": { + "label": "five additional fixed steps", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 126, + "worldTick": 117, + "worldTimeSeconds": 1.95, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.05, + "y": 0.053942836863953125, + "z": -2.7492286218552944 + }, + "offset": { + "x": 0, + "y": 0.053942836863953125, + "z": -0.34922862185529446 + }, + "tiltX": -0.27699080556943423, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.15, + "y": 0.04917366477960041, + "z": -3.4364330881990113 + }, + "offset": { + "x": 0, + "y": 0.04917366477960041, + "z": -0.3364330881990112 + }, + "tiltX": -0.26684202350135605, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.25, + "y": 0.04064842766785072, + "z": -5.013560154184094 + }, + "offset": { + "x": 0, + "y": 0.04064842766785072, + "z": -0.3135601541840938 + }, + "tiltX": -0.24870034775648034, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -1.912064621039933, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -1.8420076850375844, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785, + "sleeping": false, + "angularVelocity": { + "x": -1.7167758879501547, + "z": 0 + } + } + ], + "instanceMatrixDelta": 0.379364013671875, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "current-pause-without-hit-stop", + "status": "completed", + "data": { + "label": "pause: physical state unchanged through 30 inputs", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 126, + "worldTick": 117, + "worldTimeSeconds": 1.95, + "timeScale": 0, + "policy": "paused" + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 964, + "cueKinds": [ + "slash", + "hit" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.05, + "y": 0.053942836863953125, + "z": -2.7492286218552944 + }, + "offset": { + "x": 0, + "y": 0.053942836863953125, + "z": -0.34922862185529446 + }, + "tiltX": -0.27699080556943423, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.15, + "y": 0.04917366477960041, + "z": -3.4364330881990113 + }, + "offset": { + "x": 0, + "y": 0.04917366477960041, + "z": -0.3364330881990112 + }, + "tiltX": -0.26684202350135605, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.25, + "y": 0.04064842766785072, + "z": -5.013560154184094 + }, + "offset": { + "x": 0, + "y": 0.04064842766785072, + "z": -0.3135601541840938 + }, + "tiltX": -0.24870034775648034, + "tiltZ": 0, + "damage": 1, + "broken": true, + "impactSerial": 1, + "impactWorldTick": 111 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -1.912064621039933, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -1.8420076850375844, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785, + "sleeping": false, + "angularVelocity": { + "x": -1.7167758879501547, + "z": 0 + } + } + ], + "instanceMatrixDelta": 0.379364013671875, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "current-delayed-lightning", + "status": "completed", + "data": { + "label": "real delayed lightning", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 132, + "worldTick": 123, + "worldTimeSeconds": 2.05, + "timeScale": 0, + "policy": "normal" + }, + "hitStop": { + "remainingTicks": 9, + "strength": 1 + }, + "playerAction": "aren-charged-heavy", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.4275099509366477, + "tiltZ": 0.03713416261852295, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.183838659077873, + "y": 0.013981344730068838, + "z": -3.71884728468785 + }, + "offset": { + "x": 0.033838659077872535, + "y": 0.013981344730068838, + "z": -0.6188472846878499 + }, + "tiltX": -0.43067080776971284, + "tiltZ": -0.03158274847268168, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.254212284275713, + "y": 0.010597107583519038, + "z": -5.211791110641854 + }, + "offset": { + "x": 0.004212284275713074, + "y": 0.010597107583519038, + "z": -0.5117911106418536 + }, + "tiltX": -0.4187926787765929, + "tiltZ": -0.0039314653239988975, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -1.221202906086466, + "z": 2.228049757111377 + } + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936, + "sleeping": false, + "angularVelocity": { + "x": -2.3059331139928774, + "z": -1.8949649083609006 + } + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785, + "sleeping": false, + "angularVelocity": { + "x": -3.193262914352527, + "z": -0.23588791943993387 + } + } + ], + "instanceMatrixDelta": 0.671581506729126, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "current-settled-physical-rest", + "status": "completed", + "data": { + "label": "settled — persistent local damage", + "environmentEnabled": true, + "reducedMotion": false, + "clock": { + "simulationTick": 312, + "worldTick": 294, + "worldTimeSeconds": 4.9, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.712434918630911, + "tiltZ": 0.5569904975381996, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.501372287479114, + "y": 0, + "z": -4.2666871078706885 + }, + "offset": { + "x": 0.35137228747911387, + "y": 0, + "z": -1.1666871078706884 + }, + "tiltX": -0.9686991589697393, + "tiltZ": -0.47371827421930895, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.2823615360148075, + "y": 0, + "z": -5.413749506174329 + }, + "offset": { + "x": 0.03236153601480751, + "y": 0, + "z": -0.7137495061743291 + }, + "tiltX": -1.1638650865093754, + "tiltZ": -0.05894934185018737, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936, + "sleeping": true, + "angularVelocity": { + "x": 0, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936, + "sleeping": true, + "angularVelocity": { + "x": 0, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785, + "sleeping": true, + "angularVelocity": { + "x": 0, + "z": 0 + } + } + ], + "instanceMatrixDelta": 1.2427597045898438, + "materialDamage": 1 + }, + "errors": [] + } + }, + { + "step": "current-reduced-readable-damage", + "status": "completed", + "data": { + "label": "reduced setting — physical damage remains", + "environmentEnabled": true, + "reducedMotion": true, + "clock": { + "simulationTick": 312, + "worldTick": 294, + "worldTimeSeconds": 4.9, + "timeScale": 1, + "policy": "normal" + }, + "playerAction": "aren-idle", + "mana": 80, + "enemyHP": 946, + "cueKinds": [ + "slash", + "hit", + "lightning", + "projectile-impact" + ], + "props": [ + { + "id": "environment-loose-greenwake-west", + "material": "loose-stone", + "position": { + "x": 5.01021339719444, + "y": 0, + "z": -3.0259234835750655 + }, + "offset": { + "x": -0.03978660280555957, + "y": 0, + "z": -0.6259234835750656 + }, + "tiltX": -0.712434918630911, + "tiltZ": 0.5569904975381996, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-east", + "material": "loose-stone", + "position": { + "x": 7.501372287479114, + "y": 0, + "z": -4.2666871078706885 + }, + "offset": { + "x": 0.35137228747911387, + "y": 0, + "z": -1.1666871078706884 + }, + "tiltX": -0.9686991589697393, + "tiltZ": -0.47371827421930895, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + }, + { + "id": "environment-loose-greenwake-north", + "material": "loose-stone", + "position": { + "x": 6.2823615360148075, + "y": 0, + "z": -5.413749506174329 + }, + "offset": { + "x": 0.03236153601480751, + "y": 0, + "z": -0.7137495061743291 + }, + "tiltX": -1.1638650865093754, + "tiltZ": -0.05894934185018737, + "damage": 1, + "broken": true, + "impactSerial": 2, + "impactWorldTick": 123 + } + ], + "native": { + "actorCount": 6, + "propCount": 121, + "loadedAssetIds": [ + "prop-greenwake-houses", + "prop-mire-bank-rock", + "prop-erya-edge-tree", + "prop-hunter-cage", + "prop-root-arch", + "prop-greenwake-rope-bridge", + "actor-yara", + "actor-vara", + "actor-ember-boar", + "actor-frost-sap-stalker", + "actor-root-rammer", + "actor-aren" + ], + "assetErrors": [], + "missingActorAssets": [], + "measuredLooseBodies": [ + { + "id": "environment-loose-greenwake-west", + "radius": 0.3689793543669948, + "height": 0.26916294726816936, + "sleeping": true, + "angularVelocity": { + "x": 0, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-east", + "radius": 0.44086149654954215, + "height": 0.26916294726816936, + "sleeping": true, + "angularVelocity": { + "x": 0, + "z": 0 + } + }, + { + "id": "environment-loose-greenwake-north", + "radius": 0.3962663698222529, + "height": 0.22430245605680785, + "sleeping": true, + "angularVelocity": { + "x": 0, + "z": 0 + } + } + ], + "instanceMatrixDelta": 1.2427597045898438, + "materialDamage": 1 + }, + "errors": [] + } + } + ], + "images": [ + { + "file": "before-control.png", + "sourceEvidenceId": "ev_04ed380ad6782c83cb2aa35f8e77bb9bffbb90d49363bd0d6cc29bb85a960edf", + "stepId": "control-lightning", + "sha256": "c1178cdb50e674032ee77bc0be730053eab56f174aefb0f8ae077aa1f0239544", + "bytes": 1659722, + "mediaType": "image/png" + }, + { + "file": "moving.png", + "sourceEvidenceId": "ev_693b375a2f6a0b312d3e6f4482ce46d078139b582f4a3110ef65ea7252c1990c", + "stepId": "current-unfrozen-motion", + "sha256": "e742ec0d392d47c15deb6c123cf768f1efe484444290f764b3ba53bc6e88774c", + "bytes": 1704159, + "mediaType": "image/png" + }, + { + "file": "after-settled.png", + "sourceEvidenceId": "ev_693b375a2f6a0b312d3e6f4482ce46d078139b582f4a3110ef65ea7252c1990c", + "stepId": "current-settled-physical-rest", + "sha256": "be3df5a11b68d6066c2ae904f26c0106d080bcae15565d18fe0b9057b09ff7d4", + "bytes": 1703092, + "mediaType": "image/png" + }, + { + "file": "reduced.png", + "sourceEvidenceId": "ev_693b375a2f6a0b312d3e6f4482ce46d078139b582f4a3110ef65ea7252c1990c", + "stepId": "current-reduced-readable-damage", + "sha256": "5444d85f62abaa1ab23ca2b483fa4648e7fcbe0c97636ba06f6d6709fe41e54b", + "bytes": 1703961, + "mediaType": "image/png" + } + ] + }, + "productionUI": { + "flow": "Map selector → Enter Greenwake → Aren & Koro → skip introduction → How to Play Continue → K down/up → Escape/Resume.", + "observedManaBefore": 100, + "observedManaAfter": 88, + "observedHeavyCooldown": "2s", + "scope": "Boot, native assets, actual heavy input and pause UI only; paired environment contacts are proven by the controlled native replay.", + "audio": "Five audio requestfailed events with net::ERR_ABORTED were observed; audio playback was not verified." + }, + "limitations": [ + "Authored small loose bodies only: three reused Greenwake river stones; rooted wood bends but does not uproot; rock supports and quest/traversal structures remain authoritative.", + "Damage/crush/chipped-core projection is not mesh fracture, loose-body-to-loose-body collision or terrain deformation.", + "Local state serializes with the engine and resets with its checkpoint/region lifetime; campaign continue keeps existing safe-checkpoint policy, not permanent destruction facts.", + "All requested browser actions and retained DOM/image artifacts completed, but REA reports completeness missing events, including when events were not requested. Tool issue reported; no global trace equality or reconstruction certification is claimed.", + "The 48,247,012-byte complete REA bundle was exported locally; this file is a compact derived projection, not a canonical Evidence bundle.", + "Temporary native forensic controls are removed from the delivered game." + ], + "gates": { + "productionBuild": { + "command": "bun run build", + "passed": true, + "observed": "TypeScript and Vite completed; existing >500 kB chunk warning remains." + }, + "releaseValidatorUnits": { + "command": "node --test tests/release/*.test.mjs", + "passed": 236, + "failed": 0, + "skipped": 0, + "durationMs": 14525.133792, + "releaseCertification": false + } + } +} diff --git a/asset-source/playtests/evidence/environment-impact/after-settled.png b/asset-source/playtests/evidence/environment-impact/after-settled.png new file mode 100644 index 0000000..34bbd68 --- /dev/null +++ b/asset-source/playtests/evidence/environment-impact/after-settled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be3df5a11b68d6066c2ae904f26c0106d080bcae15565d18fe0b9057b09ff7d4 +size 1703092 diff --git a/asset-source/playtests/evidence/environment-impact/before-control.png b/asset-source/playtests/evidence/environment-impact/before-control.png new file mode 100644 index 0000000..37b60ab --- /dev/null +++ b/asset-source/playtests/evidence/environment-impact/before-control.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1178cdb50e674032ee77bc0be730053eab56f174aefb0f8ae077aa1f0239544 +size 1659722 diff --git a/asset-source/playtests/evidence/environment-impact/moving.png b/asset-source/playtests/evidence/environment-impact/moving.png new file mode 100644 index 0000000..8fb86e5 --- /dev/null +++ b/asset-source/playtests/evidence/environment-impact/moving.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e742ec0d392d47c15deb6c123cf768f1efe484444290f764b3ba53bc6e88774c +size 1704159 diff --git a/asset-source/playtests/evidence/environment-impact/reduced.png b/asset-source/playtests/evidence/environment-impact/reduced.png new file mode 100644 index 0000000..cd0ca9b --- /dev/null +++ b/asset-source/playtests/evidence/environment-impact/reduced.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5444d85f62abaa1ab23ca2b483fa4648e7fcbe0c97636ba06f6d6709fe41e54b +size 1703961 diff --git a/docs/features/character-vfx-proposal.md b/docs/features/character-vfx-proposal.md index e6b44ab..5007f20 100644 --- a/docs/features/character-vfx-proposal.md +++ b/docs/features/character-vfx-proposal.md @@ -1,6 +1,6 @@ # Proposal: Character VFX — Dark, Living, Forceful -**Trạng thái:** đã triển khai một proof Aren Thunder Cleave ([issue #126](https://github.com/img2threejs/wild-crown/issues/126), [PR #127](https://github.com/img2threejs/wild-crown/pull/127)); chưa nghiệm thu proposal toàn bộ hero/companion và chưa duyệt nhân rộng art direction. +**Trạng thái:** đã triển khai proof Aren Thunder Cleave ([issue #126](https://github.com/img2threejs/wild-crown/issues/126), [PR #127](https://github.com/img2threejs/wild-crown/pull/127)) và phản ứng vật thể/môi trường theo simulation ([issue #128](https://github.com/img2threejs/wild-crown/issues/128)); chưa nghiệm thu proposal VFX toàn bộ hero/companion và chưa duyệt nhân rộng art direction. ## 1. Mục tiêu và phạm vi @@ -182,3 +182,15 @@ Chọn **Dark, Living, Forceful**: tối ở chất liệu và khoảng nghỉ; - Hai lệnh mở rộng gộp thêm weapon/audio/story-animation timeout ở **180s / 90s**; effect suite chạy riêng và native get-up chạy riêng đều pass. Chưa kết luận nguyên nhân của timeout hoặc tuyên bố full-suite pass. - CPU presentation p95 quan sát trong paired capture: **9.80ms → 8.30ms**, sau warm-up 150 bước; 247 render samples gồm frame setup, không tính screenshot readback. Không cô lập tải nền trong capture nên **không coi đây là benchmark tải máy được kiểm soát**. Đây là thời gian CPU submit/render của fixture headless, không phải total frame time/GPU time; không chứng minh mục tiêu hiệu năng toàn proposal hoặc phần cứng tham chiếu. - [Trace, guard checks, hash và giới hạn](../../asset-source/playtests/evidence/aren-thunder-cleave/acceptance.json). Đã kiểm tra get-up immunity và zero-damage suppression bằng focused suite; targeted strike này không có block path để giả lập. Canopy, đông hiệu ứng, toàn roster và art/release verification của native assets **chưa được chứng nhận bởi proof này**. + +## 11. Bổ sung lực tác động môi trường — issue #128 + +Đây là phần simulation bổ sung, không còn chỉ là presentation của proposal ban đầu. Skill tấn công thật truyền lực và hư hại tới vật thể native trong phạm vi tiếp xúc: đá rời bật/dịch chuyển/lăn rồi lắng xuống, cây có gốc rung lệch ngắn, cây mềm bị ép xuống và bề mặt lưu dấu hư hại. Không dùng camera shake hoặc particle thay cho chuyển động vật thể. + +- Giữ damage/timing/mana/cooldown của nhân vật; tách từng contact/pulse thật và không gây damage từ windup, hỗ trợ hoặc VFX trang trí. Pause, hit-stop, slow motion, restore và reset theo simulation. +- Bảo vệ cầu, cổng, support, vật thể nhiệm vụ/delivery/movable và các cụm rễ đỡ đường. Không biến toàn bản đồ thành môi trường phá hủy được; cây lớn không bị bật gốc, terrain không bị biến dạng và chưa có mesh fracture. +- Tái sử dụng native GLB, thêm ba viên đá rời tại Greenwake; không generation trả phí, không thay native bằng proxy và không nâng trạng thái candidate/blocked thành release-verified. +- **Nguồn REA:** lịch sử chỉ cung cấp Kena và Zenless Zone Zero làm tham chiếu hình ảnh, không có binary để decompile. Lần này REA quan sát bundle Last Stand/Three.js và trực tiếp ghi replay native Wild Crown. Không suy diễn thuật toán phá hủy của hai game thương mại từ những bằng chứng đó. +- Đối chứng native bật/tắt môi trường giữ HP **1000 → 964 → 946**, mana **100 → 80**. REA ghi được instance matrix dịch chuyển, material hư hại, hai contact riêng của melee/lightning, pause ngoài hit-stop và trạng thái hư hại còn rõ khi bật Reduced Effects. **143 test pass / 0 fail**; không cộng các bộ test trước đó vào tổng này. +- [Hợp đồng simulation và giới hạn](combat.md#offensive-environmental-response-128), [trace/hashes/giới hạn của REA](../../asset-source/playtests/evidence/environment-impact/acceptance.json), [đối chứng không phản ứng môi trường](../../asset-source/playtests/evidence/environment-impact/before-control.png), [vật thể đang chuyển động](../../asset-source/playtests/evidence/environment-impact/moving.png), [hư hại sau khi lắng xuống](../../asset-source/playtests/evidence/environment-impact/after-settled.png). + diff --git a/docs/features/combat.md b/docs/features/combat.md index f7a3192..d428d52 100644 --- a/docs/features/combat.md +++ b/docs/features/combat.md @@ -409,3 +409,40 @@ A standalone canonical Bamboo engine smoke measured Asha's first shelter charge The native Yara/Heartroot fitting smoke completed in 7.23 s after this change; the prior measured construction took 12.19 s (11.05 s under CPU profiling). These are individual local observations, not a release benchmark. All 34 weapon tests passed, including wrist preservation, fist clearance, pose sampling, and repeated equip/dispose, without increasing their timeouts. The ranged camp hunter's crossbow repair removes detached clavicle/forearm weight islands while retaining connected wrist blends. Its native pose sheet was regenerated and inspected; the Bamboo asset suite passed all 83 tests, including rigid weapon distance across clips, manifest hashes, and byte-identical re-authoring. Generate these clips with Node **22.22.3**: Node 20.18.1 produced a different animation fingerprint. The current ranged animated GLB hash is `4041ef84aa834e3d06d0e84e6cef8ec55bfd37a0bebe5791f08c25cd8291901a`. Asset evidence remains `blocked` pending user approval; no paid generation was performed for this repair. + +## Offensive environmental response (#128) + +`EngineState.environment` owns local prop health, material resistance, impulse, position, angular response and sleep state. `CombatSnapshot.environmentProps` is its sparse presentation projection; `EnvironmentPropVisuals` never awards damage. No physics dependency or replacement prop mesh was introduced. + +| Eligible native material | Physical response | +|---|---| +| Rooted wood/bamboo | Brief bounded spring deflection and accumulated surface scars; the root and its collision/support authority remain fixed. | +| Soft fern/reeds | Deflection, accumulated damage and a flattened native remnant when crushed; broken foliage contributes no remaining native blocker. | +| Large structural rock | Surface damage only; standable tops, decks and navigation do not move. | +| Explicit small loose stones | Directional impulse, gravity, wall collision, ground friction and tumbling; a damaged chipped core remains visible. | + +Three existing `prop-mire-bank-rock` GLBs are placed as loose stones beside Greenwake's first combat pocket. Only the `environment-loose-` prefix **plus** the small-scale stone allowlist opts into this role. Native geometry measures their body bounds without creating a stationary actor/camera blocker or standable top. Loose travel is bounded to 4 m from its authored origin and supported playable space. Bodies sleep only after landing, never at a zero-speed airborne apex; native-support refresh wakes impacted bodies. + +### Contact and authority + +- Aren's active light/heavy windows, actual delayed Thunder Cleave and Judgement detonations, Heartroot lanes/bursts, swept projectiles, Vesper/Sakda kit strikes, confirmed Vara damage and active offensive hazards transfer force through their real shapes. Support, anticipation, cosmetic cues and Orun possession do not. +- Stable action/projectile/hazard contact identities deduplicate sustained windows. Distinct real pulses remain separate: Sakda's limb/ring and six Hundred Elbows contacts plus uppercut are not collapsed into one hit. +- Material resistance and distance attenuate environmental damage. Narrow solid-volume visibility checks walls, terrain and measured supports; they do not run the expensive character movement/landing sweep for every force query. +- A nearby eligible prop can be the target of Aren's heavy without manufacturing an actor. Empty-air rejection, mana/cooldowns and actor hit resolution remain unchanged. A real fused Bloomseed burst still resolves its existing offensive explosion; ordinary projectile expiry does not invent one. +- Conditional/story interaction targets, movable/delivery objects, collider-paired route dressing and Greenwake's named corridor root cluster are protected. Bridges, gates, quest state, rewards, actor movement and `CameraDirector` retain their existing authority. + +### Lifetime and presentation + +Physics advances only on scaled world time, freezes under pause/hit-stop and resumes with slow motion. Engine serialization preserves local health, momentum and pose; restore baselines prevent old contacts from replaying chips. Encounter checkpoint/region replacement restores its own local baseline. Campaign Continue retains the existing safe-checkpoint policy: this is not a new permanent destruction world fact or save format. + +Native instanced transforms and per-instance scar shading retain their response through visibility changes and LOD projection. A bounded 64-slot secondary chip pool is cosmetic and non-colliding. Reduced Motion/Effects suppresses chips and reduces rooted deflection, but does not hide damage or undo loose-body displacement. + +### Exercised evidence and limits + +- **143 tests / 0 failures**, 1,841 assertions across the environmental, four-hero, run-reset, native-collider and Thunder Cleave suites. Regression coverage includes thin walls/stacked floors, genuine multi-hit pulses, zero-damage/support suppression, serialization, retry, airborne apex, resting bodies and removed native support. +- Final TypeScript + Vite production build passed; the existing large-chunk warning remains. Release-validator unit tests passed **236 / 236**. These validator tests do not certify the current candidate native assets for release. +- REA controlled the actual `CombatEngine` + `GameRenderer` + `StoryPresentation` native Greenwake fixture. Paired environmental response on/off retained the same actor HP **1000 → 964 → 946** and mana **100 → 80**. Contact advanced prop serial 1, delayed lightning serial 2; native matrices/materials changed, bodies settled, pause outside hit-stop held their exact pose, and Reduced Effects kept their damage/displacement. Native asset errors and missing actor assets were empty. +- Production UI smoke entered Greenwake as Aren/Koro, accepted real K down/up input (mana **100 → 88**, heavy cooldown shown) and exercised Escape/Resume. This verifies boot/input, not an additional environmental-contact or campaign-save claim. Five audio requests reported `net::ERR_ABORTED`; audio playback was not accepted by this smoke. +- [Captured transitions, source hashes, assertions and REA limitations](../../asset-source/playtests/evidence/environment-impact/acceptance.json); [moving native bodies](../../asset-source/playtests/evidence/environment-impact/moving.png), [settled damage](../../asset-source/playtests/evidence/environment-impact/after-settled.png), [Reduced Effects](../../asset-source/playtests/evidence/environment-impact/reduced.png). +- This is bounded native prop response, **not** triangle fracture, tree uprooting, terrain deformation, loose-body-to-loose-body collision, a whole-roster native art pass or an FPS/release certification. + diff --git a/src/content/levels/act-i.ts b/src/content/levels/act-i.ts index 9106cce..73aa64f 100644 --- a/src/content/levels/act-i.ts +++ b/src/content/levels/act-i.ts @@ -22,6 +22,10 @@ export const ACT_I_REGIONS: readonly RegionDefinition[] = [ id: "greenwake", authoringStatus: "unaccepted", name: "Greenwake", biome: "Forest-edge settlement and the Erya boundary", bounds: { min: v(-56, -1, -52), max: v(108, 14, 88) }, environmentRecipeId: "greenwake-environment", props: [ prop("greenwake-yara-courtyard", "prop-greenwake-houses", v(-12, 0, -4), "ground"), + // Reused native river stones: small, non-blocking loose bodies beside the first combat pocket. + prop("environment-loose-greenwake-west", "prop-mire-bank-rock", v(5.05, 0, -2.4), "ground", 0.3, v(0.12, 0.12, 0.12)), + prop("environment-loose-greenwake-east", "prop-mire-bank-rock", v(7.15, 0, -3.1), "ground", 1.1, v(0.14, 0.12, 0.13)), + prop("environment-loose-greenwake-north", "prop-mire-bank-rock", v(6.25, 0, -4.7), "ground", 2.2, v(0.11, 0.1, 0.12)), prop("greenwake-erya-edge", "prop-erya-edge-tree", v(17, 0, -9), "ground", -0.22, v(1.5, 1.5, 1.5)), prop("greenwake-entry-tree-west", "prop-erya-edge-tree", v(-19, 0, -14), "ground", -0.18, v(0.6, 0.6, 0.6)), prop("greenwake-entry-tree-center", "prop-erya-edge-tree", v(-13, 0, -14), "ground", 0.31, v(0.6, 0.6, 0.6)), diff --git a/src/content/solid-props.ts b/src/content/solid-props.ts index b629ab6..f5c461e 100644 --- a/src/content/solid-props.ts +++ b/src/content/solid-props.ts @@ -4,6 +4,7 @@ import { GROVE_BOUNDARY_ID_PREFIX, GROVE_SOLID_PROFILES } from '../game/combat/G import { MISTVEIL_BOUNDARY_ID_PREFIX, MISTVEIL_SOLID_PROFILES } from '../game/combat/MistveilContent.js'; import { MOONPINE_BOUNDARY_ID_PREFIX, MOONPINE_SOLID_PROFILES } from '../game/combat/MoonpineContent.js'; import type { WorldAssetPlacement } from '../game/contracts.js'; +import { isLooseEnvironmentProp } from '../game/combat/EnvironmentProps.js'; export type SolidPropKind = 'trunk' | 'solid' | 'arch' | 'platform' | 'decoration'; const PROFILES: Readonly> = { @@ -24,6 +25,8 @@ const DECORATION_PREFIXES = [GREENWAKE_BOUNDARY_ID_PREFIX, CANOPY_BOUNDARY_ID_PR /** Only explicitly soft vegetation is passable; unknown props remain physical. Boundary dressing of the playtest regions is visual only. */ export function solidPropKind(placement: WorldAssetPlacement): SolidPropKind { if (DECORATION_PREFIXES.some(prefix => placement.id.startsWith(prefix))) return 'decoration'; + // Small loose rubble can be stepped over; it must never contribute a stationary collider or standable top. + if (isLooseEnvironmentProp(placement)) return 'decoration'; return PROFILES[placement.assetId] ?? 'solid'; } diff --git a/src/game/combat/CombatEngine.ts b/src/game/combat/CombatEngine.ts index 0e0361f..34a5b8d 100644 --- a/src/game/combat/CombatEngine.ts +++ b/src/game/combat/CombatEngine.ts @@ -29,6 +29,7 @@ import { createChapterCombatState, resolveChapterTriggers, trapSnapshots, trapAr import { appendCue, emitWorldEvent as emitWorldEventTo, pushEvent } from './EngineEffects.js'; import { liveAttackOf as liveAttackIn, monsterAttackProgress as monsterAttackIn, reactToCompanionHit } from './HitReactions.js'; import { movableObjectOccupied, movableObjectPathClear, pointInPolygonXZ } from './MovableObjects.js'; +import { createEnvironment, emptyEnvironment, environmentCone, environmentDisk, environmentLane, environmentReachable, impactEnvironment, measureEnvironment, snapshotEnvironment, stepEnvironment, type EnvironmentProp, type EnvironmentState } from './EnvironmentProps.js'; import { actorCollisionBody, isAerialSpecies, isLargeSpecies } from './ActorBody.js'; import { ORUN_ENCOUNTER_ID, ORUN_SKY_ECHO_IDS, ORUN_TUNING, ORUN_VINE_ROUTE } from './OrunBossContent.js'; import { createOrunState, enterOrunPhase, noteOrunCoreStrike, noteOrunHazardDamage, noteOrunPressure, onOrunCleansed, onOrunInterrupted, onOrunPossessionEnded, orunCleanseBlock, orunEvadesStrike, orunGraspResisted, orunSnapshot, orunVineRoute, resetOrunForCheckpoint, updateOrun, type OrunHost } from './OrunPhases.js'; @@ -162,7 +163,8 @@ export interface LiveTelegraph { id: string; attackId: AttackId; sourceId: Actor /** One confirmed player hit on a hostile; `stagger` picks how the target's current attack reacts. */ interface PlayerStrike { action: string; clip: ActionId; damage: number; stopTicks: number; element: AttackElement; stagger: 'interrupt' | 'idle' | 'none'; instinct: number; counter: boolean; direction?: Vec3; /** Weight, poise damage and knockback; derived from the damage when absent. */ hit?: HitSpec } export interface CombatEvent { id: string; tick: number; kind: 'cue'|'damage'|'perfect-dodge'|'counter-open'|'action'|'companion'|'traversal'|'world'|'attack-interrupted'; actorId?: ActorId; targetId?: ActorId; actionId?: string; attackId?: AttackId; message: string; worldEvent?: WorldEvent; damage?: { readonly targetId: ActorId; readonly amount: number; readonly position: Vec3; readonly worldTick: number } } -export interface EncounterCheckpoint { tick: number; worldTick: number; lastAttackTick: number; attackSelectionWorldOffset?: number; counterUntil: number; actors: CombatActor[]; player: PlayerResources; skillReadyAt?: Partial>; phaseIndex: number; phaseLatch: number; phaseActionFlags: string[]; rewardEmitted: boolean; companions: CompanionRosterState; wildlifeCombat?:WildlifeCombatState; playerSlow?:PlayerSlow; combatTrial?:CombatTrialState; chapter?: ChapterCombatState; } +/** Older checkpoints lack local environmental state. */ +export interface EncounterCheckpoint { tick: number; worldTick: number; lastAttackTick: number; attackSelectionWorldOffset?: number; counterUntil: number; actors: CombatActor[]; player: PlayerResources; skillReadyAt?: Partial>; phaseIndex: number; phaseLatch: number; phaseActionFlags: string[]; rewardEmitted: boolean; companions: CompanionRosterState; wildlifeCombat?:WildlifeCombatState; playerSlow?:PlayerSlow; combatTrial?:CombatTrialState; chapter?: ChapterCombatState; environment?:EnvironmentState; } export interface PlayerResources { vitality: number; maxVitality: number; instinct: number; mana: number; maxMana: number; } export interface PendingImpact { activeAt: number; activeUntil: number; target: ActorId; action: string; range: number; radius: number; damage: number; interrupt: boolean; counter:boolean; startVitality: number; resolved: boolean; interactionId?:string; interactionPosition?:Vec3; } /** A companion (Vara) damage strike queued by applyStormTalonStrike; the engine applies it this tick via applyHostileDamage. */ @@ -182,6 +184,7 @@ export interface EngineState { /** Accepted skill deadlines on the simulation clock; family variants share the selected cast's deadline. */ skillReadyAt: Partial>; movableObjects: LiveMovableObject[]; + environment: EnvironmentState; sceneColliders: readonly RegionColliderDefinition[]; combatObstacles: CombatObstacleSnapshot[]; events: CombatEvent[]; worldEvents: WorldEvent[]; seenWorldKeys: string[]; perfectDodgeKeys: string[]; phaseIndex: number; phaseLatch: number; phaseActionFlags:string[]; @@ -273,6 +276,8 @@ export interface AttackImpact { targetId: ActorId; damage: number; avoided: bool export class CombatEngine { private state: EngineState; + /** A restored impact keeps its physical state but cannot re-emit cosmetic chips. Not serialized gameplay state. */ + private environmentPresentationBaseline = -Infinity; /** Scratch step for trial-monster arena clamping. */ private readonly monsterStep = { dx: 0, dz: 0 }; private readonly jumpLandings: number[] = []; @@ -307,11 +312,12 @@ export class CombatEngine { }; /** Current explicit lock state (player-pressed). `undefined` when not actively held/toggled. The implicit `targetId` lives on EngineState. */ private explicitLock?: { targetId: ActorId; startedTick: number }; - constructor(seed?: EngineStateSeed) { this.state = seed ? hydrateEngineState(seed) : createEngineState(); this.syncKit(); this.refreshCombatSolids(); this.cancelTraversal(); } + constructor(seed?: EngineStateSeed) { this.state = seed ? hydrateEngineState(seed) : createEngineState(); this.environmentPresentationBaseline=this.state.worldTick; this.syncKit(); this.refreshCombatSolids(); this.cancelTraversal(); } startNewRun(runId: string): void { this.resetSideQuestActivities(); this.dodgeTraining = undefined; this.state = createEngineState(); + this.environmentPresentationBaseline=this.state.worldTick; this.state.runId = runId; this.explicitLock = undefined; this.syncKit(); @@ -448,6 +454,7 @@ export class CombatEngine { private kitBeginAction(spec: KitActionSpec): void { const s = this.state, player = this.playerActor(); if (!player) return; + s.environment.actionKey = `kit:${++s.environment.sequence}`; s.pendingImpact = undefined; s.swing = undefined; s.cast = undefined; if (spec.light) { s.lightIndex = spec.light.step; s.lightChainFrom = s.tick + spec.totalTicks - spec.light.chainEarlyTicks; s.lightChainUntil = s.tick + spec.totalTicks + spec.light.chainLateTicks; } else s.lightIndex = 0; @@ -521,12 +528,22 @@ export class CombatEngine { spendMana: skill => engine.spendMana(skill), restoreMana: amount => { const resources = engine.state.player; resources.mana = Math.min(resources.maxMana, resources.mana + amount); }, addInstinct: amount => { const resources = engine.state.player; resources.instinct = Math.min(100, resources.instinct + amount); }, - strikeHostile: (target, strike) => engine.strikeHostile(target, { ...strike, counter: false }), - strikeDisk: (centre, radius, strike, hitIds) => engine.kitStrikeArea(actor => - distance2(centre, actor.position) <= radius + actor.radius && !engine.projectileWallHit(vec(centre.x, centre.y + 0.8, centre.z), engine.chestOf(actor), 0.05), strike, hitIds), - strikeLane: (from, to, width, strike, hitIds) => engine.kitStrikeArea(actor => - segmentDistanceXZ(from, to, actor.position).distance <= width / 2 + actor.radius && !engine.projectileWallHit(vec(from.x, from.y + 0.9, from.z), engine.chestOf(actor), 0.05), strike, hitIds), + strikeHostile: (target, strike) => { + const accepted = engine.strikeHostile(target, { ...strike, counter: false }); + // Projectile sweeps already resolve their physical contacts; callbacks must not award a second prop hit. + if (accepted && !strike.direction) engine.impactProps(engine.kitImpactKey(strike, false), strike.damage, target.position, prop => environmentDisk(prop, target.position, .65)); + return accepted; + }, + strikeDisk: (centre, radius, strike, hitIds) => { + engine.impactProps(engine.kitImpactKey(strike, hitIds === undefined), strike.damage, centre, prop => environmentDisk(prop, centre, radius)); + return engine.kitStrikeArea(actor => distance2(centre, actor.position) <= radius + actor.radius && !engine.projectileWallHit(vec(centre.x, centre.y + 0.8, centre.z), engine.chestOf(actor), 0.05), strike, hitIds); + }, + strikeLane: (from, to, width, strike, hitIds) => { + engine.impactProps(engine.kitImpactKey(strike, hitIds === undefined), strike.damage, from, prop => environmentLane(prop, from, to, width)); + return engine.kitStrikeArea(actor => segmentDistanceXZ(from, to, actor.position).distance <= width / 2 + actor.radius && !engine.projectileWallHit(vec(from.x, from.y + 0.9, from.z), engine.chestOf(actor), 0.05), strike, hitIds); + }, strikeCone: (origin, facing, reach, arcDegrees, strike, hitIds) => { + engine.impactProps(engine.kitImpactKey(strike, hitIds === undefined), strike.damage, origin, prop => environmentCone(prop, origin, facing, reach, arcDegrees), facing); const full = arcDegrees >= 360, halfArc = Math.cos(arcDegrees * Math.PI / 360); return engine.kitStrikeArea(actor => { const dx = actor.position.x - origin.x, dz = actor.position.z - origin.z, distance = Math.hypot(dx, dz); @@ -671,6 +688,9 @@ export class CombatEngine { this.state.runId=input.runId; this.state.timePolicy='normal';this.state.slowMotionRemainingTicks=0;this.state.slowMotionElapsedTicks=0;this.state.worldTimeScale=1;this.state.accumulator=0; this.state.worldFacts=structuredClone(input.worldFacts??{}); + this.state.environment = createEnvironment(region, new Set(INTERACTIONS.filter(interaction => interaction.regionId === region.id).map(interaction => interaction.targetId))); + this.environmentPresentationBaseline=this.state.worldTick; + measureEnvironment(this.state.environment, this.state.sceneColliders); this.state.movableObjects=region.movableObjects.map(definition=>{ const settled=input.worldFacts?.[definition.movedFactId]===true&&!ENCOUNTER_RESETTABLE_OBJECT_IDS.includes(definition.id); return {objectId:definition.id,position:copy(settled?definition.to:definition.from),phase:settled?'settled':'resting',authored:structuredClone(definition)}; @@ -747,6 +767,7 @@ export class CombatEngine { this.state.runId=input.runId; // Prepared scene geometry remains authoritative when entering its encounter. if(input.actors||mustLoadRegion)this.state.arena=arenaFromRegion(region,this.state.arena.cameraBasis); + if (input.actors && mustLoadRegion) this.state.environment = createEnvironment(region, new Set(INTERACTIONS.filter(interaction => interaction.regionId === region.id).map(interaction => interaction.targetId))); this.state.combatObstacles = []; this.refreshCombatSolids(); let player=this.playerActor(); @@ -1133,6 +1154,7 @@ export class CombatEngine { this.updatePhase(); this.updateCombatTrial(); } + stepEnvironment(this.state.environment, this.state.arena, this.state.worldTick, worldScale); if(this.state.actionRecoveryUntil>0&&this.state.tick>=this.state.actionRecoveryUntil&&this.state.actionId!=='idle')this.finishAction(); if(slowMotionActive&&this.state.timePolicy==='perfect-dodge-slow'){ this.state.slowMotionRemainingTicks=Math.max(0,this.state.slowMotionRemainingTicks-1); @@ -1175,6 +1197,7 @@ export class CombatEngine { actors, movableObjects:this.movableObjectSnapshots(), combatObstacles: structuredClone(this.state.combatObstacles), + environmentProps: snapshotEnvironment(this.state.environment, this.environmentPresentationBaseline), worldClock:{simulationTick:this.state.tick,worldTick:this.state.worldTick,worldTimeSeconds:this.state.worldTick*FIXED_STEP_SECONDS,timeScale:this.state.worldTimeScale,policy:this.state.timePolicy}, player:{heroId:this.state.heroId,vitality:this.state.player.vitality,maxVitality:this.state.player.maxVitality,instinct:this.state.player.instinct,mana:this.state.player.mana,maxMana:this.state.player.maxMana,...this.playerSnapshotExtras()}, skills:this.playerSkills(), @@ -1213,6 +1236,7 @@ export class CombatEngine { this.dodgeTraining = undefined; this.cancelTraversal(); this.state = hydrateEngineState(JSON.parse(serialized) as EngineStateSeed); + this.environmentPresentationBaseline=this.state.worldTick; this.syncKit(); this.refreshCombatSolids(); this.cancelTraversal(); @@ -1248,7 +1272,7 @@ export class CombatEngine { return structuredClone(event); } private captureEncounterCheckpoint(): EncounterCheckpoint { - return { tick: this.state.tick, worldTick:this.state.worldTick, lastAttackTick:this.state.lastAttackTick, attackSelectionWorldOffset:this.state.attackSelectionWorldOffset, counterUntil:this.state.counterUntil, actors: this.state.actors.map(cloneActor), player: structuredClone(this.state.player), skillReadyAt:{...this.state.skillReadyAt}, phaseIndex: this.state.phaseIndex, phaseLatch: this.state.phaseLatch, phaseActionFlags:[...this.state.phaseActionFlags], rewardEmitted: this.state.rewardEmitted, companions: structuredClone(this.state.companionState), wildlifeCombat:structuredClone(this.state.wildlifeCombat), playerSlow:structuredClone(this.state.playerSlow), combatTrial:structuredClone(this.state.combatTrial), chapter:structuredClone(this.state.chapter) }; + return { tick: this.state.tick, worldTick:this.state.worldTick, lastAttackTick:this.state.lastAttackTick, attackSelectionWorldOffset:this.state.attackSelectionWorldOffset, counterUntil:this.state.counterUntil, actors: this.state.actors.map(cloneActor), player: structuredClone(this.state.player), skillReadyAt:{...this.state.skillReadyAt}, phaseIndex: this.state.phaseIndex, phaseLatch: this.state.phaseLatch, phaseActionFlags:[...this.state.phaseActionFlags], rewardEmitted: this.state.rewardEmitted, companions: structuredClone(this.state.companionState), wildlifeCombat:structuredClone(this.state.wildlifeCombat), playerSlow:structuredClone(this.state.playerSlow), combatTrial:structuredClone(this.state.combatTrial), chapter:structuredClone(this.state.chapter), environment:structuredClone(this.state.environment) }; } resetToCheckpoint(): void { this.resetSideQuestActivities(); @@ -1260,6 +1284,10 @@ export class CombatEngine { this.setTimePolicy('normal'); this.state.tick = checkpoint.tick; const worldOffset=this.state.worldTick-checkpoint.worldTick; + this.environmentPresentationBaseline=this.state.worldTick; + this.state.environment = structuredClone(checkpoint.environment ?? emptyEnvironment()); + for (const prop of this.state.environment.props) if (prop.impactWorldTick >= 0) prop.impactWorldTick += worldOffset; + for (const contact of this.state.environment.contacts) contact.touchedAtWorldTick += worldOffset; this.state.actors = checkpoint.actors.map(cloneActor); for(const actor of this.state.actors){ actor.recoveringUntil=shiftDeadline(actor.recoveringUntil,worldOffset); @@ -1426,6 +1454,8 @@ export class CombatEngine { this.state.arena.colliders = this.state.arena.colliders.filter(collider => !previous.some(solid => solid.id === collider.id)); this.state.sceneColliders = colliders.slice(); this.state.arena.supports = supports.slice(); + measureEnvironment(this.state.environment, colliders); + for (const prop of this.state.environment.props) if (prop.material === 'loose-stone' && prop.impactSerial) prop.sleeping = false; this.refreshCombatSolids(); } @@ -1555,7 +1585,9 @@ export class CombatEngine { if(id==='dive'||id==='jump'||id==='light'||sigil)return ''; if((this.state.heroId==='yara'||this.kit)&&(id==='heavy'||id==='charged_heavy'))return ''; const target=targetId&&this.actorForId(targetId); - if(!target||!target.visible||target.downed||id!=='cleanse'&&target.allegiance!=='hostile'&&!target.possessed)return 'No target'; + if(!target||!target.visible||target.downed||id!=='cleanse'&&target.allegiance!=='hostile'&&!target.possessed){ + return (id==='heavy'||id==='charged_heavy')&&this.environmentTarget(id)?'':'No target'; + } if(id==='cleanse'&&(target.corruption??0)<=0&&!target.possessed)return 'No corruption'; if(id==='cleanse'){const sealed=this.cleanseBlockFor(target);if(sealed)return sealed;} if(id==='root_tether'&&!acceptsTether(this.state,target)&&!this.state.telegraphs.some(telegraph=>telegraph.sourceId===target.id&&(telegraph.phase==='windup'||telegraph.phase==='commit')))return 'Wait for windup'; @@ -1611,9 +1643,37 @@ export class CombatEngine { const trials=this.trials(); for(let index=this.state.combatTrial.index;index boolean, direction?: Vec3): void { + if (impactEnvironment(this.state.environment, this.state.arena, this.state.worldTick, key, damage, origin, contains, direction)) this.refreshCombatSolids(); + } + + /** No hit-id list means an intentional new pulse; tracked windows retain one group through hit-stop and rendering. */ + private kitImpactKey(strike: KitStrike, pulse: boolean): string { + const key = `kit:${this.state.environment.actionKey}:${strike.action}:${strike.clip}:${strike.damage}:${strike.hit?.poise ?? 0}`; + return pulse ? `${key}:${this.state.tick}` : key; + } + + /** Environmental heavy casts still need an eligible, reachable object in front of the hero; empty-air rejection remains. */ + private environmentTarget(action: 'heavy' | 'charged_heavy'): EnvironmentProp | undefined { + const player = this.playerActor(); + if (!player) return undefined; + const strike = targetedStrike(action); + let best: EnvironmentProp | undefined, bestDistance = Infinity; + for (const prop of this.state.environment.props) { + const distance = distance2(player.position, prop.position); + if (distance >= bestDistance || !environmentCone(prop, player.position, player.facing, strike.range + strike.radius, 110) || !environmentReachable(prop, this.state.arena, player.position)) continue; + best = prop; bestDistance = distance; + } + return best; + } + private expireCombatObstacles(): void { for (const obstacle of this.state.combatObstacles) { if (this.state.worldTick >= obstacle.expiresAtWorldTick) { @@ -2671,6 +2731,7 @@ export class CombatEngine { s.lightIndex=step;s.lightChainFrom=s.tick+spec.ticks-CHAIN_EARLY_TICKS;s.lightChainUntil=s.tick+spec.ticks+CHAIN_LATE_TICKS; s.actionId=action;s.actionUntil=s.tick+spec.ticks-LIGHT_LOCK_MARGIN_TICKS;s.actionRecoveryUntil=s.tick+spec.ticks;s.actionCancelable=false; s.pendingImpact=undefined;s.cast=undefined; + s.environment.actionKey = `light:${++s.environment.sequence}`; s.swing={step:step as PlayerSwing['step'],startedTick:s.tick,hitIds:[],counter,waveSpawned:false,slashed:false,baseFacing:copy(player.facing)}; player.actionId=this.playerClip(action);player.actionStartedTick=s.tick;player.normalizedTime=0;player.actionEndsTick=s.tick+spec.ticks; player.phase='anticipation';player.targetId=target?.id;player.velocity=vec(0,0,0); @@ -2729,6 +2790,7 @@ export class CombatEngine { if(this.spendManaAmount(CRESCENT_WAVE.manaCost))this.spawnCrescentWave(player); } if(elapsed>=spec.activeFrom&&elapsed<=spec.activeTo){ + this.impactProps(s.environment.actionKey ?? `light:${swing.startedTick}`, spec.damage, player.position, prop => environmentCone(prop, player.position, player.facing, spec.reach, spec.arc * 180 / Math.PI), player.facing); const halfArc=Math.cos(spec.arc/2),full=spec.arc>=2*Math.PI; for(const actor of s.actors){ if(!this.isPlayerHostileTarget(actor)||swing.hitIds.includes(actor.id))continue; @@ -2842,6 +2904,7 @@ export class CombatEngine { if(!target)continue; const loss=this.applyHostileDamage(target,strike.damage,strike.sourceId,strike.attackId,strike.attackId as AttackId); if(loss===null)continue; + if (loss > 0) this.impactProps(`companion:${strike.sourceId}:${strike.attackId}:${this.state.worldTick}`, loss, target.position, prop => environmentDisk(prop, target.position, .75), vec(strike.dirX, 0, strike.dirZ)); reactToCompanionHit(this.state,target,hit('light',strike.poise,strike.knockback),strike.dirX,strike.dirZ); } } @@ -3020,6 +3083,7 @@ export class CombatEngine { private heartrootBurst(position:Vec3,radius:number,damage:number,clip:string):void{ const player=this.playerActor(); if(!player)return; + this.impactProps(`heartroot:${clip}:${this.state.tick}`, damage, position, prop => environmentDisk(prop, position, radius)); this.queuePresentationCue({kind:'bloom',phase:'hit',actorId:player.id,actionId:clip as ActionId,position:copy(position),footprint:{width:radius*2,length:radius*2},durationTicks:36,element:'heartroot',intensity:1.5,priority:'essential',shape:'burst'}); for(const actor of this.state.actors){ if(!this.isPlayerHostileTarget(actor)||distance2(position,actor.position)>radius+actor.radius)continue; @@ -3032,6 +3096,7 @@ export class CombatEngine { private heartrootLane(player:CombatActor,range:number,width:number,damage:number,beam:boolean):void{ const from=this.chestOf(player),destination=vec(from.x+player.facing.x*range,from.y,from.z+player.facing.z*range); const to=this.projectileWallHit(from,destination,width/2)??destination; + this.impactProps(`heartroot-lane:${this.state.tick}`, damage, player.position, prop => environmentLane(prop, from, to, width), player.facing); this.queuePresentationCue({kind:beam?'beam':'root-eruption',phase:'active',actorId:player.id,actionId:player.actionId,position:vec((from.x+to.x)/2,player.position.y,(from.z+to.z)/2),from,to,footprint:{width,length:distance2(from,to),angleRadians:yawOf(player.facing)},durationTicks:30,element:'heartroot',intensity:1.5,priority:'essential',shape:beam?'beam':'trail'}); for(const actor of this.state.actors){ if(!this.isPlayerHostileTarget(actor)||segmentDistanceXZ(from,to,actor.position).distance>width/2+actor.radius)continue; @@ -3173,6 +3238,7 @@ export class CombatEngine { } if(first){ const impact=vec(from.x+(end.x-from.x)*firstT,from.y+(end.y-from.y)*firstT,from.z+(end.z-from.z)*firstT); + this.impactProps(`projectile:${projectile.id}`, projectile.damage, from, prop => environmentLane(prop, from, impact, projectile.radius * 2), projectile.direction); this.queueProjectileImpact(projectile,impact,first.id,1); projectile.position=impact; if(projectile.callbackId)this.kit?.onProjectileHit?.(projectile,first,impact); @@ -3191,6 +3257,7 @@ export class CombatEngine { else this.strikeHostile(actor,{action:'crescent_wave',clip:impactAction,damage:projectile.damage,stopTicks:CRESCENT_WAVE.hitStopTicks,element:'arcane',stagger:'none',instinct:0,counter:false,direction:projectile.direction,hit:AREN_HITS.crescent_wave}); } } + this.impactProps(`projectile:${projectile.id}`, projectile.damage, from, prop => environmentLane(prop, from, end, projectile.radius * 2), projectile.direction); if(wall){this.queueProjectileImpact(projectile,wall,undefined,0.6);finished=true;} projectile.position=end; projectile.ageTicks+=worldScale; @@ -3246,6 +3313,7 @@ export class CombatEngine { const s=this.state,pending=s.pendingLightning,player=this.playerActor(); if(!pending||!player||s.tick environmentDisk(prop, pending.position, THUNDER_CLEAVE.radius)); this.queuePresentationCue({kind:'lightning',phase:'active',actorId:player.id,position:copy(pending.position),footprint:{width:THUNDER_CLEAVE.radius*2,length:THUNDER_CLEAVE.radius*2},durationTicks:THUNDER_CLEAVE.cueTicks,intensity:2,element:'lightning',priority:'essential',shape:'burst'}); for(const actor of s.actors){ if(!this.isPlayerHostileTarget(actor)||distance2(pending.position,actor.position)>THUNDER_CLEAVE.radius+actor.radius)continue; @@ -3328,6 +3396,7 @@ export class CombatEngine { const mark=marks[index]!; if(s.tick environmentDisk(prop, mark.position, JUDGEMENT.markRadius)); for(const actor of s.actors){ if(!this.isPlayerHostileTarget(actor)||distance2(mark.position,actor.position)>JUDGEMENT.markRadius+actor.radius)continue; this.strikeHostile(actor,{action:'judgement',clip:ACTION_CLIPS.judgement!,damage:JUDGEMENT.markDamage,stopTicks:JUDGEMENT.detonationHitStopTicks,element:'arcane',stagger:'none',instinct:0,counter:false,hit:AREN_HITS.judgement}); @@ -3364,24 +3433,29 @@ export class CombatEngine { return this.startHeartrootCast(player,action==='heavy'?'root-spike':'root-eruption',action==='heavy'?HEARTROOT.spikeTicks:HEARTROOT.eruptionTicks); } if(this.skillReason(playerSkillId(action),targetId,counter))return false; - const target=targetId===undefined?undefined:this.actorForId(targetId); - if(!target||!target.visible||target.downed)return false; - if(action==='cleanse'&&(target.corruption??0)<=0&&!target.possessed)return false; + const candidate=targetId===undefined?undefined:this.actorForId(targetId); + const heavy=action==='heavy'||action==='charged_heavy'; + const actorTarget=candidate&&candidate.visible&&!candidate.downed&&(!heavy||candidate.allegiance==='hostile'||candidate.possessed)?candidate:undefined; + const target=actorTarget??(heavy?this.environmentTarget(action==='charged_heavy'?'charged_heavy':'heavy'):undefined); + if(!target)return false; + if(action==='cleanse'&&(!actorTarget||(actorTarget.corruption??0)<=0&&!actorTarget.possessed))return false; if(action==='root_tether'){ - const telegraph=this.state.telegraphs.find(item=>item.sourceId===target.id&&(item.phase==='windup'||item.phase==='commit')); - if(!telegraph&&!acceptsTether(this.state,target))return false; + if(!actorTarget)return false; + const telegraph=this.state.telegraphs.find(item=>item.sourceId===actorTarget.id&&(item.phase==='windup'||item.phase==='commit')); + if(!telegraph&&!acceptsTether(this.state,actorTarget))return false; } if(this.kit&&action==='root_tether'){ - if(!this.spendMana('root_tether'))return false; + if(!actorTarget||!this.spendMana('root_tether'))return false; this.state.targetId=target.id; - const accepted=this.kit.startTether(target); + const accepted=this.kit.startTether(actorTarget); if(!accepted)delete this.state.skillReadyAt.root_tether; return accepted; } if(!this.spendMana(playerSkillId(action)))return false; - this.state.targetId=target.id;this.state.lightIndex=0; + this.state.targetId=actorTarget?.id;this.state.lightIndex=0; + this.state.environment.actionKey=`heavy:${++this.state.environment.sequence}`; const attack=targetedStrike(action),duration=attack.ticks; - if(this.state.heroId==='aren'&&(action==='heavy'||action==='charged_heavy'))this.beginLunge(action==='heavy'?HEAVY_LUNGE:CHARGED_LUNGE,attack.activeAt,target.id); + if(actorTarget&&this.state.heroId==='aren'&&heavy)this.beginLunge(action==='heavy'?HEAVY_LUNGE:CHARGED_LUNGE,attack.activeAt,actorTarget.id); this.state.actionId=action; this.state.actionUntil=this.state.tick+duration; this.state.actionRecoveryUntil=this.state.actionUntil+attack.recoveryTicks; @@ -3390,14 +3464,14 @@ export class CombatEngine { player.actionStartedTick=this.state.tick;player.normalizedTime=0; player.actionEndsTick=this.state.actionRecoveryUntil; player.phase='anticipation'; - player.targetId=target.id; + player.targetId=actorTarget?.id; player.facing=facingFrom(sub(target.position,player.position)); const activeAt=this.state.tick+attack.activeAt; const attackId=`aren-${action}` as AttackId; const cue=action==='cleanse'?'Root light gathers around the target.':'Staff draws back before the strike.'; this.state.telegraphs.push({id:`${this.state.runId}:${this.state.tick}:${action}`,attackId,sourceId:player.id,targetPosition:copy(target.position),phase:'windup',remainingTicks:activeAt-this.state.tick,cue}); this.emit('cue',action==='cleanse'?'Cleanse: stay within reach until the pulse completes.':`Aren prepares ${action.replaceAll('_',' ')}.`,player.id,target.id,action,attackId); - this.state.pendingImpact={activeAt,activeUntil:activeAt+6,target:target.id,action,range:attack.range,radius:attack.radius,damage:attack.damage,interrupt:attack.interrupt,counter,startVitality:this.state.player.vitality,resolved:false}; + this.state.pendingImpact={activeAt,activeUntil:activeAt+6,target:target.id,action,range:attack.range,radius:attack.radius,damage:attack.damage,interrupt:attack.interrupt,counter,startVitality:this.state.player.vitality,resolved:false,...(!actorTarget?{interactionPosition:copy(target.position)}:{})}; if(action==='cleanse')this.kit?.onSharedSkill?.('cleanse'); return true; } @@ -3690,6 +3764,7 @@ export class CombatEngine { for(const hazard of this.state.hazards){ if(this.state.worldTick=hazard.activeUntil)continue; const attack=ATTACKS[hazard.attackId]; + if (hazard.damage > 0 && hazard.attackId !== 'orun-crown-possession') this.impactProps(`hazard:${hazard.id}`, Math.round(hazard.damage * (hazard.damageScale ?? 1)), hazard.kind === 'cone' ? hazard.origin : hazard.target, prop => hazardThreatens(hazard, prop), hazard.direction); if(!hazard.activePresentationEmitted){ hazard.activePresentationEmitted=true; this.spawnCombatObstacles(hazard, attack); @@ -3803,7 +3878,10 @@ export class CombatEngine { private resolvePlayerImpact(p:PendingImpact):void{ if(this.playerIsDown())return; const player=this.playerActor(); - if(player&&(p.action==='heavy'||p.action==='charged_heavy'))this.queueHeavySlash(player,p); + if(player&&(p.action==='heavy'||p.action==='charged_heavy')){ + this.impactProps(this.state.environment.actionKey ?? `heavy:${p.activeAt}`, p.damage, player.position, prop => environmentCone(prop, player.position, player.facing, p.range+p.radius, p.action==='charged_heavy'?160:137), player.facing); + this.queueHeavySlash(player,p); + } const target=this.state.actors.find(actor=>actor.id===p.target); const objectTarget=!target&&(p.action==='heavy'||p.action==='charged_heavy')?this.state.movableObjects.find(object=>object.objectId===p.target&&object.phase==='resting'&&object.authored.playerInteractionId===p.interactionId):undefined; const targetPosition=target?.position??objectTarget?.position??p.interactionPosition; @@ -4691,6 +4769,7 @@ export function createEngineState():EngineState{ schemaVersion:3,tick:0,worldTick:0,worldTimeScale:1,timePolicy:'normal',slowMotionRemainingTicks:0,slowMotionElapsedTicks:0,accumulator:0,regionId:'greenwake',runId:'unstarted',encounter:null,worldFacts:{}, actors:[player],playerId:'aren',heroId:'aren',player:{vitality:PLAYER_MAX_VITALITY,maxVitality:PLAYER_MAX_VITALITY,instinct:0,mana:MAX_MANA,maxMana:MAX_MANA}, movableObjects:[], + environment:emptyEnvironment(), skillReadyAt:{}, sceneColliders:[],combatObstacles:[], arena:{bounds:{min:vec(-12,0,-12),max:vec(12,12,12)},groundY:0,surfaces:[],colliders:[],routes:[],obstacles:[],cameraBasis:{forward:vec(0,0,-1),right:vec(1,0,0)},traversalAffordances:[]}, @@ -4724,6 +4803,7 @@ export function hydrateEngineState(seed:EngineStateSeed):EngineState{ state.arena={...base.arena,...seed.arena}; state.sceneColliders=structuredClone(seed.sceneColliders??[]); state.combatObstacles=structuredClone(seed.combatObstacles??[]); + state.environment=structuredClone(seed.environment??emptyEnvironment()); state.hazards=seed.hazards??[]; state.telegraphs=seed.telegraphs??[]; state.events=seed.events??[]; @@ -4842,7 +4922,7 @@ function jumpPosition(from:Vec3,to:Vec3,elapsed:number,flightTicks:number,apex:n return vec(from.x+(to.x-from.x)*u,from.y+(to.y-from.y)*u+4*apex*u*(1-u),from.z+(to.z-from.z)*u); } function add(a:Vec3,b:Vec3):Vec3{return{x:a.x+b.x,y:a.y+b.y,z:a.z+b.z};} -function hazardThreatens(h:LiveHazard,a:CombatActor):boolean{ +function hazardThreatens(h:LiveHazard,a:Pick):boolean{ const bodyTop=a.position.y+a.collisionHeight; const groundY=ATTACKS[h.attackId]?.element?h.target.y:h.origin.y; const hazardBottom=groundY-.15; diff --git a/src/game/combat/EnvironmentProps.ts b/src/game/combat/EnvironmentProps.ts new file mode 100644 index 0000000..9475551 --- /dev/null +++ b/src/game/combat/EnvironmentProps.ts @@ -0,0 +1,210 @@ +import type { EnvironmentPropSnapshot, RegionColliderDefinition, RegionDefinition, Vec3, WorldAssetPlacement } from '../contracts.js'; +import { groundHeightAt, isImpactPathClear, moveAerialCircle, pointInSector, type CombatArena } from './geometry.js'; + +export const LOOSE_PROP_PREFIX = 'environment-loose-'; +export type EnvironmentMaterial = 'wood' | 'foliage' | 'stone' | 'loose-stone'; +interface MaterialResponse { health: number; resistance: number; mobility: number; radius: number; height: number } +const MATERIALS: Record = { + wood: { health: 140, resistance: .24, mobility: .025, radius: .65, height: 3 }, + foliage: { health: 16, resistance: 1, mobility: .22, radius: .45, height: .9 }, + stone: { health: 220, resistance: .1, mobility: 0, radius: .8, height: 1.2 }, + 'loose-stone': { health: 18, resistance: .8, mobility: 1, radius: .18, height: .32 }, +}; +const ROOTED_WOOD: Record = { + 'prop-erya-edge-tree': true, 'prop-moonpine-tree': true, 'prop-rainforest-canopy-tree': true, + 'prop-savanna-acacia': true, 'prop-heartroot-trunk': true, 'prop-bamboo-cluster': true, 'prop-bamboo-grove': true, +}; +const FOLIAGE: Record = { 'prop-canopy-fern-cluster': true, 'prop-mistveil-reed-cluster': true }; +const STONE: Record = { + 'prop-greenwake-boulder-cluster': true, 'prop-canopy-cliff-rock': true, + 'prop-snow-ridge-rock': true, 'prop-mire-bank-rock': true, 'prop-bamboo-stone': true, +}; +const PROTECTED_ROUTE_PROPS: Record = { 'greenwake-corridor-tree-cluster-visual': true }; + +type EnvironmentPose = { -readonly [K in keyof EnvironmentPropSnapshot]: EnvironmentPropSnapshot[K] }; +type MutableVec3 = { -readonly [K in keyof Vec3]: Vec3[K] }; +export interface EnvironmentProp extends Omit { + position: MutableVec3; + origin: Vec3; + velocity: MutableVec3; + angularVelocity: { x: number; z: number }; + health: number; + radius: number; + sleeping: boolean; + collisionHeight: number; +} +export interface EnvironmentState { + props: EnvironmentProp[]; + sequence: number; + actionKey?: string; + /** A sustained contact may call resolution repeatedly; each prop receives that attack only once. */ + contacts: { key: string; touchedAtWorldTick: number; propIds: string[] }[]; +} +export function emptyEnvironment(): EnvironmentState { return { props: [], contacts: [], sequence: 0 }; } + +export function isLooseEnvironmentProp(prop: WorldAssetPlacement): boolean { + return prop.id.startsWith(LOOSE_PROP_PREFIX) && STONE[prop.assetId] === true && Math.max(prop.scale.x, prop.scale.y, prop.scale.z) <= .16; +} + +function materialFor(prop: WorldAssetPlacement): EnvironmentMaterial | undefined { + if (isLooseEnvironmentProp(prop)) return 'loose-stone'; + if (ROOTED_WOOD[prop.assetId]) return 'wood'; + if (FOLIAGE[prop.assetId]) return 'foliage'; + if (STONE[prop.assetId]) return 'stone'; + return undefined; +} + +/** Explicit material allowlist. Story props and conditional/native support placements never become loose bodies. */ +export function createEnvironment(region: RegionDefinition, protectedIds: ReadonlySet): EnvironmentState { + const protectedProps = new Set(protectedIds); + for (const prop of region.movableObjects) protectedProps.add(prop.id); + for (const delivery of region.deliveries ?? []) protectedProps.add(delivery.id); + for (const collider of region.ground.colliders) { + // Collider-paired route dressing must remain precisely aligned with its authored blocker. + for (const prop of region.props) if (collider.id.includes(prop.id.replace(/-visual$/, ''))) protectedProps.add(prop.id); + } + const props: EnvironmentProp[] = []; + for (const placement of region.props) { + const material = materialFor(placement); + if (!material || protectedProps.has(placement.id) || PROTECTED_ROUTE_PROPS[placement.id] || placement.visibleWhen) continue; + const profile = MATERIALS[material]; + props.push({ id: placement.id, material, position: { ...placement.position }, origin: { ...placement.position }, velocity: { x: 0, y: 0, z: 0 }, angularVelocity: { x: 0, z: 0 }, sleeping: true, tiltX: 0, tiltZ: 0, health: profile.health, damage: 0, broken: false, radius: profile.radius, collisionHeight: profile.height, impactSerial: 0, impactWorldTick: -1 }); + } + return { props, contacts: [], sequence: 0 }; +} + +/** Measured native bodies, rather than canopy size or guessed GLB coordinates, define physical contact. */ +export function measureEnvironment(environment: EnvironmentState, colliders: readonly RegionColliderDefinition[]): void { + for (const prop of environment.props) { + let radius = 0, top = prop.origin.y; + const prefix = `native:${prop.id}:`; + for (const collider of colliders) { + if (!collider.id.startsWith(prefix)) continue; + const half = collider.halfExtents; + radius = Math.max(radius, Math.hypot(collider.from.x - prop.origin.x, collider.from.z - prop.origin.z) + (half ? Math.hypot(half.x, half.z) : collider.radius ?? 0)); + top = Math.max(top, collider.from.y + (half?.y ?? collider.radius ?? 0)); + } + if (radius > 0) prop.radius = radius; + if (top > prop.origin.y) prop.collisionHeight = top - prop.origin.y; + } +} + +export function environmentDisk(prop: EnvironmentProp, center: Vec3, radius: number): boolean { + return prop.position.y <= center.y + 1.1 && prop.position.y + prop.collisionHeight >= center.y - .2 + && Math.hypot(prop.position.x - center.x, prop.position.z - center.z) <= radius + prop.radius; +} +export function environmentCone(prop: EnvironmentProp, origin: Vec3, direction: Vec3, reach: number, angleDegrees: number): boolean { + return prop.position.y <= origin.y + 1.1 && prop.position.y + prop.collisionHeight >= origin.y - .2 + && (Math.hypot(prop.position.x - origin.x, prop.position.z - origin.z) <= prop.radius + || pointInSector(prop.position, origin, direction, reach + prop.radius, angleDegrees)); +} +export function environmentLane(prop: EnvironmentProp, from: Vec3, to: Vec3, width: number): boolean { + const dx = to.x - from.x, dy = to.y - from.y, dz = to.z - from.z, lengthSquared = dx * dx + dz * dz; + const t = lengthSquared ? Math.max(0, Math.min(1, ((prop.position.x - from.x) * dx + (prop.position.z - from.z) * dz) / lengthSquared)) : 0; + const y = from.y + dy * t; + return prop.position.y <= y + width / 2 && prop.position.y + prop.collisionHeight >= y - width / 2 + && Math.hypot(prop.position.x - from.x - dx * t, prop.position.z - from.z - dz * t) <= width / 2 + prop.radius; +} + +const rayStart = { x: 0, y: 0, z: 0 }, rayEnd = { x: 0, y: 0, z: 0 }; +export function environmentReachable(prop: EnvironmentProp, arena: CombatArena, from: Vec3): boolean { + const dx = prop.position.x - from.x, dz = prop.position.z - from.z, distance = Math.hypot(dx, dz); + if (distance <= prop.radius + .12) return true; + // End just before the target's own measured solid. Other props, walls, terrain and decks still occlude it. + const t = (distance - prop.radius - .12) / distance; + rayStart.x = from.x; rayStart.y = from.y + .35; rayStart.z = from.z; + rayEnd.x = from.x + dx * t; rayEnd.y = prop.position.y + .35; rayEnd.z = from.z + dz * t; + return isImpactPathClear(arena, rayStart, rayEnd); +} + +/** Called exclusively by offensive simulation resolution. No StoryCue or render frame can award damage. */ +export function impactEnvironment(environment: EnvironmentState, arena: CombatArena, worldTick: number, key: string, damage: number, origin: Vec3, contains: (prop: EnvironmentProp) => boolean, direction?: Vec3): boolean { + if (!(damage > 0)) return false; + let contact = environment.contacts.find(contact => contact.key === key); + if (contact) contact.touchedAtWorldTick = worldTick; + let changedCollider = false; + for (const prop of environment.props) { + if (contact?.propIds.includes(prop.id) || !contains(prop) || !environmentReachable(prop, arena, origin)) continue; + if (!contact) { contact = { key, touchedAtWorldTick: worldTick, propIds: [] }; environment.contacts.push(contact); } + contact.propIds.push(prop.id); + const profile = MATERIALS[prop.material]; + const dx = direction?.x ?? prop.position.x - origin.x, dz = direction?.z ?? prop.position.z - origin.z; + const length = Math.hypot(dx, dz), nx = length > .001 ? dx / length : 0, nz = length > .001 ? dz / length : -1; + const distance = Math.max(0, Math.hypot(prop.position.x - origin.x, prop.position.z - origin.z) - prop.radius); + const transferredDamage = damage / (1 + .18 * distance); + const force = Math.min(4.5, .45 + Math.sqrt(transferredDamage) * .48); + prop.health = Math.max(0, prop.health - transferredDamage * profile.resistance); + prop.damage = 1 - prop.health / profile.health; + if (prop.health === 0 && !prop.broken) { prop.broken = true; changedCollider ||= prop.material === 'foliage'; } + if (prop.material === 'loose-stone') { + prop.sleeping = false; + prop.velocity.x += nx * force; prop.velocity.z += nz * force; + const speed = Math.hypot(prop.velocity.x, prop.velocity.z); + if (speed > 4.5) { prop.velocity.x *= 4.5 / speed; prop.velocity.z *= 4.5 / speed; } + prop.velocity.y = Math.min(2.2, prop.velocity.y + force * .36); + prop.angularVelocity.x += nz * force; prop.angularVelocity.z -= nx * force; + } else { + prop.angularVelocity.x += nz * force * profile.mobility; + prop.angularVelocity.z -= nx * force * profile.mobility; + } + prop.impactSerial++; prop.impactWorldTick = worldTick; + } + return changedCollider; +} + +const motion = { x: 0, y: 0, z: 0 }; +/** World time only: pause/hit-stop never advance this; slow motion supplies a smaller fixed step. */ +export function stepEnvironment(environment: EnvironmentState, arena: CombatArena, worldTick: number, worldScale: number): void { + const dt = worldScale / 60; + if (dt <= 0) return; + for (let i = environment.contacts.length - 1; i >= 0; i--) if (worldTick - environment.contacts[i]!.touchedAtWorldTick > 600) environment.contacts.splice(i, 1); + for (const prop of environment.props) { + if (!prop.impactSerial) continue; + if (prop.material === 'loose-stone' && !prop.sleeping) { + prop.velocity.y = Math.max(-8, prop.velocity.y - 9.8 * dt); + motion.x = prop.velocity.x * dt; motion.y = prop.velocity.y * dt; motion.z = prop.velocity.z * dt; + const moved = moveAerialCircle(arena, prop.position, motion, prop.radius, prop.collisionHeight); + let x = moved.x, y = moved.y, z = moved.z; + if (Math.abs(moved.x - prop.position.x - motion.x) > .005) prop.velocity.x *= -.2; + if (Math.abs(moved.z - prop.position.z - motion.z) > .005) prop.velocity.z *= -.2; + const floor = groundHeightAt(arena, x, z, prop.position.y); + if (floor === null || Math.hypot(x - prop.origin.x, z - prop.origin.z) > 4) { + // Small rubble never escapes the playable support or crosses a quest/traversal boundary. + prop.velocity.x = prop.velocity.z = 0; + x = prop.position.x; z = prop.position.z; + } + const landing = groundHeightAt(arena, x, z, prop.position.y) ?? prop.origin.y; + if (y <= landing + .01 && prop.velocity.y <= 0) { + y = landing; + prop.velocity.y = Math.abs(prop.velocity.y) > .65 ? -prop.velocity.y * .18 : 0; + const friction = Math.exp(-7 * dt); + prop.velocity.x *= friction; prop.velocity.z *= friction; + } else { prop.velocity.x *= Math.exp(-.7 * dt); prop.velocity.z *= Math.exp(-.7 * dt); } + prop.position.x = x; prop.position.y = y; prop.position.z = z; + if (y <= landing + .01 && Math.hypot(prop.velocity.x, prop.velocity.y, prop.velocity.z) < .035) { + prop.velocity.x = prop.velocity.y = prop.velocity.z = 0; + prop.sleeping = true; + } + } + const loose = prop.material === 'loose-stone'; + const spring = loose ? 0 : 55, damping = loose ? 4 : 8; + prop.angularVelocity.x += (-spring * prop.tiltX - damping * prop.angularVelocity.x) * dt; + prop.angularVelocity.z += (-spring * prop.tiltZ - damping * prop.angularVelocity.z) * dt; + const limit = loose ? Math.PI : prop.material === 'foliage' ? .55 : .045; + prop.tiltX = Math.max(-limit, Math.min(limit, prop.tiltX + prop.angularVelocity.x * dt)); + prop.tiltZ = Math.max(-limit, Math.min(limit, prop.tiltZ + prop.angularVelocity.z * dt)); + if (loose) { + if (Math.abs(prop.angularVelocity.x) < .0001) prop.angularVelocity.x = 0; + if (Math.abs(prop.angularVelocity.z) < .0001) prop.angularVelocity.z = 0; + } else if (Math.abs(prop.tiltX) + Math.abs(prop.tiltZ) + Math.abs(prop.angularVelocity.x) + Math.abs(prop.angularVelocity.z) < .0001) prop.tiltX = prop.tiltZ = prop.angularVelocity.x = prop.angularVelocity.z = 0; + } +} + +export function snapshotEnvironment(environment: EnvironmentState, presentationBaseline = -Infinity): EnvironmentPropSnapshot[] { + const snapshots: EnvironmentPropSnapshot[] = []; + for (const prop of environment.props) { + if (!prop.impactSerial) continue; + snapshots.push({ id: prop.id, material: prop.material, position: { ...prop.position }, offset: { x: prop.position.x - prop.origin.x, y: prop.position.y - prop.origin.y, z: prop.position.z - prop.origin.z }, tiltX: prop.tiltX, tiltZ: prop.tiltZ, damage: prop.damage, broken: prop.broken, impactSerial: prop.impactSerial, impactWorldTick: prop.impactWorldTick > presentationBaseline ? prop.impactWorldTick : -1 }); + } + return snapshots; +} diff --git a/src/game/combat/geometry.ts b/src/game/combat/geometry.ts index 96cfee1..b5e8f64 100644 --- a/src/game/combat/geometry.ts +++ b/src/game/combat/geometry.ts @@ -216,6 +216,31 @@ export function isAerialCirclePathClear(arena: CombatArena, start: Vec3, end: Ve return true; } +const impactRayPoint = { x: 0, y: 0, z: 0 }; + +/** Force visibility is a narrow solid-volume query, not a character movement/landing plan. */ +export function isImpactPathClear(arena: CombatArena, start: Vec3, end: Vec3): boolean { + const radius = .06, height = .12; + for (let endpoint = 0; endpoint < 2; endpoint++) { + const point = endpoint === 0 ? start : end; + if (point.x < arena.bounds.min.x + radius || point.x > arena.bounds.max.x - radius || point.z < arena.bounds.min.z + radius || point.z > arena.bounds.max.z - radius || point.y < arena.bounds.min.y || point.y + height > arena.bounds.max.y) return false; + } + const dx = end.x - start.x, dy = end.y - start.y, dz = end.z - start.z; + // Overlapping query discs cover thin blockers; do not repeat a capsule sweep at every sample. + const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy, dz) / radius)); + let previousY = start.y; + for (let step = 0; step <= steps; step++) { + impactRayPoint.x = start.x + dx * step / steps; + impactRayPoint.y = start.y + dy * step / steps; + impactRayPoint.z = start.z + dz * step / steps; + if (blocked(arena, impactRayPoint, radius, previousY, height)) return false; + const floor = groundHeightAt(arena, impactRayPoint.x, impactRayPoint.z, impactRayPoint.y, false); + if (floor !== null && floor > impactRayPoint.y + 1e-6) return false; + previousY = impactRayPoint.y; + } + return true; +} + /** * A planned landing: the centre stands on a standable top at exactly `point.y`, the rounded capsule and its headroom are clear * (the sweeps of {@link blocked}), and every rim sample has ground under it: authored ground within 0.15 of the foot, or an diff --git a/src/game/contracts.ts b/src/game/contracts.ts index b25dc3a..5de4b96 100644 --- a/src/game/contracts.ts +++ b/src/game/contracts.ts @@ -366,6 +366,20 @@ export interface DodgePracticeSnapshot { readonly phase: "idle" | "warning" | "strike" | "recovery"; } +/** Simulation-owned response of one native prop. Local damage is not a quest/world fact. */ +export interface EnvironmentPropSnapshot { + readonly id: string; + readonly material: 'wood' | 'foliage' | 'stone' | 'loose-stone'; + readonly position: Vec3; + readonly offset: Vec3; + readonly tiltX: number; + readonly tiltZ: number; + readonly damage: number; + readonly broken: boolean; + readonly impactSerial: number; + readonly impactWorldTick: number; +} + export interface CombatSnapshot { actors: readonly ActorSnapshot[]; movableObjects: readonly MovableObjectSnapshot[]; @@ -375,6 +389,7 @@ export interface CombatSnapshot { readonly nurseryEscortActive?: boolean; readonly worldClock: WorldClockSnapshot; readonly combatObstacles?: readonly CombatObstacleSnapshot[]; + readonly environmentProps?: readonly EnvironmentPropSnapshot[]; readonly skills: readonly PlayerSkillSnapshot[]; readonly encounterStatus?: EncounterStatusSnapshot; readonly trialGates?: readonly TrialGateSnapshot[]; diff --git a/src/rendering/EnvironmentPropVisuals.ts b/src/rendering/EnvironmentPropVisuals.ts new file mode 100644 index 0000000..781c622 --- /dev/null +++ b/src/rendering/EnvironmentPropVisuals.ts @@ -0,0 +1,78 @@ +import { Color, type Scene } from 'three'; +import type { EnvironmentPropSnapshot, WorldClockSnapshot } from '../game/contracts.js'; +import { DebrisFx } from '../effects/vfx/InstancedFx.js'; +import type { NativeStaticBatch } from './NativeAssetLoader.js'; + +const COLORS = { wood: new Color(0x8b7451), foliage: new Color(0x5f743f), stone: new Color(0x797b76), 'loose-stone': new Color(0x797b76) }; + +/** Snapshot projection only: native instance transforms/scars plus a bounded, non-colliding chip pool. */ +export class EnvironmentPropVisuals { + readonly debris = new DebrisFx(64); + readonly brokenFoliageIds = new Set(); + private readonly serials = new Map(); + private readonly applied = new Set(); + private readonly seen = new Set(); + private lastWorldTick: number | undefined; + private lastSimulationTick: number | undefined; + private reducedMotion = false; + + constructor(scene: Scene, private readonly batches: ReadonlyMap) { + this.debris.mesh.name = 'environment-impact-chips'; + scene.add(this.debris.mesh); + } + + /** Returns whether camera/native-solid visibility needs rebuilding after foliage collapses or restores. */ + update(props: readonly EnvironmentPropSnapshot[], clock: WorldClockSnapshot, reducedMotion: boolean): boolean { + const rewind = this.lastWorldTick !== undefined && (clock.worldTick < this.lastWorldTick || clock.simulationTick < this.lastSimulationTick!); + const baseline = this.lastWorldTick === undefined || rewind; + if (rewind || reducedMotion && !this.reducedMotion) this.debris.clear(); + this.reducedMotion = reducedMotion; + this.seen.clear(); + let solidsChanged = false; + for (const prop of props) { + this.seen.add(prop.id); + const brokenFoliage = prop.broken && prop.material === 'foliage'; + if (brokenFoliage !== this.brokenFoliageIds.has(prop.id)) { + solidsChanged = true; + if (brokenFoliage) this.brokenFoliageIds.add(prop.id); else this.brokenFoliageIds.delete(prop.id); + } + const batches = this.batches.get(prop.id); + if (batches) for (const batch of batches) batch.setPlacementResponse(prop.id, prop, reducedMotion); + const previous = this.serials.get(prop.id) ?? 0; + const age = clock.worldTick - prop.impactWorldTick; + if (!baseline && !reducedMotion && clock.policy !== 'paused' && prop.impactSerial > previous && prop.impactWorldTick >= 0 && age >= 0 && age <= 3) { + // Seeded directions, independent of render cadence; damage/motion remain even when this secondary pool is disabled. + const count = prop.broken ? 5 : 3; + for (let i = 0; i < count; i++) { + const angle = i * 2.399963 + prop.impactSerial * 1.7; + const speed = .6 + i * .17; + this.debris.spawn(prop.position.x, prop.position.y + .04, prop.position.z, Math.cos(angle) * speed, .8 + i * .2, Math.sin(angle) * speed, prop.material === 'foliage' ? .055 : .075, 1.5, clock.worldTimeSeconds - age / 60, 0, COLORS[prop.material], i + prop.impactSerial * .31); + } + } + this.serials.set(prop.id, prop.impactSerial); + this.applied.add(prop.id); + } + for (const id of this.applied) { + if (this.seen.has(id)) continue; + const batches = this.batches.get(id); + if (batches) for (const batch of batches) batch.setPlacementResponse(id, undefined, reducedMotion); + this.applied.delete(id); this.serials.delete(id); + if (this.brokenFoliageIds.delete(id)) solidsChanged = true; + } + this.debris.update(clock.worldTimeSeconds); + this.lastWorldTick = clock.worldTick; this.lastSimulationTick = clock.simulationTick; + return solidsChanged; + } + + clear(): void { + for (const id of this.applied) { + const batches = this.batches.get(id); + if (batches) for (const batch of batches) batch.setPlacementResponse(id, undefined, false); + } + this.applied.clear(); this.seen.clear(); this.serials.clear(); this.brokenFoliageIds.clear(); + this.lastWorldTick = this.lastSimulationTick = undefined; + this.debris.clear(); + } + + dispose(): void { this.clear(); this.debris.dispose(); } +} diff --git a/src/rendering/GameRenderer.ts b/src/rendering/GameRenderer.ts index 211b0a9..383b7f3 100644 --- a/src/rendering/GameRenderer.ts +++ b/src/rendering/GameRenderer.ts @@ -21,6 +21,7 @@ import { arenaFromRegion, groundHeightAt, type CombatArena, type SupportField } import { ContactShadows, GROUNDED_FADE, HOVER_FADE, contactShadowOpacity, measureFootprint, type ContactFootprint } from './ContactShadows'; import { FootGrounding, groundNormalAt, type GroundHeightFn } from './FootGrounding'; import { CombatObstacleVisuals } from '../effects/CombatObstacleVisuals'; +import { EnvironmentPropVisuals } from './EnvironmentPropVisuals'; import { ActorHitFlash } from '../effects/vfx/HitFlash'; import { ActorBodies } from '../effects/vfx/ActorBounds'; import { PostFX } from './PostFX'; @@ -48,6 +49,7 @@ const SPIN_ACTION_ID = 'aren-light-3'; /** The player's steering heading turns at most 16 rad/s (0.27 rad per tick); a per-tick turn up to this is followed exactly, a larger one (an attack or dodge setting the heading outright) eases in. */ const STEERED_TURN_MAX = 0.4; const EMPTY_COMBAT_OBSTACLES = Object.freeze([]); +const EMPTY_ENVIRONMENT_PROPS = Object.freeze([]); /** Actions during which the feet leave the ground by design (jumps, rolls, vaults, slides, flips, defeat): no foot grounding. */ const OFF_GROUND_ACTION = /jump|air-step|dive|roll|vault|slide|cartwheel|handspring|defeat/; /** Hero actions that are plain locomotion or standing: the gaze and the stance foot-lock run only in these. */ @@ -143,6 +145,7 @@ export class GameRenderer { private readonly resizeObserver: ResizeObserver; private afterimages: PoseAfterimages | null = null; private readonly obstacleVisuals: CombatObstacleVisuals; + private readonly propResponses: EnvironmentPropVisuals; private readonly orunPresentation: OrunPresentation; private nativeSolidEntries: NativeSolidCollider[] = []; private readonly sceneColliders: RegionColliderDefinition[] = []; @@ -228,6 +231,7 @@ export class GameRenderer { this.cameraDirector = new CameraDirector(this.scene); this.camera = this.cameraDirector.camera; this.obstacleVisuals = new CombatObstacleVisuals(this.scene); + this.propResponses = new EnvironmentPropVisuals(this.scene, this.placementBatches); this.orunPresentation = new OrunPresentation(this.scene); this.postFx = new PostFX(this.renderer, this.scene, this.camera, { pixelRatioCap: this.pixelRatioCap }); this.scene.add(new AmbientLight(0xffffff, 0.05)); @@ -529,6 +533,7 @@ export class GameRenderer { this.entityRoots.clear(); this.actorRoots.clear(); this.actorManifests.clear(); + this.propResponses.clear(); this.placementBatches.clear(); this.movableProps.clear(); this.migrationGates.clear(); @@ -823,6 +828,7 @@ export class GameRenderer { if (this.atmosphere) updateRegionShafts(this.postFx, this.atmosphere, this.camera); if (player) this.afterimages?.update(player, combat.worldClock, this.reducedMotion || !this.motionBlur); this.obstacleVisuals.update(combat.combatObstacles ?? EMPTY_COMBAT_OBSTACLES, combat.worldClock, this.reducedMotion); + if (this.propResponses.update(combat.environmentProps ?? EMPTY_ENVIRONMENT_PROPS, combat.worldClock, this.reducedMotion)) this.applyPlacementVisibility(); this.orunPresentation.sync(combat, this.reducedMotion); this.trialGates?.update(combat.trialGates ?? []); this.trialGates?.animate(this.renderWorldTimeSeconds, active?.root.position ?? player?.position); @@ -1346,6 +1352,7 @@ export class GameRenderer { this.orunWeapon = null; this.hitFlash.dispose(); this.obstacleVisuals.dispose(); + this.propResponses.dispose(); this.orunPresentation.dispose(); this.trialGates?.dispose(); this.trialGates = null; this.waystone?.dispose(); this.waystone = null; @@ -1422,6 +1429,7 @@ export class GameRenderer { this.sceneSupports.length = 0; for (const entry of this.nativeSolidEntries) { if (entry.visibleWhen && this.worldFacts[entry.visibleWhen] !== true) continue; + if (this.propResponses.brokenFoliageIds.has(entry.placementId)) continue; this.sceneColliders.push(entry.collider); if (entry.support && !this.sceneSupports.includes(entry.support)) this.sceneSupports.push(entry.support); } diff --git a/src/rendering/NativeAssetLoader.ts b/src/rendering/NativeAssetLoader.ts index 23c9000..ecc63df 100644 --- a/src/rendering/NativeAssetLoader.ts +++ b/src/rendering/NativeAssetLoader.ts @@ -1,7 +1,7 @@ -import { AnimationClip, BufferAttribute, BufferGeometry, Group, InstancedBufferAttribute, InstancedMesh, InterleavedBuffer, InterleavedBufferAttribute, LoadingManager, Matrix4, Object3D, PropertyBinding, Quaternion, SkinnedMesh, Texture, Vector3, type Camera, type Material } from 'three'; +import { AnimationClip, BufferAttribute, BufferGeometry, Euler, Group, InstancedBufferAttribute, InstancedMesh, InterleavedBuffer, InterleavedBufferAttribute, LoadingManager, Matrix4, Object3D, PropertyBinding, Quaternion, SkinnedMesh, Texture, Vector3, type Camera, type Material } from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { clone as cloneSkinned } from 'three/addons/utils/SkeletonUtils.js'; -import type { AssetManifestEntry, WorldAssetPlacement } from '../game/contracts'; +import type { AssetManifestEntry, EnvironmentPropSnapshot, WorldAssetPlacement } from '../game/contracts'; import { DEFAULT_FOLIAGE_LIMITS, InstanceVisibility, type FoliageLimits } from './InstanceVisibility'; import { AnimationController } from './AnimationController'; import { simplifyCandidateGeometry } from './CandidateGeometrySimplifier'; @@ -13,6 +13,8 @@ export interface NativeAssetInstance { readonly assetId: string; readonly root: export interface NativeStaticBatch { readonly assetId: string; readonly root: Group; readonly instanceCount: number; readonly lodIds: readonly string[]; setPlacementVisible(placementId: string, visible: boolean): void; updateOcclusion(camera: Camera, target: Vector3, dt?: number): void; dispose(): void; + /** Projects an authoritative local response onto one instance; undefined restores its authored pose/material. */ + setPlacementResponse(placementId: string, response: EnvironmentPropSnapshot | undefined, reducedMotion: boolean): void; /** Instances drawn right now (neither hidden by a world fact nor culled by the foliage view-distance limit). */ readonly drawnInstanceCount: number; /** Pushes the current occlusion fade forward one frame; returns whether any alpha was re-uploaded. Allocation free. */ @@ -94,6 +96,14 @@ export class NativeAssetLoader { const staticMeshes: InstancedMesh[] = []; const attributes: InstancedBufferAttribute[] = []; const matricesByMesh: Matrix4[][] = []; + const baseMatricesByMesh: Matrix4[][] = []; + const damage = new Float32Array(placements.length); + const damageAttributes: InstancedBufferAttribute[] = []; + const responseValues = new Float64Array(placements.length * 7); + const responseMatrix = new Matrix4(), responseBack = new Matrix4(); + const responseRotation = new Quaternion(), responseEuler = new Euler(); + const responsePosition = new Vector3(), responseScale = new Vector3(); + let responseBoundsExpanded = false; const placementIndices = new Map(placements.map((placement, index) => [placement.id, index])); const positions = placements.map((placement) => new Vector3(placement.position.x, placement.position.y, placement.position.z)); const visibility = new InstanceVisibility(positions); @@ -110,6 +120,9 @@ export class NativeAssetLoader { const geometry = shallowCloneGeometry(mesh.geometry); const attribute = new InstancedBufferAttribute(visibility.alpha, 1); geometry.setAttribute('instanceOpacity', attribute); + const damageAttribute = new InstancedBufferAttribute(damage, 1); + geometry.setAttribute('instanceDamage', damageAttribute); + damageAttributes.push(damageAttribute); if (variationArrays) { // Three-component tint (rgb), three-component scale jitter (x, y, z), two-component lean (x, z) and a phase float. The // vertex shader reads them when `instanceTint` is bound; setting the attribute unconditionally is safe even when the @@ -149,7 +162,7 @@ export class NativeAssetLoader { const sz = placement.scale.z * (variation ? variation[2]! : 1); const scale = new Vector3(sx, sy, sz); const matrix = new Matrix4().compose(positions[index]!, rotation, scale).multiply(localMatrix); - matrices.push(matrix.clone()); + matrices.push(matrix); batch.setMatrixAt(index, matrix); } batch.instanceMatrix.needsUpdate = true; @@ -157,6 +170,7 @@ export class NativeAssetLoader { staticMeshes.push(batch); attributes.push(attribute); matricesByMesh.push(matrices); + baseMatricesByMesh.push([]); }); if (!staticMeshes.length) throw new NativeAssetError(manifest.id, 'GLB has no static mesh for instancing'); } catch (error) { @@ -185,6 +199,48 @@ export class NativeAssetLoader { if (released || index === undefined) return; if (visibility.setFactVisible(index, visible)) writeInstance(index); }, + setPlacementResponse: (placementId, response, reducedMotion) => { + const index = placementIndices.get(placementId); + if (released || index === undefined) return; + const placement = placements[index]!, base = placement.position; + const offset = response?.offset; + const dx = offset?.x ?? 0, dy = offset?.y ?? 0, dz = offset?.z ?? 0; + const motionScale = reducedMotion && response?.material !== 'loose-stone' ? .2 : 1; + const tiltX = (response?.tiltX ?? 0) * motionScale, tiltZ = (response?.tiltZ ?? 0) * motionScale; + const amount = response?.damage ?? 0; + const crush = response?.broken ? response.material === 'foliage' ? .22 : response.material === 'loose-stone' ? .82 : 1 : 1; + const o = index * 7; + // Zero means the authored scale; this cache also skips repeated paused/culled-frame uploads. + if (responseValues[o] === dx && responseValues[o + 1] === dy && responseValues[o + 2] === dz + && responseValues[o + 3] === tiltX && responseValues[o + 4] === tiltZ && responseValues[o + 5] === amount && responseValues[o + 6] === crush - 1) return; + const damageChanged = responseValues[o + 5] !== amount; + responseValues[o] = dx; responseValues[o + 1] = dy; responseValues[o + 2] = dz; + responseValues[o + 3] = tiltX; responseValues[o + 4] = tiltZ; responseValues[o + 5] = amount; responseValues[o + 6] = crush - 1; + responseRotation.setFromEuler(responseEuler.set(tiltX, 0, tiltZ)); + responsePosition.set(base.x + dx, base.y + dy, base.z + dz); + const coreScale = response?.broken && response.material === 'loose-stone' ? .82 : 1; + responseScale.set(coreScale, crush, coreScale); + responseMatrix.compose(responsePosition, responseRotation, responseScale); + responseBack.makeTranslation(-base.x, -base.y, -base.z); + responseMatrix.multiply(responseBack); + positions[index]!.set(base.x + dx, base.y + dy, base.z + dz); + for (let meshIndex = 0; meshIndex < staticMeshes.length; meshIndex++) { + const matrix = matricesByMesh[meshIndex]![index]!; + const originals = baseMatricesByMesh[meshIndex]!; + const original = originals[index] ?? (originals[index] = matrix.clone()); + matrix.copy(original).premultiply(responseMatrix); + if (!responseBoundsExpanded) { + const sphere = staticMeshes[meshIndex]!.boundingSphere; + if (sphere) sphere.radius += 6; // Simulation bounds loose travel to 4 m; rooted deflection stays below this margin. + } + } + responseBoundsExpanded = true; + if (damageChanged) { + damage[index] = amount; + for (const attribute of damageAttributes) attribute.needsUpdate = true; + } + writeInstance(index); + }, updateOcclusion: (camera, target, dt) => { if (released) return; visibility.updateDistance(camera.position, limits.maxViewDistance, writeInstance); @@ -439,8 +495,8 @@ function cloneFadingMaterial( ? `\nuniform float uWorldWind;\nuniform float uReducedMotion;\nuniform float uFoliageMeshHeight;\nvarying float vSwayHeight;` : ''; shader.vertexShader = shader.vertexShader - .replace('#include ', '#include \nattribute float instanceOpacity;\nvarying float vInstanceOpacity;' + variationHeader + windHeader) - .replace('#include ', '#include \nvInstanceOpacity = instanceOpacity;' + (hasVariation + .replace('#include ', '#include \nattribute float instanceOpacity;\nattribute float instanceDamage;\nvarying float vInstanceOpacity;\nvarying float vInstanceDamage;\nvarying vec3 vDamagePosition;' + variationHeader + windHeader) + .replace('#include ', '#include \nvInstanceOpacity = instanceOpacity;vInstanceDamage = instanceDamage;vDamagePosition = position;' + (hasVariation ? ` vInstanceTint = instanceTint; vInstanceScaleJitter = instanceScaleJitter; @@ -469,8 +525,8 @@ vec3 leaned = vec3(cz * transformed.x - sz * transformed.y, sz * transformed.x + transformed = vec3(leaned.x, cx * leaned.y - sx * leaned.z, sx * leaned.y + cx * leaned.z);` : '')); shader.fragmentShader = shader.fragmentShader - .replace('#include ', '#include \nvarying float vInstanceOpacity;' + (hasVariation ? '\nvarying vec3 vInstanceTint;varying vec3 vInstanceScaleJitter;varying vec2 vInstanceLean;varying float vInstancePhase;' : '') + (hasWind ? '\nvarying float vSwayHeight;' : '') + (dither ? '\n' + DITHER_GLSL : '')) - .replace('#include ', (hasVariation ? 'diffuseColor.rgb *= vInstanceTint;' : '') + '\n#include ') + .replace('#include ', '#include \nvarying float vInstanceOpacity;\nvarying float vInstanceDamage;\nvarying vec3 vDamagePosition;' + (hasVariation ? '\nvarying vec3 vInstanceTint;varying vec3 vInstanceScaleJitter;varying vec2 vInstanceLean;varying float vInstancePhase;' : '') + (hasWind ? '\nvarying float vSwayHeight;' : '') + (dither ? '\n' + DITHER_GLSL : '')) + .replace('#include ', (hasVariation ? 'diffuseColor.rgb *= vInstanceTint;' : '') + '\n#include \nfloat impactScar = smoothstep(0.89, 0.99, abs(sin(vDamagePosition.x * 21.0 + vDamagePosition.z * 9.0 + sin(vDamagePosition.y * 17.0) * 2.0)));\ndiffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.17, 0.12, 0.085), vInstanceDamage * (0.28 + 0.65 * impactScar));') .replace('#include ', (dither ? 'if (vInstanceOpacity < 0.999 && vInstanceOpacity <= wcBayer(gl_FragCoord.xy)) discard;' : 'diffuseColor.a *= vInstanceOpacity;') + '\n#include '); if (hasWind) { shader.uniforms.uWorldWind = worldWind; @@ -479,7 +535,7 @@ transformed = vec3(leaned.x, cx * leaned.y - sx * leaned.z, sx * leaned.y + cx * shader.uniforms.uFoliageMeshHeight = { value: Math.max(FOLIAGE_MESH_HEIGHT_FALLBACK, meshHeight) }; } }; - const programKey = 'wild-crown-instance-' + (hasVariation ? 'var-' : 'flat-') + (hasWind ? 'wind-' : '') + (dither ? 'dither-fade-v1' : 'occlusion-fade-v3'); + const programKey = 'wild-crown-instance-damage-v1-' + (hasVariation ? 'var-' : 'flat-') + (hasWind ? 'wind-' : '') + (dither ? 'dither-fade-v1' : 'occlusion-fade-v3'); shaderMaterial.customProgramCacheKey = () => programKey; return cloned; }; diff --git a/src/rendering/NativeSolidColliders.ts b/src/rendering/NativeSolidColliders.ts index 759590f..6653032 100644 --- a/src/rendering/NativeSolidColliders.ts +++ b/src/rendering/NativeSolidColliders.ts @@ -3,6 +3,7 @@ import type { RegionColliderDefinition, RegionDefinition, WorldAssetPlacement } import { solidPropKind, supportKind } from '../content/solid-props.js'; import type { SupportField } from '../game/combat/geometry.js'; import type { NativeStaticBatch } from './NativeAssetLoader.js'; +import { isLooseEnvironmentProp } from '../game/combat/EnvironmentProps.js'; import { measureSupportField } from './NativeSupportFields.js'; export interface NativeSolidCollider { @@ -39,7 +40,8 @@ export function buildNativePropColliders(root: Object3D, placement: WorldAssetPl function buildPropColliders(root: Object3D, placements: readonly WorldAssetPlacement[], region: RegionDefinition, instanced: boolean): NativeSolidCollider[] { if (!placements.length) return []; const kind = solidPropKind(placements[0]!); - if (kind === 'decoration') return []; + const loose = isLooseEnvironmentProp(placements[0]!); + if (kind === 'decoration' && !loose) return []; const meshes: { mesh: Mesh; local: Matrix4 }[] = []; const position = new Vector3(), scale = new Vector3(), rotation = new Quaternion(); const placementMatrix = (placement: WorldAssetPlacement) => new Matrix4().compose( @@ -63,7 +65,10 @@ function buildPropColliders(root: Object3D, placements: readonly WorldAssetPlace if (complete.isEmpty()) return []; const parts: Box3[] = []; const height = complete.max.y - complete.min.y; - if (kind === 'trunk') { + if (loose) { + // A non-blocking measurement carrier gives small rubble its actual native dimensions, not a guessed proxy mesh. + parts.push(complete); + } else if (kind === 'trunk') { const wood = new Box3(); const top = complete.min.y + height * 0.3; for (const { mesh, local } of meshes) { @@ -136,7 +141,7 @@ function buildPropColliders(root: Object3D, placements: readonly WorldAssetPlace const field = supportMode === 'none' ? null : measureSupportField('native-support:' + placement.id, placement.id, supportMode, meshes.map(({ mesh, local }) => ({ geometry: mesh.geometry, matrix: new Matrix4().multiplyMatrices(matrix, local) }))); // A measured rock top blocks through its field (real sides, real overhangs); its proxy box then only occludes the camera. - const blocksMovement = !(field && supportMode === 'top'); + const blocksMovement = !loose && !(field && supportMode === 'top'); const entries: NativeSolidCollider[] = []; for (let i = 0; i < parts.length; i++) { worldBox.copy(parts[i]!).applyMatrix4(matrix); @@ -146,7 +151,7 @@ function buildPropColliders(root: Object3D, placements: readonly WorldAssetPlace collider: { id: `native:${placement.id}:${i}`, kind: 'box', from: { x: center.x, y: center.y, z: center.z }, halfExtents: { x: size.x / 2, y: size.y / 2, z: size.z / 2 }, - blocksMovement, blocksCamera: true } }); + blocksMovement, blocksCamera: !loose } }); } if (field) { // The field travels with the placement's first entry; a deck without rails gets a non-blocking proxy to carry it. diff --git a/tests/combat/environment-props.test.ts b/tests/combat/environment-props.test.ts new file mode 100644 index 0000000..9d2a1f2 --- /dev/null +++ b/tests/combat/environment-props.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'bun:test'; +import { REGIONS } from '../../src/content/campaign.js'; +import { solidPropKind, supportKind } from '../../src/content/solid-props.js'; +import { CombatEngine, createEngineState, makeActor, type CombatInput, type EngineState, type LiveHazard } from '../../src/game/combat/CombatEngine.js'; +import { ATTACKS } from '../../src/game/combat/content.js'; +import { createEnvironment, environmentDisk, impactEnvironment, stepEnvironment } from '../../src/game/combat/EnvironmentProps.js'; +import { SIGIL, THUNDER_CLEAVE } from '../../src/game/combat/PlayerSkills.js'; +import type { HeroId } from '../../src/game/heroes.js'; +import type { RegionDefinition, WorldAssetPlacement } from '../../src/game/contracts.js'; + +const idle: CombatInput = { tick: 0, move: { x: 0, y: 0, z: 0 }, dodgePressed: false, lightPressed: false, heavyPressed: false, heavyHeld: false, heavyReleased: false, interactPressed: false }; +const stone: WorldAssetPlacement = { id: 'environment-loose-test', assetId: 'prop-mire-bank-rock', position: { x: 0, y: 0, z: -1.5 }, rotationY: 0, scale: { x: .12, y: .12, z: .12 }, placement: 'ground' }; +const tree: WorldAssetPlacement = { ...stone, id: 'test-tree', assetId: 'prop-erya-edge-tree', scale: { x: 1, y: 1, z: 1 } }; +const fern: WorldAssetPlacement = { ...tree, id: 'test-fern', assetId: 'prop-canopy-fern-cluster' }; +function regionWith(props: readonly WorldAssetPlacement[]): RegionDefinition { return { ...REGIONS[0]!, props, movableObjects: [], deliveries: [] }; } +function fixture(props: readonly WorldAssetPlacement[] = [stone], hero: HeroId = 'aren', target = false) { + const seed = createEngineState(); + seed.environment = createEnvironment(regionWith(props), new Set()); + if (target) { + const enemy = makeActor('corrupted-boar', 'boar', 'hostile', { x: 0, y: 0, z: -1.8 }, false, false, 1000); + enemy.ferocity = undefined; + seed.actors.push(enemy); seed.targetId = enemy.id; + } + const engine = new CombatEngine(seed); + engine.setPlayerHero(hero); + const state = (engine as unknown as { state: EngineState }).state; + const cues: string[] = []; + function step(input: Partial = {}, count = 1) { + for (let i = 0; i < count; i++) { engine.fixedStep({ ...idle, ...input }); cues.push(...engine.drainPresentationCues().map(cue => cue.kind)); } + } + function charge() { + step({ heavyPressed: true, heavyHeld: true }); step({ heavyHeld: true }, THUNDER_CLEAVE.holdTicks); + step({ heavyReleased: true, heavyHeldTicks: THUNDER_CLEAVE.holdTicks }); + } + return { engine, state, step, charge, cues }; +} + +describe('skill-owned environmental force', () => { + it('melee moves a loose native body and damages it only once across a sustained active window', () => { + const f = fixture(); + f.step({ lightPressed: true }); f.step({}, 5); + expect(f.engine.snapshot().environmentProps).toEqual([]); + f.step({}, 90); + const prop = f.state.environment.props[0]!; + expect(prop.damage).toBeGreaterThan(0); + expect(prop.position.z).toBeLessThan(stone.position.z - .1); + expect(prop.position.y).toBe(0); + expect(prop.velocity).toEqual({ x: 0, y: 0, z: 0 }); + expect(prop.impactSerial).toBe(1); + expect(f.engine.snapshot().events).toEqual([]); + }); + + it('Thunder Cleave keeps actor damage unchanged while props receive melee and delayed lightning separately', () => { + const f = fixture([stone], 'aren', true), control = fixture([], 'aren', true); + f.charge(); control.charge(); + expect(f.state.environment.props[0]!.impactSerial).toBe(0); + for (let tick = 0; tick < 180 && !f.cues.includes('slash'); tick++) { f.step(); control.step(); } + expect(f.state.environment.props[0]!.impactSerial).toBe(1); + expect(f.state.actors[1]!.vitality).toBe(964); + for (let tick = 0; tick < 100 && !f.cues.includes('lightning'); tick++) { f.step(); control.step(); } + expect(f.state.environment.props[0]!.impactSerial).toBe(2); + // Existing poise/stun multipliers still apply; adding prop damage must not change character resolution. + expect(f.state.actors[1]!.vitality).toBe(control.state.actors[1]!.vitality); + expect(f.state.actors[1]!.vitality).toBeLessThan(964); + expect(f.state.environment.props[0]!.broken).toBe(true); + expect(f.state.player.mana).toBe(80); + }); + + it('can intentionally strike a nearby object without a hostile, but still rejects an empty-air heavy', () => { + const f = fixture(); + f.charge(); f.step({}, 160); + expect(f.state.environment.props[0]!.damage).toBeGreaterThan(0); + expect(f.state.player.mana).toBe(80); + const empty = fixture([]); + empty.charge(); empty.step({}, 160); + expect(empty.state.player.mana).toBe(100); + expect(empty.cues.includes('slash')).toBe(false); + }); + + for (const hero of ['yara', 'vesper', 'sakda'] as const) { + it(`${hero}'s real offensive path reaches native vegetation without relying on presentation cues`, () => { + const f = fixture([tree], hero); + f.step({ lightPressed: true }); f.step({}, 120); + expect(f.state.environment.props[0]!.damage).toBeGreaterThan(0); + expect(f.state.environment.props[0]!.position).toEqual(tree.position); + }); + } + + it('Sakda sustained limb/ring windows deduplicate independently while Hundred Elbows keeps its real pulses', () => { + const ring = fixture([tree], 'sakda'); + ring.state.lightIndex = 3; ring.state.lightChainFrom = 0; ring.state.lightChainUntil = 100; + ring.step({ lightPressed: true }); ring.step({}, 120); + expect(ring.state.environment.props[0]!.impactSerial).toBe(2); + const elbows = fixture([tree], 'sakda', true); + elbows.step({ rangedPressed: true, rangedHeld: true }); + elbows.step({ rangedHeld: true }, SIGIL.barrageHoldTicks); + elbows.step({ rangedReleased: true }); elbows.step({}, 240); + expect(elbows.state.environment.props[0]!.impactSerial).toBe(7); + }); + + it('a confirmed companion strike affects nearby props but a rejected downed-target strike does not', () => { + const accepted = fixture([stone], 'aren', true), rejected = fixture([stone], 'aren', true); + rejected.state.actors[1]!.downed = true; + for (const f of [accepted, rejected]) { + f.state.pendingCompanionStrikes.push({ sourceId: 'vara', targetId: 'corrupted-boar', damage: 18, poise: 5, knockback: .4, dirX: 0, dirZ: -1, attackId: 'vara-storm-talon' }); + f.step(); + } + expect(accepted.state.environment.props[0]!.damage).toBeGreaterThan(0); + expect(rejected.state.environment.props[0]!.damage).toBe(0); + }); + + it('cosmetic cues and paused/replaced anticipation never damage props', () => { + const f = fixture(); + f.engine.queuePresentationCue({ kind: 'lightning', phase: 'active', position: stone.position, intensity: 9 }); + f.step({ heavyPressed: true, heavyHeld: true }); f.step({ heavyHeld: true }, 10); + f.engine.setTimePolicy('paused'); f.step({}, 120); + expect(f.state.environment.props[0]!.impactSerial).toBe(0); + f.engine.startNewRun('replacement'); f.step({}, 120); + expect(f.engine.snapshot().environmentProps).toEqual([]); + }); + + it('freezes displaced bodies through real hit-stop and pause, then resumes them on the world clock', () => { + const f = fixture([stone], 'aren', true); + f.step({ lightPressed: true }); + for (let tick = 0; tick < 60 && !f.state.hitStop; tick++) f.step(); + expect(f.state.hitStop!.remainingTicks).toBeGreaterThan(0); + const frozen = structuredClone(f.state.environment), worldTick = f.state.worldTick; + f.step({}, f.state.hitStop!.remainingTicks); + expect(f.state.environment).toEqual(frozen); + expect(f.state.worldTick).toBe(worldTick); + f.engine.setTimePolicy('paused'); f.step({}, 30); + expect(f.state.environment).toEqual(frozen); + f.engine.setTimePolicy('normal'); f.step(); + expect(f.state.environment.props[0]!.position.z).toBeLessThan(frozen.props[0]!.position.z); + f.step({}, 40); + expect(f.state.environment.props[0]!.impactSerial).toBe(1); + }); + + it('keeps exact physical damage/momentum through serialization without replaying a restored impact', () => { + const f = fixture(); f.step({ lightPressed: true }); f.step({}, 18); + const saved = f.engine.serialize(), restored = new CombatEngine(); restored.restore(saved); + expect(restored.snapshot().environmentProps![0]!.impactWorldTick).toBe(-1); + expect(restored.snapshotState().environment).toEqual(f.state.environment); + for (let i = 0; i < 40; i++) { f.step(); restored.fixedStep(idle); } + expect(restored.snapshotState().environment).toEqual(f.state.environment); + }); + + it('retry and region replacement restore local props without inventing durable world facts', () => { + const f = fixture(); + f.engine.loadRegion({ regionId: 'greenwake', runId: 'environment', regionDefinition: regionWith([stone]), playerPosition: { x: 0, y: 0, z: 0 }, presentActorIds: [] }); + f.step({ lightPressed: true }); f.step({}, 60); + expect(f.engine.snapshot().environmentProps!.some(prop => prop.damage > 0)).toBe(true); + f.engine.resetToCheckpoint(); + expect(f.engine.snapshot().environmentProps).toEqual([]); + expect(f.engine.snapshot().events).toEqual([]); + f.engine.loadRegion({ regionId: 'greenwake', runId: 'environment', regionDefinition: regionWith([]), playerPosition: { x: 0, y: 0, z: 0 }, presentActorIds: [] }); + expect(f.engine.snapshotState().environment.props).toEqual([]); + }); + + it('only a genuinely active hostile hazard damages an object, once per hazard', () => { + const f = fixture([tree]); + const attack = ATTACKS['koro-fruit-burst']!; + const hazard: LiveHazard = { ...structuredClone(attack.hazard), id: 'environment-hazard', attackId: attack.id, sourceId: 'koro', startTick: 0, activeFrom: 5, activeUntil: 40, target: tree.position, origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: 0, z: -1 }, hitIds: [], threatTicks: [], cue: 'Fruit burst', damage: 30 }; + f.state.hazards.push(hazard); + f.step({}, 4); expect(f.state.environment.props[0]!.impactSerial).toBe(0); + f.step({}, 20); expect(f.state.environment.props[0]!.impactSerial).toBe(1); + expect(f.state.environment.props[0]!.damage).toBeGreaterThan(0); + }); + + it('a bolt hits the measured prop surface but expiry does not manufacture distant damage', () => { + const f = fixture([tree]); + f.engine.setSceneColliders([{ id: 'native:test-tree:0', kind: 'box', from: { x: 0, y: 1, z: -1.5 }, halfExtents: { x: .3, y: 1, z: .3 }, blocksMovement: true, blocksCamera: true }]); + f.state.playerProjectiles.push({ id: 'real-bolt', kind: 'sigil-bolt', ownerId: 'aren', position: { x: 0, y: 1, z: 0 }, direction: { x: 0, y: 0, z: -1 }, speed: 12, radius: .12, ageTicks: 0, lifeTicks: 60, damage: 20, hitIds: [] }); + f.step({}, 30); + expect(f.state.environment.props[0]!.impactSerial).toBe(1); + expect(f.state.playerProjectiles).toEqual([]); + const expired = fixture([{ ...tree, position: { x: 0, y: 0, z: -7 } }]); + expired.state.playerProjectiles.push({ id: 'expiring-bolt', kind: 'sigil-bolt', ownerId: 'aren', position: { x: 0, y: 1, z: 0 }, direction: { x: 0, y: 0, z: -1 }, speed: 12, radius: .12, ageTicks: 0, lifeTicks: 1, damage: 20, hitIds: [] }); + expired.step({}, 60); + expect(expired.state.environment.props[0]!.damage).toBe(0); + }); +}); + +describe('material, collision and traversal boundaries', () => { + it('does not transfer area damage through a wall or between stacked floors', () => { + const f = fixture([stone, { ...stone, id: 'environment-loose-high', position: { x: .3, y: 5, z: -.5 } }]); + f.state.arena.colliders.push({ id: 'thin-wall', kind: 'box', from: { x: 0, y: 1, z: -.731 }, halfExtents: { x: 3, y: 1, z: .005 }, blocksMovement: true, blocksCamera: true }); + impactEnvironment(f.state.environment, f.state.arena, 1, 'disk', 40, { x: 0, y: 0, z: 0 }, prop => environmentDisk(prop, { x: 0, y: 0, z: 0 }, 3)); + expect(f.state.environment.props.map(prop => prop.damage)).toEqual([0, 0]); + }); + + it('never sleeps in mid-air at an exact apex and reaches a stable physical rest', () => { + const f = fixture(), prop = f.state.environment.props[0]!; + impactEnvironment(f.state.environment, f.state.arena, 1, 'apex', 12, prop.position, () => true); + prop.position.y = .5; + prop.velocity = { x: 0, y: 9.8 / 60, z: 0 }; + stepEnvironment(f.state.environment, f.state.arena, 2, 1); + expect(prop.sleeping).toBe(false); + stepEnvironment(f.state.environment, f.state.arena, 3, 1); + expect(prop.position.y).toBeLessThan(.5); + for (let tick = 4; tick < 484; tick++) stepEnvironment(f.state.environment, f.state.arena, tick, 1); + expect(prop.position.y).toBe(0); + expect(prop.sleeping).toBe(true); + expect(prop.velocity).toEqual({ x: 0, y: 0, z: 0 }); + expect(prop.angularVelocity).toEqual({ x: 0, z: 0 }); + const resting = structuredClone(prop); + for (let tick = 484; tick < 514; tick++) stepEnvironment(f.state.environment, f.state.arena, tick, 1); + expect(prop).toEqual(resting); + }); + + it('wakes settled rubble when its measured native support is removed', () => { + const f = fixture([{ ...stone, position: { ...stone.position, y: 1 } }]), prop = f.state.environment.props[0]!; + f.engine.setSceneColliders([], [{ id: 'support', ownerId: 'native-rock', kind: 'top', minX: -2, minZ: -3.5, cell: 4, cols: 1, rows: 1, minY: 0, maxY: 1, top: [1], bottom: [0], stand: [1] }]); + impactEnvironment(f.state.environment, f.state.arena, 1, 'supported', 12, prop.position, () => true); + prop.velocity = { x: 0, y: 0, z: 0 }; + f.step({}, 10); + expect(prop.position.y).toBe(1); + expect(prop.sleeping).toBe(true); + f.engine.setSceneColliders([], []); + expect(prop.sleeping).toBe(false); + f.step({}, 120); + expect(prop.position.y).toBe(0); + expect(prop.sleeping).toBe(true); + }); + + it('distinguishes rooted wood, crushed foliage and mobile rubble, protecting quest/support structures', () => { + const bridge = { ...tree, id: 'bridge', assetId: 'prop-greenwake-rope-bridge' }; + const protectedTree = { ...tree, id: 'story-tree' }; + const routeTree = { ...tree, id: 'greenwake-corridor-tree-cluster-visual' }; + const region = regionWith([tree, fern, stone, bridge, protectedTree, routeTree]); + const environment = createEnvironment(region, new Set(['story-tree'])); + expect(environment.props.map(prop => prop.id)).toEqual([tree.id, fern.id, stone.id]); + const arena = createEngineState().arena; + impactEnvironment(environment, arena, 1, 'slam', 60, { x: 0, y: 0, z: 0 }, () => true, { x: 1, y: 0, z: 0 }); + for (let tick = 0; tick < 120; tick++) stepEnvironment(environment, arena, tick + 2, 1); + expect(environment.props[0]!.position).toEqual(tree.position); + expect(environment.props[1]!.broken).toBe(true); + expect(environment.props[2]!.position.x).toBeGreaterThan(stone.position.x + .2); + expect(solidPropKind(stone)).toBe('decoration'); expect(supportKind(stone)).toBe('none'); + expect(supportKind(bridge)).toBe('deck'); + }); + + it('slows physical response with world time and bounds thrown rubble to reachable supported space', () => { + const f = fixture(), slow = structuredClone(f.state.environment); + for (const env of [f.state.environment, slow]) impactEnvironment(env, f.state.arena, 1, 'force', 60, { x: 0, y: 0, z: 0 }, () => true); + for (let tick = 0; tick < 20; tick++) { + stepEnvironment(f.state.environment, f.state.arena, tick + 2, 1); + stepEnvironment(slow, f.state.arena, 1 + (tick + 1) * .25, .25); + } + expect(Math.abs(slow.props[0]!.position.z - stone.position.z)).toBeLessThan(Math.abs(f.state.environment.props[0]!.position.z - stone.position.z)); + for (let tick = 0; tick < 600; tick++) stepEnvironment(f.state.environment, f.state.arena, tick + 22, 1); + const prop = f.state.environment.props[0]!; + expect(Math.hypot(prop.position.x - stone.position.x, prop.position.z - stone.position.z)).toBeLessThanOrEqual(4); + expect(prop.position.y).toBe(0); + expect(f.state.environment.contacts).toEqual([]); + }); +}); diff --git a/tests/rendering/NativeSolidColliders.test.ts b/tests/rendering/NativeSolidColliders.test.ts index 92669be..fb68786 100644 --- a/tests/rendering/NativeSolidColliders.test.ts +++ b/tests/rendering/NativeSolidColliders.test.ts @@ -12,7 +12,7 @@ function fixture(assetId: string, geometries: BufferGeometry[], options: Partial const root = new Group(), material = new MeshBasicMaterial(); const world = new Matrix4().compose(new Vector3(placement.position.x, placement.position.y, placement.position.z), new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), placement.rotationY), new Vector3(placement.scale.x, placement.scale.y, placement.scale.z)).multiply(nativeLocal); for (const geometry of geometries) { const mesh = new InstancedMesh(geometry, material, 1); mesh.setMatrixAt(0, world); root.add(mesh); } - const batch: NativeStaticBatch = { assetId, root, instanceCount: 1, drawnInstanceCount: 1, lodIds: [], setPlacementVisible() {}, updateOcclusion() {}, dispose() { for (const g of geometries) g.dispose(); material.dispose(); root.traverse(node => { if (node instanceof InstancedMesh) node.dispose(); }); } }; + const batch: NativeStaticBatch = { assetId, root, instanceCount: 1, drawnInstanceCount: 1, lodIds: [], setPlacementVisible() {}, setPlacementResponse() {}, updateOcclusion() {}, dispose() { for (const g of geometries) g.dispose(); material.dispose(); root.traverse(node => { if (node instanceof InstancedMesh) node.dispose(); }); } }; const entries = buildStaticPropColliders(batch, [placement], { ...REGIONS[0]!, movableObjects: [] }); const arena = { ...createEngineState().arena, bounds: { min: { x: -30, y: -5, z: -30 }, max: { x: 30, y: 20, z: 30 } }, colliders: entries.map(entry => entry.collider), supports: entries.flatMap(entry => entry.support ? [entry.support] : []) }; const sweep = (x: number, y = 0, aerial = false) => (aerial ? moveAerialCircle : moveCircle)(arena, { x, y, z: -4 }, { x: 0, y: 0, z: 8 }, 0.42, 1.6); @@ -22,6 +22,16 @@ function fixture(assetId: string, geometries: BufferGeometry[], options: Partial const tree = () => [new BoxGeometry(0.4, 4, 0.4).translate(0, 2, 0), new BoxGeometry(5, 2, 5).translate(0, 4, 0)]; describe('native scene solids', () => { + it('measures loose rubble without adding a stationary movement blocker, camera obstacle or standable top', () => { + const f = fixture('prop-mire-bank-rock', [new BoxGeometry(3, 2, 3).translate(0, 1, 0)], { id: 'environment-loose-test', scale: { x: .12, y: .12, z: .12 } }); + try { + expect(f.sweep(0).z).toBeCloseTo(4); + expect(f.entries[0]!.collider.halfExtents!.x).toBeCloseTo(.18); + expect(f.entries.every(entry => !entry.collider.blocksMovement && !entry.collider.blocksCamera && !entry.support)).toBe(true); + expect(groundHeightAt(f.arena, 0, 0, 1)).toBe(0); + } finally { f.dispose(); } + }); + it('blocks a tree trunk during grounded and aerial motion but leaves the space below its canopy clear', () => { const f = fixture('prop-erya-edge-tree', tree()); try { expect(f.sweep(0).z).toBeLessThan(-0.6); expect(f.sweep(0, 0.8, true).z).toBeLessThan(-0.6); expect(f.sweep(1.2).z).toBeCloseTo(4); } finally { f.dispose(); } diff --git a/tests/rendering/glb-static-batch.ts b/tests/rendering/glb-static-batch.ts index 16d24b3..24ec8aa 100644 --- a/tests/rendering/glb-static-batch.ts +++ b/tests/rendering/glb-static-batch.ts @@ -86,5 +86,5 @@ export function glbStaticBatch(playtest: string, placement: WorldAssetPlacement) mesh.setMatrixAt(0, placed.multiply(model)); const root = new Group(); root.add(mesh); - return { assetId: placement.assetId, root, instanceCount: 1, drawnInstanceCount: 1, lodIds: [], setPlacementVisible() {}, updateOcclusion() {}, dispose() { geometry.dispose(); } }; + return { assetId: placement.assetId, root, instanceCount: 1, drawnInstanceCount: 1, lodIds: [], setPlacementVisible() {}, setPlacementResponse() {}, updateOcclusion() {}, dispose() { geometry.dispose(); } }; }