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 @@ -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) |
Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/scenes/humanLandsScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function boot(opts: Parameters<typeof createHumanLandsScene>[1] = {}) {
return createHumanLandsScene(canvas, {
heroUrl: null,
palaceGuardGlbUrl: null,
tollGateGlbUrl: null,
createEngine: () => new NullEngine(),
...opts,
})
Expand Down Expand Up @@ -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 })
Expand Down
20 changes: 15 additions & 5 deletions src/scenes/humanLandsScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -144,6 +152,7 @@ export function createHumanLandsScene(
onMinimapTick,
onStaminaChange,
palaceGuardGlbUrl,
tollGateGlbUrl,
} = options

const engine = createEngine(canvas)
Expand All @@ -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)
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -361,6 +369,7 @@ export function createHumanLandsScene(
caravans,
soldiers,
palaceGuards,
tollGate,
step: frame,
dispose() {
if (disposed) return
Expand All @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions src/scenes/tollGateProp.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
56 changes: 56 additions & 0 deletions src/scenes/tollGateProp.ts
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
Loading