Skip to content
Merged
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
5 changes: 4 additions & 1 deletion agent-test/docs/modules/threed_basic/design_rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ text-to-3D service. 2D Phaser templates are unrelated and must remain unchanged.
|---|---|
| Renderer | `WebGLRenderer`, `PerspectiveCamera`, resize handling, visible non-black frame |
| World | primitives or custom low-poly geometry, ambient + directional light, fog |
| Collision | player circle stays on an authored floor patch and outside static obstacle circles |
| Input | WASD and arrow keys; mouse drag/look; ESC pause |
| HUD | DOM in `#ui-root`; canvas stays dedicated to three.js |
| Pause | resolve `gameSceneKey ?? currentLevelKey ?? LevelManager.getFirstLevelScene()` |
Expand All @@ -38,7 +39,9 @@ use `colormap` for glossy, translucent, emissive, or sky assets.

Use 8-14 floor patches, 5-10 collectibles, and 8-16 low-poly decorations.
Keep the camera far plane under 250 and cap device pixel ratio at 2. Manual
distance checks are enough for pickups; do not introduce a physics dependency.
distance checks are enough for pickups and static collision; do not introduce a
physics dependency. Give every obstacle an explicit `collisionRadius` instead
of deriving gameplay collision from rendered scale.

## 5. GDD completion notes

Expand Down
8 changes: 7 additions & 1 deletion agent-test/docs/modules/threed_basic/template_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
|---|---|
| `src/main.ts` | boots title, runtime, DOM HUD, pause, completion, render loop |
| `src/GameScene.ts` | owns renderer, scene, camera, world, manual pickup checks |
| `src/CollisionResolver.ts` | pure XZ road/obstacle movement resolution with substeps |
| `src/InputController.ts` | keyboard + mouse state only |
| `src/SceneMap.ts` | Editor-facing declarative positions via `initSceneMap()` |
| `src/ThreeSceneDefaults.ts` | shared light, fog, and background defaults |
Expand All @@ -32,11 +33,16 @@ Required public methods:

## SceneMap

`initSceneMap()` returns `floorPatches`, `collectibles`, and `obstacles`.
`initSceneMap()` returns `playerSpawn`, `floorPatches`, `collectibles`, and `obstacles`.
Change positions there instead of hard-coding level coordinates inside the
render loop. Add new declarative arrays at the `// EXT` point only when a real
consumer is implemented.

Each obstacle declares `collisionRadius` independently from visual `scale`.
`resolveMovement()` keeps the full player circle inside at least one floor
patch, subdivides long moves to prevent tunnelling, and slides along an
unblocked axis. Dynamic bodies, impulses, and gravity remain v2 concerns.

## Texture keys

`Preloader` reads Phaser-compatible `asset-pack.json` sections and loads image
Expand Down
9 changes: 7 additions & 2 deletions agent-test/docs/modules/threed_basic/threed_basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and the one level rather than replacing the shell.
| Order | Action | Done when |
|---|---|---|
| 1 | map GDD asset keys to `skybox_texture`, `floor_patch`, `energy_billboard` | every used key exists in `asset-pack.json` |
| 2 | edit `SceneMap.ts` | main route and every pickup are reachable |
| 2 | edit `SceneMap.ts` | main route and every pickup are reachable; obstacle collision radii leave a traversable lane |
| 3 | merge tuning into `gameConfig.json` | wrapper shape and core fields remain |
| 4 | theme materials and DOM text | canvas remains WebGL-only; HUD remains DOM-only |
| 5 | run build and smoke | zero errors, non-black canvas, WebGL context, ESC resume |
Expand All @@ -19,14 +19,18 @@ and the one level rather than replacing the shell.
```text
Preloader.load -> TitleScreen.show -> GameScene constructor
-> applyThreeSceneDefaults -> initSceneMap -> HUD.show
-> requestAnimationFrame -> GameScene.update -> renderer.render
-> requestAnimationFrame -> GameScene.update -> resolveMovement -> renderer.render
-> all collectibles removed -> onComplete -> GameCompleteUIScene
```

`deltaSeconds` is capped by `main.ts`; all movement must multiply by it.
`setPaused(true)` must clear input so a key held before pause cannot continue
moving after resume.

Keep `CollisionResolver.ts` pure. Floor patches and obstacle circles come from
`SceneMap.ts`; player radius comes from `gameConfig.json`. Do not replace this
with a physics dependency in v1.

## Asset hookup

