Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/guide/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions docs/guide/forest-zone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/guide/health-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/game/health/healingChest.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
68 changes: 68 additions & 0 deletions src/game/health/healingChest.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
9 changes: 9 additions & 0 deletions src/game/health/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions src/game/streaming/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/game/streaming/zoneManifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
31 changes: 25 additions & 6 deletions src/game/streaming/zoneManifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions src/scenes/GameCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { createGameEngine } from '../engine'
import {
damagePlayer,
healPlayer,
pickUpLoot,
raidCaravan,
recordCombatKill,
Expand Down Expand Up @@ -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).
Expand Down
70 changes: 55 additions & 15 deletions src/scenes/forestScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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'), {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading