From a3f5b76fac4ab30612aea8742dd771b884a58435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=9D=89=E6=81=AF?= Date: Tue, 4 Aug 2026 17:40:51 +0800 Subject: [PATCH 1/2] feat(templates): add threed_basic manual collision Keep the player on authored floor patches and outside explicit static obstacle circles without introducing a physics dependency. Substepped resolution prevents high-delta tunnelling and the self-check covers road, obstacle, and slide behavior. --- .../threed_basic/src/CollisionResolver.ts | 64 +++++++++++++++++++ .../modules/threed_basic/src/GameScene.ts | 36 ++++++----- .../modules/threed_basic/src/SceneMap.ts | 13 +++- .../modules/threed_basic/src/gameConfig.json | 5 +- .../threed_basic/tests/collision-selfcheck.ts | 39 +++++++++++ 5 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 agent-test/templates/modules/threed_basic/src/CollisionResolver.ts create mode 100644 agent-test/templates/modules/threed_basic/tests/collision-selfcheck.ts diff --git a/agent-test/templates/modules/threed_basic/src/CollisionResolver.ts b/agent-test/templates/modules/threed_basic/src/CollisionResolver.ts new file mode 100644 index 000000000..fe6156b5b --- /dev/null +++ b/agent-test/templates/modules/threed_basic/src/CollisionResolver.ts @@ -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; +} diff --git a/agent-test/templates/modules/threed_basic/src/GameScene.ts b/agent-test/templates/modules/threed_basic/src/GameScene.ts index c9878650e..649507b68 100644 --- a/agent-test/templates/modules/threed_basic/src/GameScene.ts +++ b/agent-test/templates/modules/threed_basic/src/GameScene.ts @@ -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'; @@ -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; @@ -65,7 +67,6 @@ 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({ @@ -73,7 +74,7 @@ export class GameScene { 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, @@ -88,7 +89,7 @@ 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); @@ -96,7 +97,7 @@ export class GameScene { 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( @@ -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 { @@ -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]) { diff --git a/agent-test/templates/modules/threed_basic/src/SceneMap.ts b/agent-test/templates/modules/threed_basic/src/SceneMap.ts index edbafd736..c27afb560 100644 --- a/agent-test/templates/modules/threed_basic/src/SceneMap.ts +++ b/agent-test/templates/modules/threed_basic/src/SceneMap.ts @@ -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, @@ -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, })), }; } diff --git a/agent-test/templates/modules/threed_basic/src/gameConfig.json b/agent-test/templates/modules/threed_basic/src/gameConfig.json index 1ec3b8a9f..3bec72a8f 100644 --- a/agent-test/templates/modules/threed_basic/src/gameConfig.json +++ b/agent-test/templates/modules/threed_basic/src/gameConfig.json @@ -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" } } } diff --git a/agent-test/templates/modules/threed_basic/tests/collision-selfcheck.ts b/agent-test/templates/modules/threed_basic/tests/collision-selfcheck.ts new file mode 100644 index 000000000..5c6cab22c --- /dev/null +++ b/agent-test/templates/modules/threed_basic/tests/collision-selfcheck.ts @@ -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'); From 3a0b59a7ed55b1bd1791cfbda04f14e2ca5fe311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=9D=89=E6=81=AF?= Date: Tue, 4 Aug 2026 17:41:02 +0800 Subject: [PATCH 2/2] docs(templates): define threed collision contract Teach the builder to preserve the pure XZ resolver, explicit collision radii, and v1 no-physics boundary so generated games consume the new capability instead of bypassing it. --- agent-test/docs/modules/threed_basic/design_rules.md | 5 ++++- agent-test/docs/modules/threed_basic/template_api.md | 8 +++++++- agent-test/docs/modules/threed_basic/threed_basic.md | 9 +++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/agent-test/docs/modules/threed_basic/design_rules.md b/agent-test/docs/modules/threed_basic/design_rules.md index d8cc4e95d..82a3ed896 100644 --- a/agent-test/docs/modules/threed_basic/design_rules.md +++ b/agent-test/docs/modules/threed_basic/design_rules.md @@ -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()` | @@ -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 diff --git a/agent-test/docs/modules/threed_basic/template_api.md b/agent-test/docs/modules/threed_basic/template_api.md index 2ba793f83..cbf674c74 100644 --- a/agent-test/docs/modules/threed_basic/template_api.md +++ b/agent-test/docs/modules/threed_basic/template_api.md @@ -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 | @@ -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 diff --git a/agent-test/docs/modules/threed_basic/threed_basic.md b/agent-test/docs/modules/threed_basic/threed_basic.md index 0826130eb..f9e436747 100644 --- a/agent-test/docs/modules/threed_basic/threed_basic.md +++ b/agent-test/docs/modules/threed_basic/threed_basic.md @@ -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 | @@ -19,7 +19,7 @@ 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 ``` @@ -27,6 +27,10 @@ Preloader.load -> TitleScreen.show -> GameScene constructor `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 @@ -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 |