Only call `generate_game_assets`. A skybox is a generated 2D equirectangular
Expand All @@ -53,4 +57,5 @@ playable; it does not authorize skipping the required asset call.
| ESC overlay opens but game stays paused | wrong scene key | use the three-key fallback contract exactly |
| image 404s | invented key or leading slash mismatch | read the generated `asset-pack.json`; use its key/url |
| movement depends on frame rate | raw per-frame displacement | multiply by capped `deltaSeconds` |
| player leaves the road or crosses a pylon | movement bypasses `resolveMovement` or collision radii are missing | route every XZ move through the resolver and keep SceneMap radii explicit |
| huge GPU cost | uncapped DPR or oversized textures | DPR <= 2; texture/display size <= 1024 squared |
64 changes: 64 additions & 0 deletions agent-test/templates/modules/threed_basic/src/CollisionResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export type XzPoint = { x: number; z: number };

export type FloorPatch = XzPoint & { radius: number };

export type StaticObstacle = XzPoint & { collisionRadius: number };

export type CollisionMap = {
floorPatches: FloorPatch[];
obstacles: StaticObstacle[];
};

const EPSILON = 1e-6;

function isPlayable(
point: XzPoint,
playerRadius: number,
map: CollisionMap,
): boolean {
// ponytail: linear scans fit the 8-16 authored object budget; add spatial
// indexing only when measured level budgets grow beyond that ceiling.
const onFloor = map.floorPatches.some(
(patch) =>
Math.hypot(point.x - patch.x, point.z - patch.z) + playerRadius <=
patch.radius + EPSILON,
);
if (!onFloor) return false;

return map.obstacles.every(
(obstacle) =>
Math.hypot(point.x - obstacle.x, point.z - obstacle.z) + EPSILON >=
playerRadius + obstacle.collisionRadius,
);
}

