From e406a66ed8fe3837c1649f173eeac6a35fd0192c Mon Sep 17 00:00:00 2001 From: Aldric Date: Sun, 21 Jun 2026 17:35:23 +0200 Subject: [PATCH] feat(forest): add healing chests Add reusable proximity healing chests to the forest zone and wire them into the shared health store. Document the radius, pulse amount, and cooldown tuning for FLO-473. Co-Authored-By: Aldric Co-Authored-By: GPT-5.5 --- docs/guide/assets.md | 4 +- docs/guide/forest-zone.md | 17 ++++-- docs/guide/health-system.md | 14 +++++ src/game/health/healingChest.test.ts | 50 +++++++++++++++++ src/game/health/healingChest.ts | 68 +++++++++++++++++++++++ src/game/health/index.ts | 9 +++ src/game/streaming/index.ts | 1 + src/game/streaming/zoneManifests.test.ts | 6 +- src/game/streaming/zoneManifests.ts | 31 +++++++++-- src/scenes/GameCanvas.tsx | 2 + src/scenes/forestScene.test.ts | 70 +++++++++++++++++++----- src/scenes/forestScene.ts | 29 ++++++++++ src/scenes/zoneScenes.ts | 2 + 13 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 src/game/health/healingChest.test.ts create mode 100644 src/game/health/healingChest.ts diff --git a/docs/guide/assets.md b/docs/guide/assets.md index e2e2bad..a419511 100644 --- a/docs/guide/assets.md +++ b/docs/guide/assets.md @@ -306,8 +306,8 @@ Generated for [FLO-372](/FLO/issues/FLO-372). FLO-470 wires the remaining visible-but-unused GLBs into the forest scene through the same streaming manifest used by the tree/hut props: -- `chest.glb` → `prop.forest-chest`, one static loot/decor chest near the - spawn-side caravan camp. +- `chest.glb` → `prop.forest-chest`, four forest healing chests. Standing within + 2.25 m restores 5 HP every 0.5 s; the chests are reusable cooldown stations. - `cargo-crate.glb` → `prop.forest-cargo-crate`, one static cargo prop beside the forest wagon camp. - `caravan-wagon.glb` → `prop.forest-caravan-wagon`, one prominent static wagon diff --git a/docs/guide/forest-zone.md b/docs/guide/forest-zone.md index d71c207..26e0e62 100644 --- a/docs/guide/forest-zone.md +++ b/docs/guide/forest-zone.md @@ -38,7 +38,8 @@ the [character controller](./character-controller.md): The tree and hut were generated in [FLO-299](/FLO/issues/FLO-299) by Pygmalion using the Meshy pipeline under visual-language v1.2 (≤ 3000 tris). FLO-470 adds -the leftover chest, cargo crate, wagon, and two static elf placements. Register +the leftover cargo crate, wagon, and two static elf placements; FLO-473 promotes +`chest.glb` from one-off decor to four healing chest placements. Register them via `seedForestAssets(registry)` from `src/scenes/forestScene.ts`; streamed GLBs are normalized and passed through the shared matte/faceted `flatShade()` conform before placement. @@ -60,11 +61,15 @@ conform before placement. `seedForestAssets` registers the tree and hut URLs. Each prop calls `spawnStreamedInstance` which shows a placeholder box immediately and swaps to the GLB on load. -3. **Props** — 12 streamed trees and 3 streamed huts placed at hard-coded (x, z) - coordinates, plus cheap procedural stumps, logs, rocks, and shrubs around the - spawn clearing. The inner 3.5-unit radius stays clear so the first combat - beats have readable movement space. -4. **Controller + camera** — the same `CharacterController` and `ThirdPersonCamera` +3. **Props** — 12 streamed trees, 3 streamed huts, 4 healing chests, and the + leftover cargo/wagon/elf decor placed at hard-coded (x, z) coordinates, plus + cheap procedural stumps, logs, rocks, and shrubs around the spawn clearing. + The inner 3.5-unit radius stays clear so the first combat beats have readable + movement space. +4. **Healing chests** — `FOREST_HEALING_CHEST_SPECS` mirrors the streamed chest + placements. Standing within 2.25 m of a chest restores 5 HP every 0.5 s via + `onPlayerHealed`; chests are reusable cooldown stations, not consumed pickups. +5. **Controller + camera** — the same `CharacterController` and `ThirdPersonCamera` from E1.1, spawned at `(0, 2, 0)` above the ground. ## Avatar & ground feel diff --git a/docs/guide/health-system.md b/docs/guide/health-system.md index 8ea6f67..6a61963 100644 --- a/docs/guide/health-system.md +++ b/docs/guide/health-system.md @@ -30,6 +30,20 @@ Import from `'../store'`: import { damagePlayer, healPlayer, resetPlayerHealth, restorePlayerHealth } from '../store' ``` +## Healing chests + +Forest chests are reusable recovery stations (FLO-473), not consumable pickups. +The pure proximity/cooldown rule lives in `src/game/health/healingChest.ts`: + +- Radius: 2.25 m on the ground plane. +- Pulse: 5 HP. +- Cooldown: 0.5 s per chest while the player remains in range. + +`forestScene` owns the world positions via `FOREST_HEALING_CHEST_SPECS`, ticks the +pure rule after movement each frame, and emits `onPlayerHealed(amount)`. +`GameCanvas` adapts that callback to `healPlayer(amount)`, so healing uses the +same Redux health authority and still clamps at max HP. + ## HUD While the game is not in the menu, `App.tsx` renders a health bar in the HUD diff --git a/src/game/health/healingChest.test.ts b/src/game/health/healingChest.test.ts new file mode 100644 index 0000000..3e43f83 --- /dev/null +++ b/src/game/health/healingChest.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + HEALING_CHEST_AMOUNT, + HEALING_CHEST_COOLDOWN_SECONDS, + HEALING_CHEST_RADIUS, + createHealingChestStates, + isInsideHealingChest, + tickHealingChests, + type HealingChestSpec, +} from './healingChest' + +const chest: HealingChestSpec = { + id: 'test-chest', + position: { x: 2, y: 0, z: -4 }, +} + +describe('healing chests', () => { + it('detects a player inside the ground-plane healing radius', () => { + expect(isInsideHealingChest({ x: 2, y: 9, z: -4 }, chest)).toBe(true) + expect(isInsideHealingChest({ x: 2 + HEALING_CHEST_RADIUS + 0.01, y: 0, z: -4 }, chest)).toBe(false) + }) + + it('heals immediately on contact, then waits for the cooldown', () => { + const states = createHealingChestStates([chest]) + const first = tickHealingChests([chest], states, { x: 2, y: 0, z: -4 }, 0) + expect(first.healAmount).toBe(HEALING_CHEST_AMOUNT) + expect(first.activeChestIds).toEqual(['test-chest']) + + const cooling = tickHealingChests([chest], first.states, { x: 2, y: 0, z: -4 }, 0.1) + expect(cooling.healAmount).toBe(0) + + const ready = tickHealingChests( + [chest], + cooling.states, + { x: 2, y: 0, z: -4 }, + HEALING_CHEST_COOLDOWN_SECONDS, + ) + expect(ready.healAmount).toBe(HEALING_CHEST_AMOUNT) + }) + + it('can stack separate ready chests when their radii overlap', () => { + const specs: readonly HealingChestSpec[] = [ + chest, + { id: 'second', position: { x: 2.5, y: 0, z: -4 }, healAmount: 7 }, + ] + const tick = tickHealingChests(specs, createHealingChestStates(specs), { x: 2.2, y: 0, z: -4 }, 0) + expect(tick.healAmount).toBe(HEALING_CHEST_AMOUNT + 7) + expect(tick.activeChestIds).toEqual(['test-chest', 'second']) + }) +}) diff --git a/src/game/health/healingChest.ts b/src/game/health/healingChest.ts new file mode 100644 index 0000000..4f7b7ec --- /dev/null +++ b/src/game/health/healingChest.ts @@ -0,0 +1,68 @@ +import type { Vec3 } from '../combat' + +export const HEALING_CHEST_RADIUS = 2.25 +export const HEALING_CHEST_AMOUNT = 5 +export const HEALING_CHEST_COOLDOWN_SECONDS = 0.5 + +export interface HealingChestSpec { + readonly id: string + readonly position: Vec3 + readonly radius?: number + readonly healAmount?: number + readonly cooldownSeconds?: number +} + +export interface HealingChestState { + readonly id: string + readonly cooldownRemaining: number +} + +export interface HealingChestTick { + readonly states: readonly HealingChestState[] + readonly healAmount: number + readonly activeChestIds: readonly string[] +} + +export function createHealingChestStates( + specs: readonly HealingChestSpec[], +): readonly HealingChestState[] { + return specs.map((spec) => ({ id: spec.id, cooldownRemaining: 0 })) +} + +function distanceSq2d(a: Vec3, b: Vec3): number { + const dx = a.x - b.x + const dz = a.z - b.z + return dx * dx + dz * dz +} + +export function isInsideHealingChest(player: Vec3, chest: HealingChestSpec): boolean { + const radius = chest.radius ?? HEALING_CHEST_RADIUS + return distanceSq2d(player, chest.position) <= radius * radius +} + +export function tickHealingChests( + specs: readonly HealingChestSpec[], + states: readonly HealingChestState[], + player: Vec3, + dt: number, +): HealingChestTick { + const activeChestIds: string[] = [] + let healAmount = 0 + + const nextStates = specs.map((spec) => { + const prior = states.find((state) => state.id === spec.id) + const cooledDown = Math.max(0, (prior?.cooldownRemaining ?? 0) - dt) + if (!isInsideHealingChest(player, spec) || cooledDown > 0) { + return { id: spec.id, cooldownRemaining: cooledDown } + } + + activeChestIds.push(spec.id) + healAmount += spec.healAmount ?? HEALING_CHEST_AMOUNT + return { + id: spec.id, + cooldownRemaining: spec.cooldownSeconds ?? HEALING_CHEST_COOLDOWN_SECONDS, + } + }) + + return { states: nextStates, healAmount, activeChestIds } +} diff --git a/src/game/health/index.ts b/src/game/health/index.ts index 91fcf0d..32ce8d6 100644 --- a/src/game/health/index.ts +++ b/src/game/health/index.ts @@ -37,3 +37,12 @@ export { resolveDismemberment, shouldSever, } from './dismemberment' +export type { HealingChestSpec, HealingChestState, HealingChestTick } from './healingChest' +export { + HEALING_CHEST_AMOUNT, + HEALING_CHEST_COOLDOWN_SECONDS, + HEALING_CHEST_RADIUS, + createHealingChestStates, + isInsideHealingChest, + tickHealingChests, +} from './healingChest' diff --git a/src/game/streaming/index.ts b/src/game/streaming/index.ts index b6e8cbf..6f17e4f 100644 --- a/src/game/streaming/index.ts +++ b/src/game/streaming/index.ts @@ -17,6 +17,7 @@ export { FOREST_CARGO_CRATE_ASSET_ID, FOREST_CARAVAN_WAGON_ASSET_ID, FOREST_CHEST_ASSET_ID, + FOREST_HEALING_CHEST_PLACEMENTS, FOREST_STATIC_ELF_ASSET_ID, FOREST_TREE_ASSET_ID, WOODEN_HUT_ASSET_ID, diff --git a/src/game/streaming/zoneManifests.test.ts b/src/game/streaming/zoneManifests.test.ts index ef717a5..03b3f00 100644 --- a/src/game/streaming/zoneManifests.test.ts +++ b/src/game/streaming/zoneManifests.test.ts @@ -5,6 +5,7 @@ import { FOREST_CARGO_CRATE_ASSET_ID, FOREST_CARAVAN_WAGON_ASSET_ID, FOREST_CHEST_ASSET_ID, + FOREST_HEALING_CHEST_PLACEMENTS, FOREST_STATIC_ELF_ASSET_ID, WOODEN_HUT_ASSET_ID, ZONE_MANIFESTS, @@ -19,13 +20,14 @@ describe('zone manifests', () => { } }) - it('streams the forest props plus leftover decor (12 trees, 3 huts, 5 leftovers)', () => { + it('streams the forest props plus healing chests and leftover decor', () => { const forest = getZoneManifest('forest') const trees = forest.placements.filter((p) => p.assetId === FOREST_TREE_ASSET_ID) const huts = forest.placements.filter((p) => p.assetId === WOODEN_HUT_ASSET_ID) expect(trees).toHaveLength(12) expect(huts).toHaveLength(3) - expect(forest.placements.filter((p) => p.assetId === FOREST_CHEST_ASSET_ID)).toHaveLength(1) + expect(forest.placements.filter((p) => p.assetId === FOREST_CHEST_ASSET_ID)).toHaveLength(4) + expect(FOREST_HEALING_CHEST_PLACEMENTS).toHaveLength(4) expect(forest.placements.filter((p) => p.assetId === FOREST_CARGO_CRATE_ASSET_ID)).toHaveLength(1) expect(forest.placements.filter((p) => p.assetId === FOREST_CARAVAN_WAGON_ASSET_ID)).toHaveLength(1) expect(forest.placements.filter((p) => p.assetId === FOREST_STATIC_ELF_ASSET_ID)).toHaveLength(2) diff --git a/src/game/streaming/zoneManifests.ts b/src/game/streaming/zoneManifests.ts index 45ce4b9..d7d17d2 100644 --- a/src/game/streaming/zoneManifests.ts +++ b/src/game/streaming/zoneManifests.ts @@ -21,7 +21,7 @@ import type { ZoneAssetPlacement, ZoneManifest } from './zoneStreaming' export const FOREST_TREE_ASSET_ID = 'env.forest-tree' /** Wooden hut placed at the edge of the forest clearing (FLO-299). */ export const WOODEN_HUT_ASSET_ID = 'env.wooden-hut' -/** Static loot chest tucked into the forest as visible leftover decor (FLO-470). */ +/** Healing chest tucked into the forest as visible recovery pickup decor (FLO-473). */ export const FOREST_CHEST_ASSET_ID = 'prop.forest-chest' /** Static cargo crate used as forest caravan-camp decor (FLO-470). */ export const FOREST_CARGO_CRATE_ASSET_ID = 'prop.forest-cargo-crate' @@ -53,17 +53,36 @@ const FOREST_HUT_POSITIONS: readonly [number, number][] = [ [-7, 15], ] +export const FOREST_HEALING_CHEST_PLACEMENTS: readonly ZoneAssetPlacement[] = [ + { + assetId: FOREST_CHEST_ASSET_ID, + position: { x: -2.6, y: 0, z: -6.8 }, + rotationY: -0.35, + }, + { + assetId: FOREST_CHEST_ASSET_ID, + position: { x: 4.8, y: 0, z: 4.6 }, + rotationY: 1.2, + }, + { + assetId: FOREST_CHEST_ASSET_ID, + position: { x: -10.2, y: 0, z: 9.7 }, + rotationY: 0.25, + }, + { + assetId: FOREST_CHEST_ASSET_ID, + position: { x: 13.2, y: 0, z: -4.4 }, + rotationY: -1.1, + }, +] + const FOREST_LEFTOVER_PLACEMENTS: readonly ZoneAssetPlacement[] = [ { assetId: FOREST_CARAVAN_WAGON_ASSET_ID, position: { x: -5.5, y: 0, z: -9 }, rotationY: Math.PI / 2, }, - { - assetId: FOREST_CHEST_ASSET_ID, - position: { x: -2.6, y: 0, z: -6.8 }, - rotationY: -0.35, - }, + ...FOREST_HEALING_CHEST_PLACEMENTS, { assetId: FOREST_CARGO_CRATE_ASSET_ID, position: { x: -7.4, y: 0, z: -6.1 }, diff --git a/src/scenes/GameCanvas.tsx b/src/scenes/GameCanvas.tsx index 0c59a07..b8ca214 100644 --- a/src/scenes/GameCanvas.tsx +++ b/src/scenes/GameCanvas.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { createGameEngine } from '../engine' import { damagePlayer, + healPlayer, pickUpLoot, raidCaravan, recordCombatKill, @@ -110,6 +111,7 @@ export function GameCanvas() { emitDismember(limb) } }, + onPlayerHealed: (amount) => dispatch(healPlayer(amount)), // Close the loot loop (E3.5): adapt the caravan's aggregated drop // into one pickUpLoot per stack so the HUD inventory updates, then // advance the raid objective + score the haul (MPG.1). diff --git a/src/scenes/forestScene.test.ts b/src/scenes/forestScene.test.ts index dacc3d3..655a087 100644 --- a/src/scenes/forestScene.test.ts +++ b/src/scenes/forestScene.test.ts @@ -7,6 +7,7 @@ import { FOREST_CARGO_CRATE_ASSET_ID, FOREST_CARAVAN_WAGON_ASSET_ID, FOREST_CHEST_ASSET_ID, + FOREST_HEALING_CHEST_SPECS, FOREST_STATIC_ELF_ASSET_ID, FOREST_TREE_ASSET_ID, FOREST_SPAWN_PROP_SPECS, @@ -32,6 +33,7 @@ import { CorpseStore } from '../game/corpses' import { onAttack, onShake } from '../game/combat/damageEvents' import { SoldierEnemy } from './soldierEnemy' import { DEFAULT_SOLDIER_PARAMS } from '../game/ai' +import { HEALING_CHEST_AMOUNT, HEALING_CHEST_COOLDOWN_SECONDS } from '../game/health' // jsdom has no WebGL, so inject a headless NullEngine and skip hero/asset GLB // fetches. Asserts the scene wires the ground, controller, and streaming loader. @@ -318,6 +320,40 @@ describe('createForestScene — caravan loot loop (E3.5)', () => { game.dispose() }) + it('declares multiple healing chests in the forest start zone', () => { + expect(FOREST_HEALING_CHEST_SPECS).toHaveLength(4) + for (const chest of FOREST_HEALING_CHEST_SPECS) { + expect(chest.position.y).toBe(0) + } + }) + + it('emits reusable healing pulses when the player stands on a chest', () => { + takeSpawn() + const onPlayerHealed = vi.fn() + const [chest] = FOREST_HEALING_CHEST_SPECS + const game = createForestScene(document.createElement('canvas'), { + heroUrl: null, + createEngine: () => new NullEngine(), + onPlayerHealed, + initialSpawn: { + position: { x: chest.position.x, y: 2, z: chest.position.z }, + rotationY: 0, + }, + }) + + game.step(0) + expect(onPlayerHealed).toHaveBeenCalledWith(HEALING_CHEST_AMOUNT) + onPlayerHealed.mockClear() + + game.step(0.1) + expect(onPlayerHealed).not.toHaveBeenCalled() + + game.step(HEALING_CHEST_COOLDOWN_SECONDS) + expect(onPlayerHealed).toHaveBeenCalledWith(HEALING_CHEST_AMOUNT) + + game.dispose() + }) + it('emits the rolled loot exactly once when the caravan is defeated', () => { const onCaravanLooted = vi.fn() const game = createForestScene(document.createElement('canvas'), { @@ -403,21 +439,25 @@ describe('forest safe spawn & difficulty curve (FLO-412)', () => { // 13 m closest approach, outside the 10 m detection radius — and the wander is // deterministic, so the full ≥ 30 s window is provably and reproducibly safe. // Restored to the real acceptance bound the band-aid had shrunk. - it('keeps a New Game player unharmed for 30+ s of idle play (survivable spawn)', () => { - takeSpawn() // New Game → clearing centre (0,2,0) - const onPlayerDamaged = vi.fn() - const game = createForestScene(document.createElement('canvas'), { - heroUrl: null, - createEngine: () => new NullEngine(), - onPlayerDamaged, - }) - - // 1900 frames ≈ 31.7 s at the fixed 1/60 step. - for (let i = 0; i < 1900; i++) game.step(1 / 60) - expect(onPlayerDamaged).not.toHaveBeenCalled() - - game.dispose() - }) + it( + 'keeps a New Game player unharmed for 30+ s of idle play (survivable spawn)', + () => { + takeSpawn() // New Game → clearing centre (0,2,0) + const onPlayerDamaged = vi.fn() + const game = createForestScene(document.createElement('canvas'), { + heroUrl: null, + createEngine: () => new NullEngine(), + onPlayerDamaged, + }) + + // 1900 frames ≈ 31.7 s at the fixed 1/60 step. + for (let i = 0; i < 1900; i++) game.step(1 / 60) + expect(onPlayerDamaged).not.toHaveBeenCalled() + + game.dispose() + }, + 15000, + ) // Acceptance (FLO-412): the player can walk to a caravan without dying. The // nearest caravan (`caravan-1`, (-8,-6), 10 m) sits inside the soldier-free diff --git a/src/scenes/forestScene.ts b/src/scenes/forestScene.ts index 168fb42..ba7edab 100644 --- a/src/scenes/forestScene.ts +++ b/src/scenes/forestScene.ts @@ -29,6 +29,7 @@ import { WOODEN_HUT_ASSET_ID, ZoneStreamingManager, defaultLoadGlb, + FOREST_HEALING_CHEST_PLACEMENTS, getZoneManifest, } from '../game/streaming' import { @@ -54,6 +55,11 @@ import { CorpseManager } from './corpseManager' import { getZoneContent, type EncounterKind } from '../game/world' import { ZONE_MAPS } from '../game/world/mapProps' import { renderMapProps } from './mapPropsRenderer' +import { + createHealingChestStates, + tickHealingChests, + type HealingChestSpec, +} from '../game/health' /** Zone id used for the forest scene's corpse persistence. */ export const FOREST_ZONE_ID = 'forest' @@ -259,6 +265,8 @@ export interface ForestSceneOptions { heroUrl?: string | null /** Called when an enemy hits the player. Caller dispatches damagePlayer. */ onPlayerDamaged?: (amount: number) => void + /** Called when the player stands on/near a healing chest. Caller dispatches healPlayer. */ + onPlayerHealed?: (amount: number) => void /** * Spawn pose for the player capsule. Defaults to a staged Continue spawn * (`takeSpawn()`) so a loaded save lands the player where it left off, falling @@ -385,6 +393,13 @@ export function spawnGraceDamageScale(elapsedSeconds: number): number { return elapsedSeconds < SPAWN_GRACE_SECONDS ? 0 : 1 } +export const FOREST_HEALING_CHEST_SPECS: readonly HealingChestSpec[] = + FOREST_HEALING_CHEST_PLACEMENTS.flatMap((placement, index) => + placement.position + ? [{ id: `forest-healing-chest-${index + 1}`, position: placement.position }] + : [], + ) + function defaultEngineFactory(canvas: HTMLCanvasElement): Engine { return new Engine(canvas, true, { preserveDrawingBuffer: true, stencil: true }, true) } @@ -406,6 +421,7 @@ export function createForestScene( createEngine = defaultEngineFactory, heroUrl = DEFAULT_HERO_URL, onPlayerDamaged, + onPlayerHealed, initialSpawn = takeSpawn(), corpseStore, corpseGlbUrl, @@ -600,6 +616,7 @@ export function createForestScene( // Melee attack state for the player (edge-triggered on intent.attack). let meleeState = createMeleeAttack() let prevAttack = false + let healingChestStates = createHealingChestStates(FOREST_HEALING_CHEST_SPECS) // Minimap radar (FLO-449): publish a top-down snapshot to the HUD bridge, // throttled to ~10 Hz so the bottom-screen radar tracks the player + caravans @@ -698,6 +715,18 @@ export function createForestScene( loop.advance(dt) clampToWorld(controller.mesh.position) // contain the player within the world bounds + if (onPlayerHealed) { + const playerPos = controller.mesh.position + const healing = tickHealingChests( + FOREST_HEALING_CHEST_SPECS, + healingChestStates, + { x: playerPos.x, y: playerPos.y, z: playerPos.z }, + dt, + ) + healingChestStates = healing.states + if (healing.healAmount > 0) onPlayerHealed(healing.healAmount) + } + // Minimap radar tick — throttled to ~10 Hz off the accumulated sim time so // the HUD radar updates without a per-frame snapshot (FLO-449). if (onMinimapTick) { diff --git a/src/scenes/zoneScenes.ts b/src/scenes/zoneScenes.ts index beaa00c..8843b54 100644 --- a/src/scenes/zoneScenes.ts +++ b/src/scenes/zoneScenes.ts @@ -15,6 +15,8 @@ import { createEmpireScene } from './empireScene' */ export interface ZoneSceneOptions { onPlayerDamaged?: (amount: number) => void + /** Fired when a zone-local recovery source heals the player. */ + onPlayerHealed?: (amount: number) => void /** Fired when the player defeats a caravan (E3.5); ignored by zones with no caravan. */ onCaravanLooted?: (drop: LootDrop) => void /** Fired once per defeated combat target so progression can award XP. */