From cde8d21520ef46e7d97369da42a48fbf661a8066 Mon Sep 17 00:00:00 2001 From: Soren Date: Sun, 21 Jun 2026 17:27:21 +0200 Subject: [PATCH] feat(human-lands): wire toll-gate.glb landmark (FLO-478) Replace the toll-gate greybox with the shrunk GLB at the zoneContent position, applying flatShade conform like other Salt Road props. Co-Authored-By: Soren Co-Authored-By: Cursor Composer 2.5 Co-authored-by: Cursor --- docs/guide/assets.md | 4 +-- src/scenes/humanLandsScene.test.ts | 8 +++++ src/scenes/humanLandsScene.ts | 20 ++++++++--- src/scenes/tollGateProp.test.ts | 37 ++++++++++++++++++++ src/scenes/tollGateProp.ts | 56 ++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 src/scenes/tollGateProp.test.ts create mode 100644 src/scenes/tollGateProp.ts diff --git a/docs/guide/assets.md b/docs/guide/assets.md index 7587b80..64d5dcf 100644 --- a/docs/guide/assets.md +++ b/docs/guide/assets.md @@ -62,7 +62,7 @@ any Community/showcase page. | Chest *(wired: forest static loot decor, FLO-470)* | `public/models/chest.glb` | — | — | static | — | | **Empire soldier (enemy)** | `public/models/empire-soldier.glb` | **2794** | 130 KiB | static (no skeleton) | `019ee601-93f0-7988-86f8-e35ce1067881` | | **Roadside shrine (Salt Road)** | `public/models/roadside-shrine.glb` | **1984** | 394 KiB | static | `019ee73a-c8a6-73bd-a2ec-e21137a6dba2` (preview) / `019ee747-ae35-786a-a8c7-490e65d0cddd` (retexture) | -| **Empire toll gate** | `public/models/toll-gate.glb` | **1947** | 378 KiB | static | preview `019ee748-205e-763a-a883-bdda11e91c7e` · retexture `019ee749-ae59-7680-aa94-e6a8842c7bd5` | +| **Empire toll gate** *(wired: human-lands landmark, FLO-478)* | `public/models/toll-gate.glb` | **1947** | 378 KiB | static | preview `019ee748-205e-763a-a883-bdda11e91c7e` · retexture `019ee749-ae59-7680-aa94-e6a8842c7bd5` | | **Caravan wagon (Salt Road)** *(wired: caravan enemy visual + forest static prop, FLO-470)* | `public/models/caravan-wagon.glb` | **2827** | 394 KiB | static | `019ee749-d4cf-79b9-b813-d98be2201197` (preview) / `019ee74d-2300-772c-8a22-612c27dd99dc` (retexture) | | **Cargo crate (Salt Road)** *(wired: forest cargo decor, FLO-470)* | `public/models/cargo-crate.glb` | **1022** | 296 KiB | static | `019ee743-a02c-7878-a22e-7b67cdfbafa6` (preview) | | **Ruined watchtower (Salt Road)** | `public/models/watchtower.glb` | **2564** | 379 KiB | static | `019ee749-6051-7990-b286-98be4ecf82b4` (retexture) | @@ -78,7 +78,7 @@ Salt Road landmark and navigation anchor (world-specs.md §1). Generated for [FL - **Texture budget (FLO-477):** Meshy retexture embedded a single 2048² PBR JPEG (~4.0 MiB), pushing the GLB to 4.1 MiB — ~10× over the Salt Road web budget on a 1947-tri mesh. Downscaled to **1024²** (q85) via `tools/meshy-3d/resize_glb_textures.py`, recompacting the BIN chunk; mesh/accessor data is byte-identical, so the faceted/matte silhouette is untouched (texture 4015 KiB → 263 KiB; GLB 4130 KiB → 378 KiB). No Meshy credits spent (purely local repack). - **Rig:** static mesh, no skeleton. - **Verification:** loads headless via `node tools/meshy-3d/smoke_load_glb.mjs public/models/toll-gate.glb` → 2 meshes, 1947 tris, no errors. Resized texture atlas inspected: empire palette (grey planks, red/gold banner) and matte stylized read preserved. -- **Handoff:** scene wiring (replacing the placeholder box in `humanLandsScene.ts`) is engineering scope — see [FLO-373](/FLO/issues/FLO-373). +- **Wired (FLO-478):** `spawnTollGateProp` in `src/scenes/tollGateProp.ts` mounts the GLB at the `toll-gate` landmark position from `zoneContent`, applies the shared `flatShade()` matte/faceted conform, and removes the procedural greybox placeholder from `humanLandsScene.ts`. ### Ruined watchtower (Salt Road landmark) — Phase 3.5 / MPG diff --git a/src/scenes/humanLandsScene.test.ts b/src/scenes/humanLandsScene.test.ts index 8d14a22..a403083 100644 --- a/src/scenes/humanLandsScene.test.ts +++ b/src/scenes/humanLandsScene.test.ts @@ -9,6 +9,7 @@ function boot(opts: Parameters[1] = {}) { return createHumanLandsScene(canvas, { heroUrl: null, palaceGuardGlbUrl: null, + tollGateGlbUrl: null, createEngine: () => new NullEngine(), ...opts, }) @@ -63,6 +64,13 @@ describe('createHumanLandsScene', () => { game.dispose() }) + it('mounts the toll-gate landmark root and drops the greybox placeholder (FLO-478)', () => { + const game = boot() + expect(game.tollGate.root.name).toBe('landmark:toll-gate') + expect(game.scene.getMeshByName('landmark-2')).toBeNull() + game.dispose() + }) + it('freezes the sim while paused but keeps the scene alive', () => { let paused = true const game = boot({ isPaused: () => paused }) diff --git a/src/scenes/humanLandsScene.ts b/src/scenes/humanLandsScene.ts index d1ac351..3cd63b1 100644 --- a/src/scenes/humanLandsScene.ts +++ b/src/scenes/humanLandsScene.ts @@ -38,6 +38,7 @@ import { getZoneContent, type EncounterKind } from '../game/world' import { ZONE_MAPS } from '../game/world/mapProps' import { renderMapProps } from './mapPropsRenderer' import { type PalaceGuardProp, spawnPalaceGuardProps } from './palaceGuardProp' +import { type TollGateProp, spawnTollGateProp } from './tollGateProp' /** Zone id used for the human-lands scene's save/corpse persistence. */ export const HUMAN_LANDS_ZONE_ID = 'human-lands' @@ -83,6 +84,11 @@ export interface HumanLandsSceneOptions { * (headless tests) but still spawns placement roots. */ palaceGuardGlbUrl?: string | null + /** + * Toll-gate landmark GLB (FLO-478); `null` skips the fetch (headless tests) but + * still spawns the placement root at the zone-content position. + */ + tollGateGlbUrl?: string | null } export interface HumanLandsScene { @@ -95,6 +101,8 @@ export interface HumanLandsScene { readonly soldiers: readonly SoldierEnemy[] /** Static ceremonial palace-guard decor at the toll gate (FLO-471). */ readonly palaceGuards: readonly PalaceGuardProp[] + /** Empire toll gate landmark GLB (FLO-478). */ + readonly tollGate: TollGateProp /** Advance one frame by `dt` seconds (tests drive this directly). */ step(dt: number): void dispose(): void @@ -144,6 +152,7 @@ export function createHumanLandsScene( onMinimapTick, onStaminaChange, palaceGuardGlbUrl, + tollGateGlbUrl, } = options const engine = createEngine(canvas) @@ -163,11 +172,9 @@ export function createHumanLandsScene( // call per symbol; non-pickable so the player passes through. Swap to GLBs later. const mapProps = renderMapProps(scene, ZONE_MAPS[HUMAN_LANDS_ZONE_ID]) - // Greybox marker boxes standing in for the Salt Road's landmarks (toll gate, - // shrine, watchtower) so the zone reads as visually distinct from the forest. - // Geometry/colour come from the zone-content table; a later asset ticket swaps - // these for streamed GLBs via each landmark's `assetKey`. - const landmarkMeshes = ZONE.landmarks.map((lm, i) => { + // Greybox marker boxes for landmarks not yet swapped to GLBs (shrine, watchtower). + // The toll gate is mounted via `spawnTollGateProp` (FLO-478). + const landmarkMeshes = ZONE.landmarks.filter((lm) => lm.id !== 'toll-gate').map((lm, i) => { const box = MeshBuilder.CreateBox(`landmark-${i}`, { size: lm.size, height: lm.height }, scene) box.position = new Vector3(lm.position.x, lm.height / 2, lm.position.z) const mat = new StandardMaterial(`landmarkMat-${i}`, scene) @@ -221,6 +228,7 @@ export function createHumanLandsScene( const screenShake = new ScreenShakeManager() const palaceGuards = spawnPalaceGuardProps(scene, undefined, palaceGuardGlbUrl) + const tollGate = spawnTollGateProp(scene, tollGateGlbUrl) const soldiers = HUMAN_LANDS_SOLDIER_SPAWNS.map( (spawn) => @@ -361,6 +369,7 @@ export function createHumanLandsScene( caravans, soldiers, palaceGuards, + tollGate, step: frame, dispose() { if (disposed) return @@ -372,6 +381,7 @@ export function createHumanLandsScene( mapProps.dispose(false, true) // disposes the thin-instanced map + its materials for (const mesh of landmarkMeshes) mesh.dispose() for (const g of palaceGuards) g.dispose() + tollGate.dispose() for (const s of soldiers) s.dispose() for (const c of caravans) c.dispose() scene.dispose() diff --git a/src/scenes/tollGateProp.test.ts b/src/scenes/tollGateProp.test.ts new file mode 100644 index 0000000..404e276 --- /dev/null +++ b/src/scenes/tollGateProp.test.ts @@ -0,0 +1,37 @@ +import { NullEngine, Scene, TransformNode } from '@babylonjs/core' +import { describe, expect, it } from 'vitest' +import { getZoneContent } from '../game/world' +import { + DEFAULT_TOLL_GATE_GLB, + TOLL_GATE_TARGET_SIZE, + spawnTollGateProp, +} from './tollGateProp' + +function makeScene() { + const engine = new NullEngine() + return new Scene(engine) +} + +describe('spawnTollGateProp', () => { + it('exports the shipped toll-gate GLB path and landmark scale', () => { + expect(DEFAULT_TOLL_GATE_GLB).toBe('/models/toll-gate.glb') + expect(TOLL_GATE_TARGET_SIZE).toBe(2) + }) + + it('places the root at the zone-content toll-gate landmark without fetching the GLB', () => { + const landmark = getZoneContent('human-lands').landmarks.find((lm) => lm.id === 'toll-gate') + expect(landmark).toBeDefined() + + const scene = makeScene() + const prop = spawnTollGateProp(scene, null) + + expect(prop.root).toBeInstanceOf(TransformNode) + expect(prop.root.name).toBe('landmark:toll-gate') + expect(prop.root.position.x).toBeCloseTo(landmark!.position.x) + expect(prop.root.position.z).toBeCloseTo(landmark!.position.z) + expect(prop.root.position.y).toBe(0) + + prop.dispose() + scene.dispose() + }) +}) diff --git a/src/scenes/tollGateProp.ts b/src/scenes/tollGateProp.ts new file mode 100644 index 0000000..a7f7ca0 --- /dev/null +++ b/src/scenes/tollGateProp.ts @@ -0,0 +1,56 @@ +import { TransformNode, Vector3, type Scene } from '@babylonjs/core' +import { flatShade } from '../game/util' +import { getZoneContent } from '../game/world' + +/** FLO-373 Empire toll gate mesh — web-ready GLB shipped in /public/models. */ +export const DEFAULT_TOLL_GATE_GLB = '/models/toll-gate.glb' + +/** Normalized longest extent for the road-spanning prop (assets.md ≈ 2.0 units). */ +export const TOLL_GATE_TARGET_SIZE = 2 + +export interface TollGateProp { + readonly root: TransformNode + dispose(): void +} + +/** + * Spawn the Empire toll gate landmark on the Salt Road at the `toll-gate` entry + * in `zoneContent` (FLO-478). Best-effort GLB fetch; the placement root remains + * even when the model cannot load (headless tests pass `glbUrl: null`). + */ +export function spawnTollGateProp( + scene: Scene, + glbUrl: string | null = DEFAULT_TOLL_GATE_GLB, +): TollGateProp { + const landmark = getZoneContent('human-lands').landmarks.find((lm) => lm.id === 'toll-gate') + if (!landmark) { + throw new Error('human-lands zone content is missing the toll-gate landmark') + } + + const root = new TransformNode('landmark:toll-gate', scene) + root.position = new Vector3(landmark.position.x, 0, landmark.position.z) + + if (glbUrl) { + void import('./modelLoader') + .then(({ loadModel }) => + loadModel(scene, glbUrl, { + targetSize: TOLL_GATE_TARGET_SIZE, + groundIt: true, + }).then((model) => { + flatShade(model.meshes) + model.root.parent = root + for (const mesh of model.meshes) mesh.isPickable = false + }), + ) + .catch(() => { + /* keep the empty placement root */ + }) + } + + return { + root, + dispose() { + root.dispose(false, true) + }, + } +}