export function resolveMovement(
position: XzPoint,
movement: XzPoint,
playerRadius: number,
map: CollisionMap,
): XzPoint {
const distance = Math.hypot(movement.x, movement.z);
if (distance === 0) return { ...position };

const stepCount = Math.max(
1,
Math.ceil(distance / Math.max(0.05, playerRadius * 0.5)),
);
const step = { x: movement.x / stepCount, z: movement.z / stepCount };
let current = { ...position };

for (let index = 0; index < stepCount; index++) {
const candidates = [
{ x: current.x + step.x, z: current.z + step.z },
{ x: current.x + step.x, z: current.z },
{ x: current.x, z: current.z + step.z },
];
current =
candidates.find((candidate) =>
isPlayable(candidate, playerRadius, map),
) ?? current;
}

return current;
}
36 changes: 20 additions & 16 deletions agent-test/templates/modules/threed_basic/src/GameScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
WebGLRenderer,
} from 'three';
import gameConfig from './gameConfig.json';
import { resolveMovement } from './CollisionResolver';
import { InputController } from './InputController';
import { initSceneMap } from './SceneMap';
import { applyThreeSceneDefaults } from './ThreeSceneDefaults';
Expand All @@ -32,6 +33,7 @@ export class GameScene {
private readonly scene = new Scene();
private readonly camera = new PerspectiveCamera(65, 1, 0.1, 200);
private readonly input: InputController;
private readonly map = initSceneMap();
private readonly collectibles: Object3D[] = [];
private paused = false;
private completed = false;
Expand Down Expand Up @@ -65,15 +67,14 @@ export class GameScene {
this.scene.background = skybox;
}

const map = initSceneMap();
const floorTexture = this.textures.get('floor_patch');
if (floorTexture) floorTexture.colorSpace = SRGBColorSpace;
const floorMaterial = new MeshStandardMaterial({
color: floorTexture ? 0xffffff : 0x123c66,
map: floorTexture,
roughness: 0.92,
});
for (const patch of map.floorPatches) {
for (const patch of this.map.floorPatches) {
const floor = new Mesh(
new CircleGeometry(patch.radius, 20),
floorMaterial,
Expand All @@ -88,15 +89,15 @@ export class GameScene {
emissive: new Color(0x21084f),
flatShading: true,
});
for (const obstacle of map.obstacles) {
for (const obstacle of this.map.obstacles) {
const mesh = new Mesh(new IcosahedronGeometry(obstacle.scale, 0), obstacleMaterial);
mesh.position.set(obstacle.x, obstacle.y, obstacle.z);
this.scene.add(mesh);
}

const energyTexture = this.textures.get('energy_billboard');
if (energyTexture) energyTexture.colorSpace = SRGBColorSpace;
for (const item of map.collectibles) {
for (const item of this.map.collectibles) {
const collectible = energyTexture
? new Sprite(new SpriteMaterial({ map: energyTexture, transparent: true }))
: new Mesh(
Expand All @@ -109,7 +110,11 @@ export class GameScene {
this.collectibles.push(collectible);
this.scene.add(collectible);
}
this.camera.position.set(0, 2.2, 7);
this.camera.position.set(
this.map.playerSpawn.x,
2.2,
this.map.playerSpawn.z,
);
}

update(deltaSeconds: number): void {
Expand All @@ -118,18 +123,17 @@ export class GameScene {
this.input.consumeLookDelta() * gameConfig.playerConfig.mouseSensitivity.value;
const { forward, strafe } = this.input.movement();
const speed = gameConfig.playerConfig.moveSpeed.value * deltaSeconds;
this.camera.position.x +=
(Math.cos(this.yaw) * strafe + Math.sin(this.yaw) * forward) * speed;
this.camera.position.z +=
(Math.sin(this.yaw) * strafe - Math.cos(this.yaw) * forward) * speed;
this.camera.position.x = Math.max(
-gameConfig.levelConfig.trackHalfWidth.value,
Math.min(gameConfig.levelConfig.trackHalfWidth.value, this.camera.position.x),
);
this.camera.position.z = Math.max(
gameConfig.levelConfig.finishZ.value,
Math.min(8, this.camera.position.z),
const nextPosition = resolveMovement(
{ x: this.camera.position.x, z: this.camera.position.z },
{
x: (Math.cos(this.yaw) * strafe + Math.sin(this.yaw) * forward) * speed,
z: (Math.sin(this.yaw) * strafe - Math.cos(this.yaw) * forward) * speed,
},
gameConfig.playerConfig.collisionRadius.value,
this.map,
);
this.camera.position.x = nextPosition.x;
this.camera.position.z = nextPosition.z;
this.camera.rotation.y = this.yaw;

for (const collectible of [...this.collectibles]) {
Expand Down
13 changes: 11 additions & 2 deletions agent-test/templates/modules/threed_basic/src/SceneMap.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
export type SceneMap = {
playerSpawn: { x: number; z: number };
floorPatches: Array<{ x: number; z: number; radius: number }>;
collectibles: Array<{ id: string; x: number; y: number; z: number }>;
obstacles: Array<{ x: number; y: number; z: number; scale: number }>;
obstacles: Array<{
x: number;
y: number;
z: number;
scale: number;
collisionRadius: number;
}>;
};

/** Editor-facing data initialization; keep positions declarative and code-free. */
export function initSceneMap(): SceneMap {
return {
playerSpawn: { x: 0, z: 6.5 },
// EXT: append authored path patches without changing GameScene.
floorPatches: Array.from({ length: 14 }, (_, i) => ({
x: Math.sin(i * 0.8) * 2.2,
Expand All @@ -20,10 +28,11 @@ export function initSceneMap(): SceneMap {
z: -5 - i * 9,
})),
obstacles: Array.from({ length: 12 }, (_, i) => ({
x: i % 2 ? 5.6 : -5.6,
x: Math.sin(i * 0.8) * 2.2 + (i % 2 ? 2.2 : -2.2),
y: 1.2,
z: -i * 6,
scale: 0.7 + (i % 3) * 0.25,
collisionRadius: 0.8 + (i % 3) * 0.2,
})),
};
}
5 changes: 2 additions & 3 deletions agent-test/templates/modules/threed_basic/src/gameConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@
},
"playerConfig": {
"moveSpeed": { "value": 9, "type": "number", "description": "Movement units per second" },
"mouseSensitivity": { "value": 0.002, "type": "number", "description": "Mouse look sensitivity" }
"mouseSensitivity": { "value": 0.002, "type": "number", "description": "Mouse look sensitivity" },
"collisionRadius": { "value": 0.45, "type": "number", "description": "Player radius for XZ collision" }
},
"levelConfig": {
"trackHalfWidth": { "value": 7, "type": "number", "description": "Half-width of the playable track" },
"finishZ": { "value": -78, "type": "number", "description": "End of the main path" },
"collectRadius": { "value": 1.8, "type": "number", "description": "Manual pickup radius" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { resolveMovement, type CollisionMap } from '../src/CollisionResolver.js';

function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}

const openFloor: CollisionMap = {
floorPatches: [{ x: 0, z: 0, radius: 5 }],
obstacles: [],
};
const roadEdge = resolveMovement(
{ x: 0, z: 0 },
{ x: 20, z: 0 },
0.5,
openFloor,
);
assert(roadEdge.x <= 4.5 + 1e-6, 'player escaped the authored floor');

const blockedFloor: CollisionMap = {
...openFloor,
obstacles: [{ x: 0, z: 0, collisionRadius: 0.75 }],
};
const blocked = resolveMovement(
{ x: -3, z: 0 },
{ x: 6, z: 0 },
0.5,
blockedFloor,
);
assert(blocked.x < -1.2, 'high-delta movement tunneled through an obstacle');

const sliding = resolveMovement(
{ x: -2, z: -1.5 },
{ x: 2, z: 1 },
0.5,
blockedFloor,
);
assert(sliding.z > -1.5, 'unblocked axis did not slide along the obstacle');

console.log('threed_basic collision self-check: PASS');