diff --git a/core/src/draw.rs b/core/src/draw.rs index b1c4558..8ce9601 100644 --- a/core/src/draw.rs +++ b/core/src/draw.rs @@ -636,6 +636,25 @@ impl<'a> Walker<'a> { return; } + // -- host surface backdrop (PROP.scene3d) ---------------------------- + // Emitted BEFORE background/children: the host composites the bound + // 3D scene as this node's backdrop layer at its world AABB (viewports + // are axis-aligned by contract; a rotated ancestor degrades to the + // AABB rather than dropping the scene). + let scene_handle = node + .overrides + .iter() + .find(|&&(p, _)| p == spec::prop::SCENE3D) + .map(|&(_, v)| v) + .unwrap_or(0); + if scene_handle != 0 { + let c = self.world_aabb(&world, l.w, l.h); + dl.words.push(spec::draw_op::SCENE_QUAD); + dl.words.push(xy_word(c.x0, c.y0)); + dl.words.push(wh_word(c.x1 - c.x0, c.y1 - c.y0)); + dl.words.push(scene_handle); + } + // -- background + shadow -------------------------------------------- let has_grad = r.grad_dir != NO_GRADIENT && r.grad_dir <= spec::GradDir::ToRight as u32; let bg_color = scale_alpha(r.bg_color, op); diff --git a/core/src/spec.rs b/core/src/spec.rs index 626d18c..2d9467c 100644 --- a/core/src/spec.rs +++ b/core/src/spec.rs @@ -142,6 +142,7 @@ pub mod prop { pub const ARC_START: u8 = 140; pub const ARC_SWEEP: u8 = 141; pub const ARC_WIDTH: u8 = 142; + pub const SCENE3D: u8 = 160; } /// How a prop's u32 payload is interpreted (see spec.ts VALUE_KIND). @@ -163,7 +164,7 @@ pub const PROP_VALUE_KIND: [u8; 256] = [ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, @@ -358,6 +359,7 @@ pub mod draw_op { pub const SCISSOR_POP: u32 = 6; pub const TRI: u32 = 7; pub const TEX_TRI: u32 = 8; + pub const SCENE_QUAD: u32 = 9; } /// .pak container constants (byte-compatible with dreamcart's format; diff --git a/demos/dogfight/app.tsx b/demos/dogfight/app.tsx new file mode 100644 index 0000000..1600fc9 --- /dev/null +++ b/demos/dogfight/app.tsx @@ -0,0 +1,133 @@ +// demos/dogfight/app.tsx — "Pocket Dogfight": the playset flight+combat demo. +// +// The pure sim lives in game.ts; this component wires it to the runtime: +// createGameLoop steps the game at a fixed 1/60 s on the virtual clock +// (hz-invariant, DETERMINISM.md), the render callback flushes the Scene3D +// and refreshes one HUD signal, and the cockpit overlay — FlightHud pitch +// tape + data boxes, HeadingRelativeRadar, damage flash, message line — +// composes as ordinary Views over the . On hosts without a 3D +// core the viewport is an empty box and the HUD, driven by the same +// deterministic sim, still renders. + +import { Show, createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { createGameLoop } from "../../playset/loop.ts"; +import { Viewport3D } from "../../playset/scene3d/viewport.ts"; +import { FlightHud, type FlightHudState } from "../../playset/modules/user-interface/flight-hud.ts"; +import { HeadingRelativeRadar } from "../../playset/modules/user-interface/heading-relative-radar.ts"; +import { createDogfightGame } from "./game.ts"; + +// spec/spec.ts ENUMS ordinals (stable wire values; same idiom as flight-hud.ts). +const POS_ABSOLUTE = 1; +const ALIGN_CENTER = 1; +const FLEX_COL = 1; + +const SCREEN_W = 480; +const SCREEN_H = 272; +const RADAR_SIZE = 58; +const RADAR_RANGE = 3600; + +function pad(value: number, width: number): string { + return String(Math.max(0, Math.round(value))).padStart(width, "0"); +} + +export default function Dogfight() { + const game = createDogfightGame(); + const [hud, setHud] = createSignal(game.hudState()); + + createGameLoop({ + step: (dt, input) => game.step(dt, input), + render: () => { + game.scene.flush(); + setHud(game.hudState()); + }, + }); + + // Status row budget is tight at 480 px: HP rides in the region slot, the + // score is abbreviated, and the time slot stays empty. + const hudSource = (): Partial => { + const h = hud(); + return { + regionName: h.failed ? "MISSION FAILED" : `HP ${pad(h.health, 3)}`, + speed: h.speed, + altitude: h.altitude, + agl: h.agl, + waveLabel: `WAVE ${h.waveNumber}`, + waveDetail: `${h.banditsAlive} BANDIT${h.banditsAlive === 1 ? "" : "S"}`, + compassHeadingDegrees: h.headingDeg, + timeText: "", + scoreText: `SCR ${pad(h.score, 4)}`, + throttle: h.throttle, + pitchDegrees: h.pitchDeg, + rollDegrees: h.rollDeg, + weaponLabel: h.weaponLabel, + lockStatus: h.lockStatus, + gunHeat: h.gunHeat, + pullUpWarning: h.pullUp, + }; + }; + + // No bgColor on the viewport: on native hosts the 3D scene composites + // UNDER the ui layer, so the viewport (and its ancestors) stay unpainted. + return ( + + + + + {/* damage flash — the demo's red vignette as a flat overlay */} + 0.01}> + + + + {/* combat messages (FOX TWO / SPLASH ONE / WAVE N INBOUND) */} + + + + {hud().message} + + + + + {/* heading-relative radar, bottom-right (the demo's scope corner) */} + + hud().playerPosition} + playerForward={() => hud().playerForward} + contacts={() => hud().contacts} + width={RADAR_SIZE} + height={RADAR_SIZE} + range={RADAR_RANGE} + contactColor="#ff4f42" + /> + + + + ); +} diff --git a/demos/dogfight/game.ts b/demos/dogfight/game.ts new file mode 100644 index 0000000..699acfa --- /dev/null +++ b/demos/dogfight/game.ts @@ -0,0 +1,1103 @@ +// demos/dogfight/game.ts — "Pocket Dogfight": the pure jet-combat sim, +// composed from playset flight + combat modules. +// +// Reference: the GameBlocks demo "jet-dogfight" (https://gb-jet-dogfight.vercel.app/, +// unbundled sources under /src/). The library modules it composes are the MIT +// GameBlocks modules already ported as playset/modules/. This file is fresh +// Pocket-shaped glue derived from the demo's game.js/main.js/terrain.js — +// flight envelope, terrain function, enemy wave/AI/weapon balance numbers and +// visual palette transfer directly; the composition is rebuilt on scene3d, +// PSP buttons, and the deterministic fixed-step loop (DETERMINISM.md). +// +// Everything is deterministic fixed-step state: inputs are the per-step +// button mask + analog nub and FIXED_DT — no wall clock (one manual Clock +// advanced by the sim feeds weapon cooldowns), no Math.random (one seeded +// RandomGenerator drives waves/spawns/cooldowns; two more drive cloud +// placement and burst-particle scatter). The composition under test: +// +// terrainHeightAt + createTerrainMesh the demo's analytic mountain +// range baked to a heightfield +// AirplaneMotionController ×N player (buttons/nub) + bandits +// ProjectileWeaponSystem player fire control: gun heat, +// missile lock-on (boresight) +// ProjectileManager + ProjectileObject bullets + homing missiles, +// per-team target lists +// WaveSpawnDirector fighter/ace wave escalation +// CombatPlay player health/armor referee +// FlightPlay terrain-crash referee (the +// demo's soft altitude floor +// becomes real mountain impact) +// AirplaneVisualFactory + AirplaneModelController + JetFlame +// WeaponEffectsSystem tracers + hit/explosion bursts +// PoseFollowCameraRig chase camera +// +// Controls: nub/d-pad = pitch + bank (hold L: left/right becomes rudder yaw, +// the demo's 0.78 rad/s coefficient), CROSS = gun, SQUARE = missile (selects +// + holds lock, fires when LOCKED), TRIANGLE/CIRCLE = throttle, R = boost. +// +// A debug probe rides on globalThis.__dogfightProbe so the headless E2E +// (playset/test/dogfight-sim.test.ts) can assert combat + crash progress +// without scraping HUD pixels. + +import { BTN } from "@pocketjs/framework/input"; +import { Vector3 } from "../../playset/math/index.ts"; +import { MAT, Scene3D } from "../../playset/scene3d/client.ts"; +import type { GameInput } from "../../playset/loop.ts"; +import { RandomGenerator } from "../../playset/modules/math/random-utils.ts"; +import { Clock } from "../../playset/modules/math/time-utils.ts"; +import { clamp, clamp01, lerp, smoothToward, toDeg } from "../../playset/modules/math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS } from "../../playset/modules/math/world-basis.ts"; +import { rgbToAbgr } from "../../playset/modules/world/color-utils.ts"; +import { createTerrainMesh } from "../../playset/modules/world/environment/terrain-mesh-factory.ts"; +import { AirplaneMotionController } from "../../playset/modules/actor-motion/aircraft/airplane-motion-controller.ts"; +import { AirplaneModelController } from "../../playset/modules/actor-motion/aircraft/airplane-model-controller.ts"; +import { + createAirplaneVisual, + type AirplaneVisual, +} from "../../playset/modules/world/object/factory/airplane-visual-factory.ts"; +import { + createBulletProjectileVisual, + createMissileProjectileVisual, +} from "../../playset/modules/world/object/factory/projectile-visual-factory.ts"; +import { + ProjectileObject, + type ProjectileObjectOptions, +} from "../../playset/modules/world/object/projectile-object.ts"; +import { ProjectileManager } from "../../playset/modules/gameplay/combat/projectile-manager.ts"; +import { + MISSILE_LOCK_STATUS, + ProjectileWeaponSystem, + WEAPON_AIM_MODES, + WEAPON_DECISIONS, + WEAPON_TYPES, + type MissileLockStatus, + type WeaponFireDecision, +} from "../../playset/modules/gameplay/combat/projectile-weapon-system.ts"; +import { WaveSpawnDirector } from "../../playset/modules/gameplay/wave-spawn-director.ts"; +import { COMBAT_PLAY_EVENTS, CombatPlay } from "../../playset/modules/gameplay/combat-play.ts"; +import { FlightPlay } from "../../playset/modules/gameplay/flight-play.ts"; +import { WeaponEffectsSystem } from "../../playset/modules/world/visual-effects/weapon-effects-system.ts"; +import { PoseFollowCameraRig } from "../../playset/modules/camera/pose-follow-camera-rig.ts"; + +const BASIS = DEFAULT_WORLD_BASIS; +export const PLAYER_ID = "player"; + +// --------------------------------------------------------------------------- +// Terrain — the demo's analytic mountain range (terrain.js), verbatim math. +// Planar (right, forward) in, height up. Pure function; the FlightPlay crash +// referee and the enemy terrain floor sample it directly. +// --------------------------------------------------------------------------- + +function mountainMass( + right: number, + forward: number, + centerRight: number, + centerForward: number, + radiusRight: number, + radiusForward: number, + height: number, +): number { + const nr = (right - centerRight) / radiusRight; + const nf = (forward - centerForward) / radiusForward; + return Math.exp(-(nr * nr + nf * nf)) * height; +} + +export function terrainHeightAt(right: number, forward: number): number { + const broadRidges = + Math.sin(right * 0.00074 + forward * 0.00038) * 320 + + Math.cos(forward * 0.00068 - right * 0.00031) * 270; + const brokenHills = + Math.sin((right + forward) * 0.00162) * 155 + + Math.cos((right - forward) * 0.00132) * 130 + + Math.sin(right * 0.0042) * Math.cos(forward * 0.0034) * 82; + const peaks = + mountainMass(right, forward, -4200, 2600, 1500, 1350, 630) + + mountainMass(right, forward, 3600, 3100, 1450, 1300, 590) + + mountainMass(right, forward, 0, 3600, 1300, 1500, 750) + + mountainMass(right, forward, -900, 4700, 2100, 1250, 510) + + mountainMass(right, forward, 2900, -3200, 1300, 1550, 490) + + mountainMass(right, forward, -4200, 2600, 620, 540, 260) + + mountainMass(right, forward, 3600, 3100, 560, 520, 230) + + mountainMass(right, forward, 0, 3600, 520, 560, 310); + return (-740 + broadRidges + brokenHills + peaks) * 0.5; +} + +/** Height→color ramp from the demo's terrain vertex-color bake (main.js). */ +interface RGB { + r: number; + g: number; + b: number; +} + +function hexRgb(hex: number): RGB { + return { r: ((hex >> 16) & 255) / 255, g: ((hex >> 8) & 255) / 255, b: (hex & 255) / 255 }; +} + +const VALLEY = hexRgb(0x31592f); +const GRASS = hexRgb(0x6d823f); +const EARTH = hexRgb(0x8a7651); +const ROCK = hexRgb(0x69717c); +const SNOW = hexRgb(0xf2f7fb); + +function lerpRgb(a: RGB, b: RGB, t: number): RGB { + return { r: lerp(a.r, b.r, t), g: lerp(a.g, b.g, t), b: lerp(a.b, b.b, t) }; +} + +export function terrainColorAt(height: number): RGB { + if (height < -560) return lerpRgb(VALLEY, GRASS, clamp01((height + 900) / 340)); + if (height < -120) return lerpRgb(GRASS, EARTH, clamp01((height + 560) / 440)); + if (height < 220) return lerpRgb(EARTH, ROCK, clamp01((height + 120) / 340)); + return lerpRgb(ROCK, SNOW, clamp01((height - 220) / 360)); +} + +// --------------------------------------------------------------------------- +// Balance — the demo's tuning tables (game.js), numbers verbatim. +// --------------------------------------------------------------------------- + +const GAME_SEED = 20260616; +const CLOUD_SEED = 20260616; +const EFFECTS_SEED = 20260617; + +const PLAYER_TUNING = Object.freeze({ + minSpeed: 88, + maxSpeed: 260, + pitchRate: 1.35, + bankTurnRate: 0.56, + throttleRate: 0.55, + initialSpeed: 150, + initialThrottle: 0.55, + initialAltitude: 520, + yawRate: 0.78, // rad/s at full rudder (the demo's intent.yaw coefficient) + radius: 30, + health: 100, + armor: 16, +}); + +const PLAYER_GUN = Object.freeze({ + fireRate: 0.055, + speed: 1650, + launchOffset: { right: 0, up: -1.2, forward: 18 }, + damage: 10, + lifetimeSeconds: 1.05, + hitRadius: 30, +}); + +const PLAYER_MISSILE = Object.freeze({ + ammo: 6, + fireRate: 0.9, + speed: 620, + launchOffset: { right: 6, up: -2.3, forward: 12 }, + damage: 62, + lifetimeSeconds: 8, + hitRadius: 55, + turnResponse: 1.8, +}); + +const LOCK = Object.freeze({ + lockRequiredSeconds: 0.75, + targetAimDotMin: 0.955, + targetMaxDistance: 4200, +}); + +const ENEMY_BALANCE = Object.freeze({ + baseWaveSize: 2, + growthPerWave: 1.125, + maxWaveSize: 9, + maxSpawnsPerStep: 2, + aceUnlockWave: 3, + aceWeightPerWaveAfterUnlock: 0.27, + fighterHealth: 105, + aceHealth: 123, + fighterRadius: 30, + aceRadius: 39, + minSpeed: 88, + maxSpeed: 225, + pitchRate: 1.0, + bankTurnRate: 0.52, + initialSpeed: 135, + initialThrottle: 0.45, + initialGunCooldownMin: 0.67, + initialGunCooldownMax: 1.6, + initialMissileCooldownMin: 6.7, + initialMissileCooldownMax: 10, + fighterAggression: 1.02, + aceAggression: 1.29, + spawnDistanceMin: 2000, + spawnDistanceMax: 3100, + spawnAltitudeMin: 430, + spawnAltitudeMax: 980, + gunAimDotMin: 0.979, + gunRange: 2625, + gunProjectileSpeed: 1950, + gunDamage: 5.25, + gunCooldownMin: 0.43, + gunCooldownMax: 0.73, + missileMinRange: 700, + missileProjectileSpeed: 645, + missileDamage: 21, + missileCooldownMin: 8, + missileCooldownMax: 12, + minPlayerDistance: 500, + breakawayDistance: 700, + breakawayLeadDistance: 900, + terrainClearance: 240, +}); + +/** Gun bullets inherit this fraction of the shooter's airspeed (game.js). */ +const GUN_SPEED_INHERIT = 0.55; +/** Gun tracer flash: 250 units long, 0.07 s (game.js TRACER event). */ +const TRACER_LENGTH = 250; +const TRACER_TTL = 0.07; +const TRACER_COLOR = 0xfff0a0; +/** AGL under which the HUD shows PULL UP (port-native; the demo's fixed + * altitude-70 soft floor becomes a hard terrain crash via FlightPlay). */ +const PULL_UP_AGL = 150; + +const KILL_SCORE = 100; +const KILL_HEAL = 10; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Aircraft { + id: string; + motion: AirplaneMotionController; + /** Live reference to motion.position (WeaponTarget/ProjectileTarget shape). */ + position: Vector3; + health: number; + maxHealth: number; + radius: number; + destroyed: boolean; + enemy: boolean; + ace: boolean; + gunCooldown: number; + missileCooldown: number; + aiSeed: number; + visual: AirplaneVisual; + model: AirplaneModelController; +} + +interface ProjectileMeta { + weaponId: string; + damage: number; + sourceId: string; +} + +export interface DogfightProbe { + /** scene3d handle of the game's one scene (0 in pure-mirror mode). */ + sceneId: number; + /** Player airframe group node id — the test reads its serialized pose. */ + playerNodeId: number; + steps: number; + waveNumber: number; + enemiesSpawned: number; + enemiesDestroyed: number; + playerGunShots: number; + playerMissileShots: number; + /** Player projectile hit events (gun + missile) that landed on a bandit. */ + playerHitsLanded: number; + playerHealth: number; + score: number; + /** FlightPlay hit-ground events (terrain crash referee). */ + crashes: number; + playerDestroyed: boolean; + lockStatus: MissileLockStatus; + playerPosition: { x: number; y: number; z: number }; +} + +/** Radar contact rows (heading-relative-radar's loose RadarContact shape). */ +export interface RadarContactState extends Record { + position: { x: number; y: number; z: number }; + color: number; + size: number; +} + +export interface DogfightHudState { + speed: number; + altitude: number; + agl: number; + health: number; + score: number; + waveNumber: number; + banditsAlive: number; + weaponLabel: string; + lockStatus: MissileLockStatus; + missiles: number; + gunHeat: number; + throttle: number; + headingDeg: number; + pitchDeg: number; + rollDeg: number; + pullUp: boolean; + failed: boolean; + damageFlash: number; + message: string; + playerPosition: { x: number; y: number; z: number }; + playerForward: { x: number; y: number; z: number }; + contacts: RadarContactState[]; +} + +export interface DogfightGame { + scene: Scene3D; + /** One fixed 1/60 s simulation step (createGameLoop's `step`). */ + step(dt: number, input: GameInput): void; + /** Fresh HUD snapshot (call from the loop's `render`). */ + hudState(): DogfightHudState; +} + +function vec(v: Vector3): { x: number; y: number; z: number } { + return { x: v.x, y: v.y, z: v.z }; +} + +function shortestAngleDelta(target: number, current: number): number { + return Math.atan2(Math.sin(target - current), Math.cos(target - current)); +} + +// --------------------------------------------------------------------------- +// The game +// --------------------------------------------------------------------------- + +export function createDogfightGame(): DogfightGame { + const scene = new Scene3D(); + const prng = new RandomGenerator(GAME_SEED); + const clock = new Clock({ manual: true, nowMs: 0 }); + + // -- world: terrain heightfield + sky/fog/light + clouds --------------------- + createTerrainMesh({ + scene, + terrainSampler: { + sample(right: number, forward: number): { height: number; color: RGB } { + const height = terrainHeightAt(right, forward); + return { height, color: terrainColorAt(height) }; + }, + }, + size: 14500, + segments: 180, + }); + + // Demo lighting: sun from basis (1300, 2200, 900), hemisphere 0xbfe8ff over + // 0x2e4d64, solid 0x8bc6ee sky with FogExp2(0x8bc6ee, 0.00042) — the exp2 + // curve approximated by a linear band (50% fade ≈ 1980 u, 90% ≈ 3610 u). + scene.sun(new Vector3(-1300, -2200, 900).normalize(), rgbToAbgr(0xfff5d0)); + scene.ambient(rgbToAbgr(0xbfe8ff), rgbToAbgr(0x2e4d64)); + scene.sky(rgbToAbgr(0x8bc6ee), rgbToAbgr(0xd0e8f5)); + scene.fog(rgbToAbgr(0x8bc6ee), 900, 3900); + scene.camera.fovY = (68 * Math.PI) / 180; + scene.camera.znear = 1; + scene.camera.zfar = 18000; + + // Cloud deck (demo counts/draw order; unit sphere scaled per puff — the + // demo's per-puff SphereGeometry radius folds into the node scale). + { + const cloudPrng = new RandomGenerator(CLOUD_SEED); + const cloudMaterial = scene.material(rgbToAbgr(0xf8fcff, 0.92), MAT.transparent); + const puffGeom = scene.sphere(1, 10); + for (let i = 0; i < 72; i += 1) { + const cloud = scene.node(); + const parts = 4 + cloudPrng.randint(0, 4); + for (let p = 0; p < parts; p += 1) { + const radius = cloudPrng.uniform(35, 95); + const puff = scene.mesh(puffGeom, cloudMaterial, cloud); + puff.scale.set( + radius * cloudPrng.uniform(1.4, 3.4), + radius * cloudPrng.uniform(0.25, 0.55), + radius * cloudPrng.uniform(0.75, 1.6), + ); + puff.position.set( + cloudPrng.uniform(-150, 150), + cloudPrng.uniform(-12, 18), + cloudPrng.uniform(-80, 80), + ); + } + cloud.position.copy( + BASIS.fromBasisComponents( + cloudPrng.uniform(-5200, 5200), + cloudPrng.uniform(820, 1650), + cloudPrng.uniform(-5200, 5200), + ), + ); + } + } + + // -- effects ------------------------------------------------------------------ + const effects = new WeaponEffectsSystem({ + scene, + maxEffects: 72, + prng: new RandomGenerator(EFFECTS_SEED), + tracerWidth: 2.5, // world-scale stand-ins for the demo's 1px lines / + particleSize: 6, // 0.05 points (see module header) + }); + + // -- aircraft ------------------------------------------------------------------- + function makeAircraft(options: { + id: string; + enemy: boolean; + ace: boolean; + bodyColor: number; + accentColor: number; + scale: number; + position: Vector3; + yaw: number; + }): Aircraft { + const motion = new AirplaneMotionController({ + minSpeed: options.enemy ? ENEMY_BALANCE.minSpeed : PLAYER_TUNING.minSpeed, + maxSpeed: options.enemy ? ENEMY_BALANCE.maxSpeed : PLAYER_TUNING.maxSpeed, + pitchRate: options.enemy ? ENEMY_BALANCE.pitchRate : PLAYER_TUNING.pitchRate, + bankTurnRate: options.enemy ? ENEMY_BALANCE.bankTurnRate : PLAYER_TUNING.bankTurnRate, + throttleRate: PLAYER_TUNING.throttleRate, + basis: BASIS, + }); + motion.reset(options.position); + motion.setState( + options.enemy ? ENEMY_BALANCE.initialSpeed : PLAYER_TUNING.initialSpeed, + options.enemy ? ENEMY_BALANCE.initialThrottle : PLAYER_TUNING.initialThrottle, + 0, + 0, + options.yaw, + options.position, + ); + const visual = createAirplaneVisual(scene, { + scale: options.scale, + bodyColor: options.bodyColor, + accentColor: options.accentColor, + showTargetRing: options.enemy, + targetRingColor: options.enemy ? 0xff503d : 0x6cffc7, + }); + const health = options.enemy + ? options.ace + ? ENEMY_BALANCE.aceHealth + : ENEMY_BALANCE.fighterHealth + : PLAYER_TUNING.health; + return { + id: options.id, + motion, + position: motion.position, + health, + maxHealth: health, + radius: options.enemy && options.ace ? ENEMY_BALANCE.aceRadius : ENEMY_BALANCE.fighterRadius, + destroyed: false, + enemy: options.enemy, + ace: options.ace, + gunCooldown: prng.uniform( + ENEMY_BALANCE.initialGunCooldownMin, + ENEMY_BALANCE.initialGunCooldownMax, + ), + missileCooldown: prng.uniform( + ENEMY_BALANCE.initialMissileCooldownMin, + ENEMY_BALANCE.initialMissileCooldownMax, + ), + aiSeed: prng.random() * Math.PI * 2, + visual, + model: new AirplaneModelController(visual.group, visual.jetFlames, BASIS), + }; + } + + const player = makeAircraft({ + id: PLAYER_ID, + enemy: false, + ace: false, + bodyColor: 0xeaf6ff, + accentColor: 0x31d6ff, + scale: 10, + position: BASIS.fromBasisComponents(0, PLAYER_TUNING.initialAltitude, 0), + yaw: 0, + }); + const enemies: Aircraft[] = []; + let nextEnemyId = 1; + + const liveEnemies = (): Aircraft[] => enemies.filter((enemy) => !enemy.destroyed); + + // -- referees --------------------------------------------------------------------- + const combat = new CombatPlay({ maxHealth: 100, maxArmor: 30, armorAbsorption: 0.45 }); + combat.addPlayer({ + playerId: PLAYER_ID, + teamId: "blue", + health: PLAYER_TUNING.health, + armor: PLAYER_TUNING.armor, + }); + // The red team is represented by a sentinel so combat never auto-finishes + // while bandits respawn in waves (the demo does the same). + combat.addPlayer({ playerId: "red-force", teamId: "red", health: 100, armor: 0 }); + combat.startGame(); + + const flight = new FlightPlay({ crashHeightAt: terrainHeightAt, basis: BASIS }); + flight.addPlayer({ playerId: PLAYER_ID, position: vec(player.position) }); + flight.startGame(); + + const waves = new WaveSpawnDirector({ + baseWaveSize: ENEMY_BALANCE.baseWaveSize, + growthPerWave: ENEMY_BALANCE.growthPerWave, + maxWaveSize: ENEMY_BALANCE.maxWaveSize, + maxSpawnsPerStep: ENEMY_BALANCE.maxSpawnsPerStep, + typeWeights: { + fighter: 1, + ace: (wave: number): number => + Math.max(0, wave - ENEMY_BALANCE.aceUnlockWave + 1) * + ENEMY_BALANCE.aceWeightPerWaveAfterUnlock, + }, + unlockRules: [ + { waveNumber: 1, type: "fighter" }, + { waveNumber: ENEMY_BALANCE.aceUnlockWave, type: "ace" }, + ], + prng, + }); + + // -- player fire control --------------------------------------------------------- + const weaponSystem = new ProjectileWeaponSystem({ + aimMode: WEAPON_AIM_MODES.BORESIGHT, + lockRequiredSeconds: LOCK.lockRequiredSeconds, + targetAimDotMin: LOCK.targetAimDotMin, + targetMaxDistance: LOCK.targetMaxDistance, + clock, + }); + weaponSystem.updateWeaponConfig(WEAPON_TYPES.GUN, { + ammo: Infinity, + maxAmmo: Infinity, + fireRate: PLAYER_GUN.fireRate, + speed: PLAYER_GUN.speed, + launchOffset: PLAYER_GUN.launchOffset, + }); + weaponSystem.updateWeaponConfig(WEAPON_TYPES.MISSILE, { + ammo: PLAYER_MISSILE.ammo, + maxAmmo: PLAYER_MISSILE.ammo, + fireRate: PLAYER_MISSILE.fireRate, + speed: PLAYER_MISSILE.speed, + launchOffset: PLAYER_MISSILE.launchOffset, + }); + + // -- projectiles (per-team target lists; ProjectileObject drives visuals) -------- + const makeManager = (): ProjectileManager => + new ProjectileManager({ + basis: BASIS, + // The manager forwards our own spawn options; ProjectileObject's richer + // visual type is what we actually passed in. + createProjectile: (config) => new ProjectileObject(config as ProjectileObjectOptions), + }); + const playerShots = makeManager(); + const enemyShots = makeManager(); + + interface SpawnShotOptions { + manager: ProjectileManager; + owner: Aircraft; + weaponId: string; + damage: number; + position: Vector3; + direction: Vector3; + speed: number; + target: Aircraft | null; + lifetimeSeconds: number; + hitRadius: number; + turnResponse: number; + } + + function spawnShot(options: SpawnShotOptions): void { + const visual = + options.weaponId === WEAPON_TYPES.MISSILE + ? createMissileProjectileVisual(scene) + : createBulletProjectileVisual(scene); + options.manager.spawnProjectile({ + visual, + metadata: { + weaponId: options.weaponId, + damage: options.damage, + sourceId: options.owner.id, + } satisfies ProjectileMeta, + position: options.position, + direction: options.direction, + speed: options.speed, + target: options.target, + lifetimeSeconds: options.lifetimeSeconds, + hitRadius: options.hitRadius, + turnResponse: options.turnResponse, + basis: BASIS, + }); + if (options.weaponId === WEAPON_TYPES.GUN) { + effects.spawnTracer( + options.position, + options.position.clone().addScaledVector(options.direction, TRACER_LENGTH), + TRACER_COLOR, + TRACER_TTL, + ); + } + } + + // -- chase camera ------------------------------------------------------------------ + const cameraRig = new PoseFollowCameraRig({ + cameraOffset: { forward: -52, up: 16, right: 0 }, + lookAtOffset: { forward: 100, up: 8, right: 0 }, + speedCameraOffset: { forward: -0.05, up: 0.01, right: 0 }, + positionLag: 0.08, + lookLag: 0.04, + frameLag: 0.06, + basis: BASIS, + }); + + // -- hud/message + probe state --------------------------------------------------------- + let elapsedSeconds = 0; + let score = 0; + let damageFlash = 0; + let message = "WAVE 1 INBOUND"; + let messageTimer = 2; + let lastWaveNumber = 1; + + const probe: DogfightProbe = { + sceneId: scene.__scene, + playerNodeId: player.visual.group.__id, + steps: 0, + waveNumber: 1, + enemiesSpawned: 0, + enemiesDestroyed: 0, + playerGunShots: 0, + playerMissileShots: 0, + playerHitsLanded: 0, + playerHealth: player.health, + score: 0, + crashes: 0, + playerDestroyed: false, + lockStatus: MISSILE_LOCK_STATUS.NONE, + playerPosition: vec(player.position), + }; + (globalThis as Record).__dogfightProbe = probe; + + function showMessage(text: string, seconds: number): void { + message = text; + messageTimer = seconds; + } + + // -- damage / destruction --------------------------------------------------------------- + function destroyAircraft(target: Aircraft): void { + target.destroyed = true; + effects.emitHitBurst(target.position, BASIS.upVector(), 0xff5a22, 48, 120, 2.4, 900); + target.visual.group.destroy(); + if (target.enemy) { + score += KILL_SCORE; + combat.heal({ playerId: PLAYER_ID, amount: KILL_HEAL }); + player.health = combat.getPlayer(PLAYER_ID).health; + probe.enemiesDestroyed += 1; + showMessage("SPLASH ONE", 2); + } else { + probe.playerDestroyed = true; + } + } + + function applyDamage( + target: Aircraft, + amount: number, + sourceId: string, + bypassArmor = false, + ): void { + if (target.destroyed) return; + target.health = Math.max(0, target.health - amount); + if (!target.enemy) { + combat.damage({ playerId: PLAYER_ID, amount, sourceId, bypassArmor }); + target.health = combat.getPlayer(PLAYER_ID).health; + damageFlash = 1; + } + if (target.health > 0) return; + destroyAircraft(target); + } + + // -- enemy brain (demo game.js steering, fresh composition) ----------------------------- + const scratchTo = new Vector3(); + const scratchDir = new Vector3(); + + function steerEnemyToward( + enemy: Aircraft, + targetPosition: Vector3, + dt: number, + aggression: number, + ): number { + const frame = BASIS.yawPitchRollFrame(enemy.motion.yaw, enemy.motion.pitch, enemy.motion.roll); + scratchTo.copy(targetPosition).sub(enemy.position); + const distance = scratchTo.length(); + const desiredYaw = BASIS.forwardToYaw(scratchTo); + const yawError = shortestAngleDelta(desiredYaw, enemy.motion.yaw); + scratchDir.copy(scratchTo); + if (distance > 1e-6) scratchDir.multiplyScalar(1 / distance); + const desiredPitch = Math.asin(clamp(BASIS.upComponent(scratchDir), -0.85, 0.85)); + const pitchError = clamp(desiredPitch - enemy.motion.pitch, -1, 1); + enemy.motion.yaw += clamp(yawError, -0.85, 0.85) * 0.42 * aggression * dt; + enemy.motion.pitch += Math.sin(elapsedSeconds * 0.9 + enemy.aiSeed) * 0.25 * dt; + enemy.motion.planMovement({ + left: yawError > 0.08 ? 1 : 0, + right: yawError < -0.08 ? 1 : 0, + up: pitchError > 0.04 ? 1 : 0, + down: pitchError < -0.04 ? 1 : 0, + throttle: distance > 900 ? 1 : -0.25, + boost: false, + deltaSeconds: dt, + commit: true, + }); + if (BASIS.upComponent(enemy.position) < 120) { + enemy.motion.pitch = smoothToward(enemy.motion.pitch, 0.28, 0.2, dt); + } + return frame.forward.dot(scratchDir); + } + + function enforcePlayerSpacing(enemy: Aircraft): void { + const offset = enemy.position.clone().sub(player.position); + const distance = offset.length(); + if (distance >= ENEMY_BALANCE.minPlayerDistance) return; + const direction = + distance > 1e-6 + ? offset.multiplyScalar(1 / distance) + : BASIS.yawPitchRollFrame(enemy.motion.yaw, enemy.motion.pitch, enemy.motion.roll) + .forward.multiplyScalar(-1) + .normalize(); + enemy.position + .copy(player.position) + .addScaledVector(direction, ENEMY_BALANCE.minPlayerDistance); + } + + function enemyShoot(enemy: Aircraft, aimDot: number, distance: number, dt: number): void { + enemy.gunCooldown -= dt; + enemy.missileCooldown -= dt; + if ( + aimDot < ENEMY_BALANCE.gunAimDotMin || + distance > ENEMY_BALANCE.gunRange || + enemy.gunCooldown > 0 + ) { + return; + } + + const frame = BASIS.yawPitchRollFrame(enemy.motion.yaw, enemy.motion.pitch, enemy.motion.roll); + spawnShot({ + manager: enemyShots, + owner: enemy, + weaponId: WEAPON_TYPES.GUN, + damage: ENEMY_BALANCE.gunDamage, + position: enemy.position.clone().addScaledVector(frame.forward, 18), + direction: frame.forward.clone(), + speed: ENEMY_BALANCE.gunProjectileSpeed + enemy.motion.speed * GUN_SPEED_INHERIT, + target: null, + lifetimeSeconds: PLAYER_GUN.lifetimeSeconds, + hitRadius: PLAYER_GUN.hitRadius + PLAYER_TUNING.radius, + turnResponse: 0, + }); + enemy.gunCooldown = prng.uniform(ENEMY_BALANCE.gunCooldownMin, ENEMY_BALANCE.gunCooldownMax); + + if ( + enemy.missileCooldown <= 0 && + distance > ENEMY_BALANCE.missileMinRange && + !player.destroyed + ) { + spawnShot({ + manager: enemyShots, + owner: enemy, + weaponId: WEAPON_TYPES.MISSILE, + damage: ENEMY_BALANCE.missileDamage, + position: enemy.position.clone().addScaledVector(frame.forward, 26), + direction: frame.forward.clone(), + speed: ENEMY_BALANCE.missileProjectileSpeed, + target: player, + lifetimeSeconds: PLAYER_MISSILE.lifetimeSeconds, + hitRadius: PLAYER_MISSILE.hitRadius + PLAYER_TUNING.radius, + turnResponse: PLAYER_MISSILE.turnResponse, + }); + enemy.missileCooldown = prng.uniform( + ENEMY_BALANCE.missileCooldownMin, + ENEMY_BALANCE.missileCooldownMax, + ); + } + } + + function spawnEnemy(type: string | undefined, waveNumber: number, spawnIndex: number): void { + const angle = prng.uniform(0, Math.PI * 2); + const distance = prng.uniform(ENEMY_BALANCE.spawnDistanceMin, ENEMY_BALANCE.spawnDistanceMax); + const right = Math.cos(angle) * distance + BASIS.rightComponent(player.position); + const forward = Math.sin(angle) * distance + BASIS.forwardComponent(player.position); + const up = prng.uniform(ENEMY_BALANCE.spawnAltitudeMin, ENEMY_BALANCE.spawnAltitudeMax); + const position = BASIS.fromBasisComponents(right, up, forward); + const toPlayer = player.position.clone().sub(position).normalize(); + const ace = type === "ace"; + const enemy = makeAircraft({ + id: `enemy-${waveNumber}-${spawnIndex}-${nextEnemyId}`, + enemy: true, + ace, + bodyColor: ace ? 0xffdfd3 : 0xf5a097, + accentColor: ace ? 0xffd23f : 0xff4a36, + scale: ace ? 10 : 8.8, + position, + yaw: BASIS.forwardToYaw(toPlayer), + }); + nextEnemyId += 1; + enemies.push(enemy); + probe.enemiesSpawned += 1; + } + + // -- player weapon triggers -------------------------------------------------------------- + function requestPlayerFire(weaponId: string): void { + weaponSystem.selectWeapon(weaponId); + const frame = BASIS.yawPitchRollFrame( + player.motion.yaw, + player.motion.pitch, + player.motion.roll, + ); + const decision: WeaponFireDecision | null = weaponSystem.requestFire({ + shooterPosition: player.position, + shooterBodyFrame: frame, + weaponId, + }); + if (!decision) return; + + if (decision.type === WEAPON_DECISIONS.FIRE_GUN) { + spawnShot({ + manager: playerShots, + owner: player, + weaponId: WEAPON_TYPES.GUN, + damage: PLAYER_GUN.damage, + position: decision.position, + direction: decision.direction, + speed: (decision.speed ?? PLAYER_GUN.speed) + player.motion.speed * GUN_SPEED_INHERIT, + target: null, + lifetimeSeconds: PLAYER_GUN.lifetimeSeconds, + // Uniform bandit body radius folded into the projectile radius + // (ProjectileObject carries one hitRadius; aces are +9 in the demo). + hitRadius: PLAYER_GUN.hitRadius + ENEMY_BALANCE.fighterRadius, + turnResponse: 0, + }); + probe.playerGunShots += 1; + } else if (decision.type === WEAPON_DECISIONS.FIRE_MISSILE) { + const target = (decision.target as Aircraft | null) ?? null; + spawnShot({ + manager: playerShots, + owner: player, + weaponId: WEAPON_TYPES.MISSILE, + damage: PLAYER_MISSILE.damage, + position: decision.position, + direction: decision.direction, + speed: decision.speed ?? PLAYER_MISSILE.speed, + target, + lifetimeSeconds: PLAYER_MISSILE.lifetimeSeconds, + hitRadius: PLAYER_MISSILE.hitRadius + (target ? target.radius : ENEMY_BALANCE.fighterRadius), + turnResponse: PLAYER_MISSILE.turnResponse, + }); + probe.playerMissileShots += 1; + showMessage("FOX TWO", 1.2); + } + } + + function resolveHits(hits: ReturnType, shooterIsPlayer: boolean): void { + for (const hit of hits) { + const meta = hit.metadata as ProjectileMeta; + const missile = meta.weaponId === WEAPON_TYPES.MISSILE; + effects.emitHitBurst( + hit.position, + BASIS.upVector(), + missile ? 0xff6b2a : 0xffe08a, + missile ? 42 : 12, + missile ? 130 : 50, + 2, + 650, + ); + // Targets are always Aircraft — we own both target lists. + applyDamage(hit.hittedTarget as Aircraft, meta.damage, meta.sourceId); + if (shooterIsPlayer) probe.playerHitsLanded += 1; + } + } + + // -- fixed step ----------------------------------------------------------------------------- + function step(dt: number, input: GameInput): void { + clock.advanceMs(dt * 1000); + elapsedSeconds += dt; + + // Controls: nub/d-pad → bank+pitch (L converts lateral to rudder yaw), + // TRIANGLE/CIRCLE throttle, R boost, CROSS gun, SQUARE missile. + const b = input.buttons; + const yawMode = (b & BTN.LTRIGGER) !== 0; + const lateral = clamp( + (b & BTN.RIGHT ? 1 : 0) - (b & BTN.LEFT ? 1 : 0) + input.analogX, + -1, + 1, + ); + const pitchUp = clamp((b & BTN.UP ? 1 : 0) + Math.max(0, -input.analogY), 0, 1); + const pitchDown = clamp((b & BTN.DOWN ? 1 : 0) + Math.max(0, input.analogY), 0, 1); + const throttle = (b & BTN.TRIANGLE ? 1 : 0) - (b & BTN.CIRCLE ? 1 : 0); + const fireGun = (b & BTN.CROSS) !== 0; + const fireMissile = (b & BTN.SQUARE) !== 0; + + // -- player flight + if (!player.destroyed) { + player.motion.yaw += (yawMode ? -lateral : 0) * PLAYER_TUNING.yawRate * dt; + player.motion.planMovement({ + left: yawMode ? 0 : Math.max(0, -lateral), + right: yawMode ? 0 : Math.max(0, lateral), + up: pitchUp, + down: pitchDown, + throttle, + boost: (b & BTN.RTRIGGER) !== 0, + deltaSeconds: dt, + commit: true, + }); + } + + // -- waves + enemy brains + const wavePlan = waves.step({ activeUnits: liveEnemies().length }); + for (const spawn of wavePlan.spawns) { + spawnEnemy(spawn.type, spawn.waveNumber, spawn.spawnIndex); + } + const waveNumber = waves.snapshot().waveNumber; + if (waveNumber !== lastWaveNumber) { + lastWaveNumber = waveNumber; + showMessage(`WAVE ${waveNumber} INBOUND`, 2); + } + + for (const enemy of enemies) { + if (enemy.destroyed || player.destroyed) continue; + const aggression = enemy.ace ? ENEMY_BALANCE.aceAggression : ENEMY_BALANCE.fighterAggression; + const away = enemy.position.clone().sub(player.position); + const distanceToPlayer = away.length(); + const shouldBreakAway = distanceToPlayer < ENEMY_BALANCE.breakawayDistance; + const steeringTarget = + shouldBreakAway && distanceToPlayer > 1e-6 + ? enemy.position + .clone() + .addScaledVector(away.normalize(), ENEMY_BALANCE.breakawayLeadDistance) + : player.position; + const aimDot = steerEnemyToward(enemy, steeringTarget, dt, aggression); + enforcePlayerSpacing(enemy); + const terrainFloor = + terrainHeightAt( + BASIS.rightComponent(enemy.position), + BASIS.forwardComponent(enemy.position), + ) + ENEMY_BALANCE.terrainClearance; + if (BASIS.upComponent(enemy.position) < terrainFloor) { + BASIS.setHeight(enemy.position, terrainFloor); + enemy.motion.pitch = Math.max(enemy.motion.pitch, 0.28); + } + const shotDistance = enemy.position.distanceTo(player.position); + if (!shouldBreakAway) enemyShoot(enemy, aimDot, shotDistance, dt); + } + + // -- player fire control (lock steps while the missile is selected) + if (!player.destroyed) { + const frame = BASIS.yawPitchRollFrame( + player.motion.yaw, + player.motion.pitch, + player.motion.roll, + ); + weaponSystem.step({ + shooterPosition: player.position, + shooterBodyFrame: frame, + targets: liveEnemies(), + deltaSeconds: dt, + }); + if (fireGun) requestPlayerFire(WEAPON_TYPES.GUN); + if (fireMissile) requestPlayerFire(WEAPON_TYPES.MISSILE); + } + + // -- projectiles (per-team target lists) + resolveHits(playerShots.step(liveEnemies(), dt), true); + resolveHits(enemyShots.step(player.destroyed ? [] : [player], dt), false); + + // -- referees: combat death + terrain crash + for (const event of combat.step()) { + if (event.type === COMBAT_PLAY_EVENTS.PLAYER_KILLED && event.playerId === PLAYER_ID) { + if (!player.destroyed) destroyAircraft(player); + if (event.sourceId !== "terrain") showMessage("YOU WERE SHOT DOWN", 2.5); + } + } + if (!player.destroyed) { + flight.movePlayer(PLAYER_ID, vec(player.position)); + for (const event of flight.step()) { + void event; + probe.crashes += 1; + applyDamage(player, 1000, "terrain", true); + showMessage("TERRAIN IMPACT", 2.5); + } + } + + // -- presentation state (guest-side mirrors; render() flushes once a frame) + for (const actor of [player, ...enemies]) { + if (actor.destroyed) continue; + actor.model.step({ + position: actor.position, + yaw: actor.motion.yaw, + pitch: actor.motion.pitch, + roll: actor.motion.roll, + throttle: actor.motion.throttle, + isBoosting: actor.motion.isBoosting, + elapsedTimeSeconds: elapsedSeconds, + deltaSeconds: dt, + }); + } + effects.step(dt); + cameraRig.step({ + targetPosition: player.position, + targetFrame: BASIS.yawPitchRollFrame( + player.motion.yaw, + player.motion.pitch, + player.motion.roll, + ), + targetSpeed: player.motion.speed, + snapToTarget: probe.steps === 0, + deltaSeconds: dt, + camera: scene.camera, + }); + + damageFlash = Math.max(0, damageFlash - dt * 1.9); + if (messageTimer > 0) { + messageTimer -= dt; + if (messageTimer <= 0) message = ""; + } + + probe.steps += 1; + probe.waveNumber = waveNumber; + probe.playerHealth = player.health; + probe.score = score; + probe.lockStatus = weaponSystem.lockStatus; + probe.playerPosition = vec(player.position); + } + + // -- HUD snapshot ------------------------------------------------------------------------------ + function hudState(): DogfightHudState { + const altitude = BASIS.upComponent(player.position); + const ground = terrainHeightAt( + BASIS.rightComponent(player.position), + BASIS.forwardComponent(player.position), + ); + const agl = altitude - ground; + const missiles = weaponSystem.weapons.get(WEAPON_TYPES.MISSILE)?.ammo ?? 0; + const frame = BASIS.yawPitchRollFrame( + player.motion.yaw, + player.motion.pitch, + player.motion.roll, + ); + return { + speed: Math.round(player.motion.speed), + altitude: Math.max(0, Math.round(altitude)), + agl: Math.max(0, Math.round(agl)), + health: Math.max(0, Math.round(player.health)), + score, + waveNumber: waves.snapshot().waveNumber, + banditsAlive: liveEnemies().length, + weaponLabel: + weaponSystem.selectedWeaponId === WEAPON_TYPES.MISSILE + ? `MSL ${String(missiles).padStart(2, "0")}` + : "GUN", + lockStatus: weaponSystem.lockStatus, + missiles, + gunHeat: weaponSystem.gunHeat, + throttle: player.motion.throttle, + headingDeg: ((-toDeg(player.motion.yaw)) % 360 + 360) % 360, + pitchDeg: toDeg(player.motion.pitch), + rollDeg: toDeg(player.motion.roll), + pullUp: !player.destroyed && agl < PULL_UP_AGL, + failed: player.destroyed, + damageFlash, + message, + playerPosition: vec(player.position), + playerForward: vec(frame.forward), + contacts: liveEnemies().map( + (enemy): RadarContactState => ({ + position: vec(enemy.position), + color: 0xff4f42, + size: 5, + }), + ), + }; + } + + return { scene, step, hudState }; +} diff --git a/demos/dogfight/main.tsx b/demos/dogfight/main.tsx new file mode 100644 index 0000000..530a933 --- /dev/null +++ b/demos/dogfight/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: Dogfight +import Dogfight from "./app.tsx"; +import { mount } from "@pocketjs/framework"; + +mount(() => ); diff --git a/demos/dogfight/screenshot-f10.png b/demos/dogfight/screenshot-f10.png new file mode 100644 index 0000000..a9f6cab Binary files /dev/null and b/demos/dogfight/screenshot-f10.png differ diff --git a/demos/dogfight/screenshot-f300-combat.png b/demos/dogfight/screenshot-f300-combat.png new file mode 100644 index 0000000..f444185 Binary files /dev/null and b/demos/dogfight/screenshot-f300-combat.png differ diff --git a/demos/rally/app.tsx b/demos/rally/app.tsx new file mode 100644 index 0000000..028d232 --- /dev/null +++ b/demos/rally/app.tsx @@ -0,0 +1,88 @@ +// demos/rally/app.tsx — "Pocket Rally": the playset end-to-end demo app. +// +// The pure sim lives in game.ts; this component wires it to the runtime: +// createGameLoop steps the game at a fixed 1/60 s on the virtual clock +// (hz-invariant, DETERMINISM.md), the render callback flushes the Scene3D +// and refreshes one HUD signal, and the HUD (lap counter, standings, +// RaceMinimap) composes as ordinary flex children over the . +// On hosts without a 3D core the viewport is an empty box and the HUD — +// driven by the same deterministic sim — still renders. + +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { createGameLoop } from "../../playset/loop.ts"; +import { Viewport3D } from "../../playset/scene3d/viewport.ts"; +import { RaceMinimap } from "../../playset/modules/user-interface/race-minimap.ts"; +import { createRallyGame, LAP_COUNT, RIVAL_ID, TRACK_BOUNDS } from "./game.ts"; + +const INK = "#e8f0f2"; +const DIM = "#8fa3ad"; +const LIME = "#b8f34a"; +const AMBER = "#fbbf24"; + +export default function Rally() { + const game = createRallyGame(); + const [hud, setHud] = createSignal(game.hudState()); + + createGameLoop({ + step: (dt, input) => game.step(dt, input), + render: () => { + game.scene.flush(); + setHud(game.hudState()); + }, + }); + + const statusLine = () => + hud().raceState === "FINISHED" + ? "RACE COMPLETE" + : `GATES ${hud().checkpointsPassed} · × GAS · ▢ BRAKE`; + + // No bgColor on the viewport: on native hosts the 3D scene composites + // UNDER the ui layer, so the viewport (and its ancestors) stay unpainted. + return ( + + + {/* Top bar: lap + status left, standings right */} + + + + {`LAP ${hud().lap}/${LAP_COUNT}`} + + + {statusLine()} + + + + + {`P1 ${hud().standings[0]}`} + + + {`P2 ${hud().standings[1]}`} + + + + + {/* Bottom bar: speed left, minimap right */} + + + {`${hud().speed} M/S`} + + game.checkpoints} + localProgress={() => ({ nextCheckpointIndex: hud().nextCheckpointIndex })} + localVehicle={() => ({ + position: hud().playerPosition, + bodyFrame: { forward: hud().playerForward }, + })} + aiCars={() => [{ id: RIVAL_ID, position: hud().rivalPosition, color: "#5ac8fa" }]} + aiLeaderId={() => hud().leaderId} + /> + + + + ); +} diff --git a/demos/rally/game.ts b/demos/rally/game.ts new file mode 100644 index 0000000..d9c4406 --- /dev/null +++ b/demos/rally/game.ts @@ -0,0 +1,387 @@ +// demos/rally/game.ts — the pure rally sim: a closed checkpoint circuit +// composed entirely from playset modules. +// +// Everything here is deterministic fixed-step state (DETERMINISM.md): the +// only inputs are the per-step button mask and FIXED_DT — no wall clock, no +// Math.random (one seeded RandomGenerator drives the environment's prop +// placement). The composition under test: +// +// RoadTerrainSampler + RaceTrackEnvironment road-flattened terrain, gates, +// barrier fences → CollisionWorld +// ArcadeCarMotionController ×2 player (buttons) + AI rival +// KinematicBatchResolver both cars vs barriers/props and +// each other (planar push-out) +// WaypointProgressTracker → AgentPathNavigator → WaypointDriver +// the rival's driving brain +// RaceCheckpointLapPlay 2-lap race state + standings +// CarVisualFactory + CarModelController chassis pose, wheel spin/steer +// PoseFollowCameraRig chase camera on the player +// +// A tiny debug probe rides on globalThis.__rallyProbe so the headless E2E +// (playset/test/rally-sim.test.ts) can assert progress without scraping HUD +// pixels. + +import { BTN } from "@pocketjs/framework/input"; +import { Euler, Vector3 } from "../../playset/math/index.ts"; +import { Scene3D } from "../../playset/scene3d/client.ts"; +import type { GameInput } from "../../playset/loop.ts"; +import { RandomGenerator } from "../../playset/modules/math/random-utils.ts"; +import { CollisionWorld } from "../../playset/modules/physics/collision-world.ts"; +import { rgbToAbgr } from "../../playset/modules/world/color-utils.ts"; +import type { PlanarPoint } from "../../playset/modules/world/environment/planar-utils.ts"; +import { RaceTrackEnvironment } from "../../playset/modules/world/environment/race-track-environment.ts"; +import { + createCarVisual, + type CarVisual, +} from "../../playset/modules/world/object/factory/car-visual-factory.ts"; +import { + ArcadeCarMotionController, + type ArcadeCarCommitResult, + type ArcadeCarIntent, +} from "../../playset/modules/actor-motion/ground-vehicle/arcade-car-motion-controller.ts"; +import { CarModelController } from "../../playset/modules/actor-motion/ground-vehicle/car-model-controller.ts"; +import { + KinematicBatchResolver, + type KinematicActor, +} from "../../playset/modules/actor-motion/kinematic-batch-resolver.ts"; +import { WaypointProgressTracker } from "../../playset/modules/behavior/waypoint-progress-tracker.ts"; +import { WaypointDriver } from "../../playset/modules/behavior/waypoint-driver.ts"; +import { AgentPathNavigator } from "../../playset/modules/behavior/agent-path-navigator.ts"; +import { + RACE_CHECKPOINT_LAP_EVENTS, + RACE_STATES, + RaceCheckpointLapPlay, +} from "../../playset/modules/gameplay/race-checkpoint-lap-play.ts"; +import { PoseFollowCameraRig } from "../../playset/modules/camera/pose-follow-camera-rig.ts"; + +export const LAP_COUNT = 2; +export const PLAYER_ID = "player"; +export const RIVAL_ID = "rival"; + +/** A rounded 10-point circuit (planar right/forward, ~100×110 units). */ +export const TRACK_POINTS: readonly PlanarPoint[] = [ + { right: -30, forward: -46 }, + { right: 0, forward: -54 }, + { right: 30, forward: -46 }, + { right: 46, forward: -18 }, + { right: 46, forward: 18 }, + { right: 30, forward: 46 }, + { right: 0, forward: 54 }, + { right: -30, forward: 46 }, + { right: -46, forward: 18 }, + { right: -46, forward: -18 }, +]; + +/** Planar bounds for the HUD minimap (track extent + barrier margin). */ +export const TRACK_BOUNDS = Object.freeze({ + minRight: -62, + maxRight: 62, + minForward: -62, + maxForward: 62, +}); + +const CAR_RIDE_HEIGHT = 0.38; // ArcadeCarMotionController default rideHeight + +/** GameBlocks' defaults are scaled for ~500-unit tracks; this circuit is + * ~100 units across, so both cars run a gentler, more agile tune (top speed + * throttleAccel/engineBrake ≈ 14.5 u/s, short wheelbase). */ +const CAR_TUNING = { + maxForwardSpeed: 22, + maxReverseSpeed: 10, + throttleAccel: 16, + reverseAccel: 10, + engineBrake: 1.1, + steerAngleMax: 0.6, + wheelBase: 2.6, + rideHeight: CAR_RIDE_HEIGHT, +} as const; + +export interface RallyProbe { + /** scene3d handle of the game's one scene (0 in pure-mirror mode). */ + sceneId: number; + /** Chassis group node ids — the test reads their serialized poses. */ + playerNodeId: number; + rivalNodeId: number; + steps: number; + /** Total checkpoint.passed events across both cars. */ + checkpointsPassed: number; + playerCheckpoints: number; + playerLaps: number; + raceState: string; + playerPosition: { x: number; y: number; z: number }; +} + +export interface RallyHudState { + raceState: string; + /** Player display lap (1-based, clamped to LAP_COUNT). */ + lap: number; + /** Player tangent speed, rounded for stable HUD text. */ + speed: number; + /** Standings labels, leader first. */ + standings: string[]; + nextCheckpointIndex: number; + playerPosition: { x: number; y: number; z: number }; + playerForward: { x: number; y: number; z: number }; + rivalPosition: { x: number; y: number; z: number }; + /** RIVAL_ID when the rival leads (minimap leader ring), else null. */ + leaderId: string | null; + checkpointsPassed: number; +} + +export interface RallyGame { + scene: Scene3D; + /** Static checkpoint centers for the minimap. */ + checkpoints: { x: number; y: number; z: number }[]; + /** One fixed 1/60 s simulation step (createGameLoop's `step`). */ + step(dt: number, input: GameInput): void; + /** Fresh HUD snapshot (call from the loop's `render`). */ + hudState(): RallyHudState; +} + +interface RallyCar { + motion: ArcadeCarMotionController; + actor: KinematicActor; + visual: CarVisual; + model: CarModelController; + /** Euler mirrors the CarModelController writes; step() folds them into + * the SceneNode quaternions (scene3d nodes carry no Euler rotation). */ + wheelMirrors: { rotation: { x: number; y: number } }[]; + pivotMirrors: { rotation: { x: number; y: number } }[]; + speed: number; +} + +function vec(v: Vector3): { x: number; y: number; z: number } { + return { x: v.x, y: v.y, z: v.z }; +} + +export function createRallyGame(): RallyGame { + const scene = new Scene3D(); + const world = new CollisionWorld(); + + // -- world: road-flattened terrain, gates, barriers, props ------------------ + const env = new RaceTrackEnvironment({ + scene, + trackPlanarPoints: [...TRACK_POINTS], + naturalEnvironmentConfig: { + terrainSize: 150, + terrainSegments: 48, + treeCount: 18, + rockCount: 6, + grassBladeCount: 24, + renderOrder: 0, + prng: new RandomGenerator(42), + }, + }).create(); + env.createColliders(world); + + scene.sun(new Vector3(-0.45, -1, -0.35), rgbToAbgr(0xfff1d6)); + scene.ambient(rgbToAbgr(0x9db8d6), rgbToAbgr(0x55624a)); + scene.sky(rgbToAbgr(0x6fa8e4), rgbToAbgr(0xd9e8f2)); + scene.fog(rgbToAbgr(0xcfe0ee), 70, 190); + scene.camera.zfar = 400; + + // -- cars -------------------------------------------------------------------- + const resolver = new KinematicBatchResolver(world); + + function buildCar(paint: number, cabin: number, spawnDistance: number, lateral: number): RallyCar { + const spawn = env.spawnPose(0, true, spawnDistance, lateral, CAR_RIDE_HEIGHT); + const visual = createCarVisual(scene, { paintColor: paint, cabinColor: cabin }); + const motion = new ArcadeCarMotionController({ ...CAR_TUNING }); + motion.reset(spawn.position, spawn.yaw); + visual.group.position.copy(motion.position); + const wheelMirrors = visual.wheels.map(() => ({ rotation: { x: 0, y: 0 } })); + const pivotMirrors = visual.wheelPivots.map(() => ({ rotation: { x: 0, y: 0 } })); + return { + motion, + visual, + model: new CarModelController({ + vehicleModel: visual.group, + wheels: wheelMirrors, + wheelPivots: pivotMirrors, + wheelRadius: 0.35, + }), + actor: resolver.createActor({ + position: spawn.position, + colliderShape: { type: "cuboid", halfX: 0.9, halfY: CAR_RIDE_HEIGHT, halfZ: 1.5 }, + }), + wheelMirrors, + pivotMirrors, + speed: 0, + }; + } + + const player = buildCar(0xc75238, 0xf4f7ff, 12, 2.4); + const rival = buildCar(0x3878c7, 0xe8f0ff, 17, -2.4); + + // -- race state ---------------------------------------------------------------- + const race = new RaceCheckpointLapPlay({ + checkpoints: env.checkpoints.map((c) => ({ + id: c.id, + position: vec(c.position), + radius: c.radius, + })), + lapCount: LAP_COUNT, + }); + race.addPlayer({ playerId: PLAYER_ID, position: vec(player.motion.position) }); + race.addPlayer({ playerId: RIVAL_ID, position: vec(rival.motion.position) }); + race.startGame(); + + // -- the rival's driving brain --------------------------------------------------- + const tracker = new WaypointProgressTracker({ + waypoints: env.checkpoints.map((c) => c.position), + reachDistance: 6, + closed: true, + }); + tracker.reset(0); + const driver = new WaypointDriver({ targetSpeed: 14, minSpeed: 5, cornerSlowdown: 8 }); + const navigator = new AgentPathNavigator({ maxSpeed: 14, arriveRadius: 10 }); + + // -- chase camera ------------------------------------------------------------------- + const cameraRig = new PoseFollowCameraRig({ + cameraOffset: { forward: -7.5, up: 3.4, right: 0 }, + lookAtOffset: { forward: 5, up: 1.1, right: 0 }, + speedCameraOffset: { forward: -0.03, up: 0.01, right: 0 }, + positionLag: 0.16, + lookLag: 0.1, + }); + + // -- probe ------------------------------------------------------------------------------ + const probe: RallyProbe = { + sceneId: scene.__scene, + playerNodeId: player.visual.group.__id, + rivalNodeId: rival.visual.group.__id, + steps: 0, + checkpointsPassed: 0, + playerCheckpoints: 0, + playerLaps: 0, + raceState: race.raceState, + playerPosition: vec(player.motion.position), + }; + (globalThis as Record).__rallyProbe = probe; + + // -- fixed step ------------------------------------------------------------------------------ + const eulerScratch = new Euler(); + + function syncCarVisual(car: RallyCar, res: ArcadeCarCommitResult, dt: number): void { + car.model.step({ + position: res.position, + bodyFrame: res.bodyFrame, + velocity: res.velocity, + steeringAngle: res.steeringAngle, + deltaSeconds: dt, + }); + for (let i = 0; i < car.visual.wheels.length; i += 1) { + const wheel = car.wheelMirrors[i].rotation; + car.visual.wheels[i].quaternion.setFromEuler(eulerScratch.set(wheel.x, wheel.y, 0)); + car.visual.wheelPivots[i].quaternion.setFromEuler( + eulerScratch.set(0, car.pivotMirrors[i].rotation.y, 0), + ); + } + } + + function step(dt: number, input: GameInput): void { + // Player intent straight from the button mask. + const b = input.buttons; + const playerIntent = player.motion.planMovement({ + left: b & BTN.LEFT ? 1 : 0, + right: b & BTN.RIGHT ? 1 : 0, + throttle: b & BTN.CROSS ? 1 : 0, + reverse: b & BTN.SQUARE ? 1 : 0, + deltaSeconds: dt, + terrain: env.terrainSampler, + }) as ArcadeCarIntent; + + // Rival intent: waypoint progress → arrival slowdown → driver controls. + const progress = tracker.step(rival.motion.position); + const waypoint = progress ? progress.currentWaypoint : null; + const nav = navigator.step({ position: rival.motion.position, waypoint }); + const controls = driver.step({ + position: rival.motion.position, + yaw: rival.motion.yaw, + speed: rival.speed, + waypoint, + cornerMagnitude: progress ? progress.cornerMagnitude : 0, + raceStarted: race.raceState === RACE_STATES.STARTED, + deltaSeconds: dt, + }); + const easeOff = navigator.maxSpeed > 0 ? nav.desiredSpeed / navigator.maxSpeed : 1; + const rivalIntent = rival.motion.planMovement({ + left: controls.left ? 1 : 0, + right: controls.right ? 1 : 0, + throttle: controls.throttle ? Math.max(0.4, easeOff) : 0, + reverse: controls.reverse ? 1 : controls.brake ? 0.55 : 0, + boost: controls.boost, + deltaSeconds: dt, + terrain: env.terrainSampler, + }) as ArcadeCarIntent; + + // Both cars resolve against the barriers/props and each other. + resolver.beginFrame(); + resolver.queueMove(player.actor, playerIntent); + resolver.queueMove(rival.actor, rivalIntent); + const results = resolver.resolveQueuedMoves(dt); + const playerRes = player.motion.commitMovement( + playerIntent, + results.get(player.actor)!, + env.terrainSampler, + ); + const rivalRes = rival.motion.commitMovement( + rivalIntent, + results.get(rival.actor)!, + env.terrainSampler, + ); + player.speed = playerRes.speed; + rival.speed = rivalRes.speed; + + // Race progress + events. + race.updatePlayer(PLAYER_ID, vec(playerRes.position)); + race.updatePlayer(RIVAL_ID, vec(rivalRes.position)); + for (const ev of race.step(dt)) { + if (ev.type === RACE_CHECKPOINT_LAP_EVENTS.CHECKPOINT_PASSED) { + probe.checkpointsPassed += 1; + if (ev.playerId === PLAYER_ID) probe.playerCheckpoints += 1; + } else if (ev.type === RACE_CHECKPOINT_LAP_EVENTS.LAP_COMPLETED && ev.playerId === PLAYER_ID) { + probe.playerLaps = ev.lap; + } + } + + // Presentation state (guest-side mirrors; render() flushes once a frame). + syncCarVisual(player, playerRes, dt); + syncCarVisual(rival, rivalRes, dt); + cameraRig.step({ + targetPosition: playerRes.position, + targetFrame: playerRes.bodyFrame, + targetSpeed: playerRes.speed, + deltaSeconds: dt, + camera: scene.camera, + }); + + probe.steps += 1; + probe.raceState = race.raceState; + probe.playerPosition = vec(playerRes.position); + } + + function hudState(): RallyHudState { + const standings = race.getStandings(); + const playerState = race.getPlayer(PLAYER_ID); + return { + raceState: race.raceState, + lap: Math.min(playerState.completedLaps + 1, LAP_COUNT), + speed: Math.round(player.speed), + standings: standings.map((s) => (s.playerId === PLAYER_ID ? "YOU" : "RIVAL")), + nextCheckpointIndex: playerState.nextCheckpointIndex, + playerPosition: vec(player.motion.position), + playerForward: vec(player.motion.bodyFrame.forward), + rivalPosition: vec(rival.motion.position), + leaderId: standings.length > 0 && standings[0].playerId === RIVAL_ID ? RIVAL_ID : null, + checkpointsPassed: probe.checkpointsPassed, + }; + } + + return { + scene, + checkpoints: env.checkpoints.map((c) => vec(c.position)), + step, + hudState, + }; +} diff --git a/demos/rally/main.tsx b/demos/rally/main.tsx new file mode 100644 index 0000000..986cea5 --- /dev/null +++ b/demos/rally/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: Rally +import Rally from "./app.tsx"; +import { mount } from "@pocketjs/framework"; + +mount(() => ); diff --git a/demos/rally/screenshot-f10.png b/demos/rally/screenshot-f10.png new file mode 100644 index 0000000..e641830 Binary files /dev/null and b/demos/rally/screenshot-f10.png differ diff --git a/demos/rally/screenshot-f240.png b/demos/rally/screenshot-f240.png new file mode 100644 index 0000000..1134e00 Binary files /dev/null and b/demos/rally/screenshot-f240.png differ diff --git a/demos/runner/app.tsx b/demos/runner/app.tsx new file mode 100644 index 0000000..61872a2 --- /dev/null +++ b/demos/runner/app.tsx @@ -0,0 +1,97 @@ +// demos/runner/app.tsx — "Pocket Runner": the 3-lane endless runner app. +// +// The pure sim lives in game.ts; this component wires it to the runtime: +// createGameLoop steps the game at a fixed 1/60 s on the virtual clock +// (hz-invariant, DETERMINISM.md), the render callback flushes the Scene3D +// and refreshes one HUD signal, and the HUD (score/coins/distance, boost +// pill, start / game-over cards) composes as flex children over the +// . On hosts without a 3D core the viewport is an empty box and +// the HUD — driven by the same deterministic sim — still renders. + +import { createSignal, Show } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { createGameLoop } from "../../playset/loop.ts"; +import { Viewport3D } from "../../playset/scene3d/viewport.ts"; +import { createRunnerGame } from "./game.ts"; + +const INK = "#10243d"; +const DIM = "#4a6076"; +const GOLD = "#b8860b"; +const RED = "#d92c3a"; +const CARD = "#f4fbffee"; + +export default function Runner() { + const game = createRunnerGame(); + const [hud, setHud] = createSignal(game.hudState()); + + createGameLoop({ + step: (dt, input) => game.step(dt, input), + render: () => { + game.scene.flush(); + setHud(game.hudState()); + }, + }); + + // No bgColor on the viewport: on native hosts the 3D scene composites + // UNDER the ui layer, so the viewport (and its ancestors) stay unpainted. + return ( + + + {/* Top bar: score + coins left, distance + boost right */} + + + + {`SCORE ${hud().score}`} + + + {`● ${hud().coins} COINS`} + + + + + {`${hud().distance} M`} + + + + BOOST! + + + + + + {/* Center card: start / game-over flow */} + + + + + {hud().status === "gameOver" ? "GAME OVER" : "POCKET RUNNER"} + + + + {`SCORE ${hud().finalScore}`} + + + + {hud().status === "gameOver" ? "PRESS × TO RUN AGAIN" : "PRESS × TO START"} + + + + + + {/* Bottom hint (light pills so the text reads over the dark road) */} + + + + ◀ ▶ LANES · × JUMP · ▼ SLIDE + + + + + {`${hud().speed} M/S`} + + + + + + ); +} diff --git a/demos/runner/game.ts b/demos/runner/game.ts new file mode 100644 index 0000000..1a0e607 --- /dev/null +++ b/demos/runner/game.ts @@ -0,0 +1,975 @@ +// demos/runner/game.ts — "Pocket Runner": a 3-lane winter endless runner, +// composed from playset modules over the scene3d surface. +// +// Behavior reference: the GameBlocks endless-runner demo at +// https://gb-endless-runner.vercel.app/ (src/main.js and its imports). +// LICENSING NOTE: the GameBlocks LIBRARY modules are MIT and are used here +// through their playset ports; the demo's game-specific glue carries NO +// license, so it was treated as a DESIGN REFERENCE ONLY — gameplay constants +// (lane offsets, speeds, spawn cadences, scoring rules, entity dimensions, +// colors, camera framing) were extracted as behavioral facts, and this file's +// code was written fresh against the playset vocabulary. No code structure, +// comments, or statements were copied from the reference. +// +// Everything here is deterministic fixed-step state (DETERMINISM.md): the +// only inputs are the per-step button mask and FIXED_DT — no wall clock, no +// Math.random (one seeded RandomGenerator drives every spawn decision, and a +// second fixed-seed stream places the snowfall). The composition: +// +// direct lane-lerp glue the reference's observed movement model is a +// damped lane slide + ballistic jump — a full +// character controller would change the feel, +// so the glue integrates it directly +// fixed entity pools obstacles / coins / boosts / trees stream +// toward the player with zero per-frame +// allocation (free-list reuse, spawn skipped +// if a pool is momentarily exhausted) +// AABB collision in glue the reference used plain bounds intersects +// scene.spritePool 560-flake snowfall billboard pool +// PositionFollowCameraRig fixed-azimuth chase camera on the player +// createBlobShadow contact shadow (scene3d has no shadow maps) +// +// A debug probe rides on globalThis.__runnerProbe so the headless E2E +// (playset/test/runner-sim.test.ts) can assert score/collision/restart +// progress without scraping HUD pixels. + +import { BTN } from "@pocketjs/framework/input"; +import { Euler, Vector3 } from "../../playset/math/index.ts"; +import { MAT, Scene3D, type SceneNode, type SpritePool } from "../../playset/scene3d/client.ts"; +import type { GameInput } from "../../playset/loop.ts"; +import { RandomGenerator } from "../../playset/modules/math/random-utils.ts"; +import { clamp, smoothingAlpha } from "../../playset/modules/math/scalar-utils.ts"; +import { rgbToAbgr } from "../../playset/modules/world/color-utils.ts"; +import { createBlobShadow, updateBlobShadow } from "../../playset/modules/world/blob-shadow.ts"; +import { PositionFollowCameraRig } from "../../playset/modules/camera/position-follow-camera-rig.ts"; + +// --------------------------------------------------------------------------- +// Behavior spec (facts extracted from the reference demo) +// --------------------------------------------------------------------------- + +/** Lane center offsets, left to right (world +X = right). */ +export const LANES: readonly number[] = [-3.2, 0, 3.2]; +export const TRACK_HALF_WIDTH = 5.6; + +const PLAYER_WIDTH = 1.05; +const PLAYER_BASE_HEIGHT = 1.7; +const PLAYER_SLIDE_HEIGHT = 0.9; +const PLAYER_DEPTH = 0.9; + +const JUMP_VELOCITY = 11.5; +const GRAVITY = 28; +const SLIDE_DURATION = 0.72; +const SLIDE_SQUASH = 0.55; +/** Exponential lane-slide rate (three MathUtils.damp lambda) + snap window. */ +const LANE_DAMP_LAMBDA = 18; +const LANE_SNAP_EPSILON = 0.03; +/** Run-cycle clock rate and grounded bob amplitude. */ +const BOB_RATE = 12; +const BOB_AMPLITUDE = 0.035; + +const BASE_SPEED = 11; +const SPEED_RAMP_PER_SECOND = 0.19; +const BOOST_MULTIPLIER = 1.45; +const BOOST_DURATION = 3.5; + +const SCORE_PER_UNIT = 8; +const COIN_SCORE = 75; +const BOOST_SCORE = 250; + +/** Obstacle sets keep spawning to +145 ahead; pruning trails 18 behind. */ +const SPAWN_AHEAD = 145; +const SCENERY_AHEAD = 160; +const PRUNE_BEHIND = 18; +const FIRST_SPAWN_FORWARD = 26; +const FIRST_SCENERY_FORWARD = 10; +const SPAWN_GAP_MIN = 7.5; +const SPAWN_GAP_MAX = 13.5; +const SCENERY_GAP_MIN = 7; +const SCENERY_GAP_MAX = 13; +/** Above this speed a set may block a second lane (p = 0.52). */ +const SECOND_OBSTACLE_SPEED = 15; + +const WORLD_SEED = 20260617; +const SNOW_SEED = 777; + +export type ObstacleKind = "block" | "barrier" | "lowBeam"; + +interface ObstacleSpec { + /** Full AABB extents (right × up × forward) and base height above ground. */ + width: number; + height: number; + depth: number; + base: number; +} + +const OBSTACLE_SPECS: Record = { + block: { width: 1.35, height: 1.55, depth: 1.1, base: 0 }, + barrier: { width: 1.55, height: 0.85, depth: 1.0, base: 0 }, + lowBeam: { width: 1.75, height: 0.55, depth: 1.05, base: 1.18 }, +}; + +const COLLECTIBLE_SIZE = 0.9; +const COIN_BASE = 0.75; +const BOOST_BASE = 0.72; + +const SNOWFLAKE_COUNT = 560; + +// Palette (reference colors, 0xRRGGBB). +const C = { + sky: 0xdff6ff, + snow: 0xf4fbff, + snowBlue: 0xd7effa, + road: 0x26384e, + roadPanel: 0x3c5369, + laneWhite: 0xffffff, + laneGlow: 0xfff3a6, + shoulderRed: 0xd92c3a, + rail: 0xb9f4ff, + suit: 0xd92c3a, + suitDark: 0x0f7f5f, + skin: 0xffcf8a, + visor: 0x10243d, + trim: 0xf6f7eb, + gold: 0xffd166, + coinGold: 0xffd447, + shadowDark: 0x121a2a, + beamGreen: 0x138a66, + trunk: 0x7f5539, + treeA: 0x0d6b49, + treeB: 0x11835f, + treeC: 0x1f9f6a, +} as const; + +// Pool capacities: the live window is (SPAWN_AHEAD + PRUNE_BEHIND) = 163 +// units at a mean set spacing of 10.5 — comfortably inside these caps; if a +// worst-case RNG streak ever exhausts a pool the spawn is skipped (still +// deterministic: same seed, same skips). +const POOL_BLOCK = 32; +const POOL_BARRIER = 32; +const POOL_LOWBEAM = 20; +const POOL_COIN = 80; +const POOL_BOOST = 12; +const POOL_TREE = 32; + +// --------------------------------------------------------------------------- +// Public shapes +// --------------------------------------------------------------------------- + +export type RunnerStatus = "ready" | "running" | "gameOver"; + +export interface RunnerProbe { + /** scene3d handle of the game's one scene (0 in pure-mirror mode). */ + sceneId: number; + /** Player rig root node id — the test reads its serialized pose. */ + playerNodeId: number; + steps: number; + status: RunnerStatus; + score: number; + coins: number; + boosts: number; + /** Total forward distance of the CURRENT run (units). */ + distance: number; + laneIndex: number; + /** Obstacle hits across all runs this boot. */ + collisions: number; + /** Completed runs (each collision ends one). */ + gameOvers: number; + /** Runs started (1 after the first START, 2 after one restart, ...). */ + runsStarted: number; + playerPosition: { x: number; y: number; z: number }; + /** Nearest active obstacles ahead (sorted by forward), for test scripting. */ + upcoming: { kind: ObstacleKind; laneIndex: number; forward: number }[]; +} + +export interface RunnerHudState { + status: RunnerStatus; + score: number; + coins: number; + distance: number; + speed: number; + boostActive: boolean; + /** Score of the run that just ended (game-over card). */ + finalScore: number; +} + +export interface RunnerGame { + scene: Scene3D; + /** One fixed 1/60 s simulation step (createGameLoop's `step`). */ + step(dt: number, input: GameInput): void; + /** Fresh HUD snapshot (call from the loop's `render`). */ + hudState(): RunnerHudState; +} + +// --------------------------------------------------------------------------- +// Internal state shapes +// --------------------------------------------------------------------------- + +interface ObstacleSlot { + active: boolean; + kind: ObstacleKind; + laneIndex: number; + forward: number; + group: SceneNode; +} + +interface CollectibleSlot { + active: boolean; + /** Still collectible (false once picked up; kept until pruned). */ + live: boolean; + boost: boolean; + laneIndex: number; + forward: number; + phase: number; + group: SceneNode; +} + +interface TreeSlot { + active: boolean; + forward: number; + group: SceneNode; +} + +interface PlayerState { + laneIndex: number; + targetLaneIndex: number; + laneRight: number; + forward: number; + height: number; + verticalVelocity: number; + grounded: boolean; + slideTimer: number; + bobTime: number; +} + +interface AabbLike { + laneRight: number; + base: number; + height: number; + width: number; + depth: number; + forward: number; +} + +/** Plain AABB overlap in (right, up, forward) space — the glue's collision. */ +function aabbOverlap(a: AabbLike, b: AabbLike): boolean { + return ( + Math.abs(a.laneRight - b.laneRight) * 2 <= a.width + b.width && + a.base <= b.base + b.height && + b.base <= a.base + a.height && + Math.abs(a.forward - b.forward) * 2 <= a.depth + b.depth + ); +} + +function vec(v: Vector3): { x: number; y: number; z: number } { + return { x: v.x, y: v.y, z: v.z }; +} + +// --------------------------------------------------------------------------- +// The game +// --------------------------------------------------------------------------- + +export function createRunnerGame(): RunnerGame { + const scene = new Scene3D(); + const rng = new RandomGenerator(WORLD_SEED); + + // -- environment: sky, fog, lights ---------------------------------------- + scene.sky(rgbToAbgr(C.sky), rgbToAbgr(0xf6feff)); + scene.fog(rgbToAbgr(C.sky), 48, 185); + scene.sun(new Vector3(0.42, -0.72, -0.54), rgbToAbgr(0xfff7df)); + scene.ambient(rgbToAbgr(0xffffff), rgbToAbgr(0x8eb7c7)); + scene.camera.fovY = (62 * Math.PI) / 180; + scene.camera.znear = 0.1; + scene.camera.zfar = 420; + + // -- materials -------------------------------------------------------------- + const m = { + snow: scene.material(rgbToAbgr(C.snow), 0), + snowBlue: scene.material(rgbToAbgr(C.snowBlue), 0), + road: scene.material(rgbToAbgr(C.road), 0), + roadPanel: scene.material(rgbToAbgr(C.roadPanel), 0), + laneWhite: scene.material(rgbToAbgr(C.laneWhite), 0), + laneGlow: scene.material(rgbToAbgr(C.laneGlow), MAT.unlit), + shoulderRed: scene.material(rgbToAbgr(C.shoulderRed), 0), + rail: scene.material(rgbToAbgr(C.rail), MAT.unlit), + suit: scene.material(rgbToAbgr(C.suit), 0), + suitDark: scene.material(rgbToAbgr(C.suitDark), 0), + skin: scene.material(rgbToAbgr(C.skin), 0), + visor: scene.material(rgbToAbgr(C.visor), MAT.unlit), + trim: scene.material(rgbToAbgr(C.trim), 0), + gold: scene.material(rgbToAbgr(C.gold), 0), + goldGlow: scene.material(rgbToAbgr(C.gold), MAT.unlit), + coinGold: scene.material(rgbToAbgr(C.coinGold), 0), + shadowDark: scene.material(rgbToAbgr(C.shadowDark), 0), + beamGreen: scene.material(rgbToAbgr(C.beamGreen), 0), + trunk: scene.material(rgbToAbgr(C.trunk), 0), + crowns: [ + scene.material(rgbToAbgr(C.treeA), 0), + scene.material(rgbToAbgr(C.treeB), 0), + scene.material(rgbToAbgr(C.treeC), 0), + ], + }; + + // -- static track (one long strip; a run outlasting it has earned it) ------- + const TRACK_LENGTH = 12000; + const trackMidZ = -(TRACK_LENGTH / 2 - 60); + + function longBox(width: number, thick: number, matId: number, x: number, yTop: number): void { + const node = scene.mesh(scene.box(width / 2, thick / 2, TRACK_LENGTH / 2), matId); + node.position.set(x, yTop - thick / 2, trackMidZ); + } + + longBox(160, 0.1, m.snow, 0, -0.03); // snowfield + longBox(TRACK_HALF_WIDTH * 2, 0.12, m.road, 0, 0.0); // roadbed + for (const side of [-1, 1]) { + longBox(0.5, 0.05, m.shoulderRed, side * (TRACK_HALF_WIDTH + 0.42), 0.04); // shoulders + longBox(1.05, 0.34, m.snowBlue, side * (TRACK_HALF_WIDTH + 1.12), 0.26); // snow banks + longBox(1.18, 0.12, m.snow, side * (TRACK_HALF_WIDTH + 1.12), 0.37); // bank caps + longBox(0.22, 0.42, m.rail, side * TRACK_HALF_WIDTH, 0.42); // guard rails + longBox(0.34, 0.08, m.laneGlow, side * TRACK_HALF_WIDTH, 0.5); // rail caps + longBox(0.08, 0.035, m.laneWhite, side * 1.6, 0.043); // lane lines + } + + // -- recycled near-field track dressing (windowed, wraps with the player) --- + interface Recycler { + nodes: SceneNode[]; + spacing: number; + place(node: SceneNode, forward: number, index: number): void; + } + const recyclers: Recycler[] = []; + + function addRecycler( + count: number, + spacing: number, + make: () => SceneNode, + place: (node: SceneNode, forward: number, index: number) => void, + ): void { + const nodes: SceneNode[] = []; + for (let i = 0; i < count; i += 1) nodes.push(make()); + recyclers.push({ nodes, spacing, place }); + } + + function stepRecyclers(playerForward: number): void { + for (const r of recyclers) { + const span = r.nodes.length * r.spacing; + // Window [playerForward - 20, +span): each node owns one spacing slot. + const first = Math.ceil((playerForward - 20) / r.spacing); + for (let k = 0; k < r.nodes.length; k += 1) { + const slot = first + k; + const idx = ((slot % r.nodes.length) + r.nodes.length) % r.nodes.length; + r.place(r.nodes[idx], slot * r.spacing, slot); + } + void span; + } + } + + // Glowing lane dashes on both lane lines (spacing 8.5, 2.4 long). + for (const laneX of [-1.6, 1.6]) { + addRecycler( + 26, + 8.5, + () => scene.mesh(scene.box(0.09, 0.0225, 1.2), m.laneGlow), + (node, forward) => node.position.set(laneX, 0.052, -(forward + 3)), + ); + } + // Road expansion-panel bands (spacing 14, full road width). + addRecycler( + 16, + 14, + () => scene.mesh(scene.box(TRACK_HALF_WIDTH - 0.5, 0.0125, 0.05), m.roadPanel), + (node, forward) => node.position.set(0, 0.012, -(forward + 5)), + ); + // Candy stripes on the red shoulders (spacing 10, alternating skew). + const stripeEuler = new Euler(); + for (const side of [-1, 1]) { + addRecycler( + 22, + 10, + () => scene.mesh(scene.box(0.27, 0.029, 1.4), m.laneWhite), + (node, forward, index) => { + node.position.set(side * (TRACK_HALF_WIDTH + 0.42), 0.052, -(forward + 5)); + node.quaternion.setFromEuler(stripeEuler.set(0, index % 2 === 0 ? 0.42 : -0.42, 0)); + }, + ); + } + + // -- the runner: ~11 rigid primitives with procedural swing ------------------ + const rig = scene.node(); + const torso = scene.mesh(scene.box(0.44, 0.54, 0.28), m.suit, rig); + torso.position.set(0, 0.8, 0); + const head = scene.mesh(scene.sphere(0.42, 14), m.skin, rig); + head.position.set(0, 1.55, 0); + const visorPlate = scene.mesh(scene.box(0.29, 0.09, 0.04), m.visor, rig); + visorPlate.position.set(0, 1.58, -0.37); + const hat = scene.mesh(scene.cone(0.34, 0.58, 10), m.suit, rig); + hat.position.set(0.02, 2.0, 0.02); + const pom = scene.mesh(scene.sphere(0.1, 8), m.trim, rig); + pom.position.set(-0.04, 2.32, 0.02); + + interface Limb { + pivot: SceneNode; + } + function limb(x: number, y: number, matId: number, geomId: number, dropY: number): Limb { + const pivot = scene.node(rig); + pivot.position.set(x, y, 0); + const mesh = scene.mesh(geomId, matId, pivot); + mesh.position.set(0, dropY, 0); + return { pivot }; + } + const armGeom = scene.cylinder(0.09, 0.11, 0.62, 8); + const legGeom = scene.box(0.09, 0.22, 0.1); + const armL = limb(-0.64, 1.05, m.suitDark, armGeom, -0.31); + const armR = limb(0.64, 1.05, m.suitDark, armGeom, -0.31); + const legL = limb(-0.28, 0.56, m.suitDark, legGeom, -0.22); + const legR = limb(0.28, 0.56, m.suitDark, legGeom, -0.22); + const shoeGeom = scene.box(0.14, 0.08, 0.29); + for (const l of [legL, legR]) { + const shoe = scene.mesh(shoeGeom, m.shadowDark, l.pivot); + shoe.position.set(0, -0.48, -0.05); + } + const playerShadow = createBlobShadow(scene, { radius: 0.55, opacity: 0.3 }); + + // -- entity pools ------------------------------------------------------------- + function buildBlock(): SceneNode { + const group = scene.node(); + const s = OBSTACLE_SPECS.block; + const base = scene.mesh(scene.box(s.width / 2, s.height / 2, s.depth / 2), m.shoulderRed, group); + base.position.set(0, s.height / 2, 0); + const ribbon = scene.mesh(scene.box(0.09, (s.height * 1.03) / 2, (s.depth * 1.06) / 2), m.gold, group); + ribbon.position.set(0, s.height / 2, 0); + const cap = scene.mesh(scene.box((s.width * 0.98) / 2, 0.04, (s.depth * 0.94) / 2), m.snow, group); + cap.position.set(0, s.height + 0.04, 0); + return group; + } + + function buildBarrier(): SceneNode { + const group = scene.node(); + for (const x of [-0.62, 0.62]) { + const post = scene.mesh(scene.box(0.09, 0.43, 0.14), m.visor, group); + post.position.set(x, 0.43, 0); + } + const railTop = scene.mesh(scene.box(0.69, 0.09, 0.09), m.shoulderRed, group); + railTop.position.set(0, 0.62, -0.08); + const railLow = scene.mesh(scene.box(0.69, 0.09, 0.09), m.trim, group); + railLow.position.set(0, 0.28, -0.08); + return group; + } + + function buildLowBeam(): SceneNode { + const group = scene.node(); + const s = OBSTACLE_SPECS.lowBeam; + const beam = scene.mesh(scene.box(s.width / 2, s.height / 2, s.depth / 2), m.beamGreen, group); + beam.position.set(0, s.base + s.height / 2, 0); + const strip = scene.mesh(scene.box((s.width * 0.86) / 2, 0.04, (s.depth * 1.04) / 2), m.goldGlow, group); + strip.position.set(0, s.base + s.height / 2, 0); + for (const x of [-0.82, 0.82]) { + const upright = scene.mesh(scene.box(0.08, (s.base + 0.28) / 2, 0.11), m.beamGreen, group); + upright.position.set(x, (s.base + 0.28) / 2, 0); + } + return group; + } + + function buildCoin(): SceneNode { + const group = scene.node(); + const ball = scene.mesh(scene.sphere(0.36, 12), m.coinGold, group); + ball.position.set(0, 0, 0); + const ribbon = scene.mesh(scene.torus(0.365, 0.03, 12, 6), m.shoulderRed, group); + ribbon.quaternion.setFromEuler(new Euler(Math.PI / 2, 0, 0)); + return group; + } + + function buildBoost(): SceneNode { + const group = scene.node(); + scene.mesh(scene.sphere(0.28, 10), m.goldGlow, group); + const ringA = scene.mesh(scene.torus(0.62, 0.025, 16, 6), m.goldGlow, group); + ringA.quaternion.setFromEuler(new Euler(Math.PI / 2, 0, 0)); + scene.mesh(scene.torus(0.62, 0.025, 16, 6), m.goldGlow, group).quaternion.setFromEuler( + new Euler(0, Math.PI / 2, 0), + ); + return group; + } + + function buildTree(crownMat: number): SceneNode { + const group = scene.node(); + const trunk = scene.mesh(scene.cylinder(0.14, 0.24, 1.5, 8), m.trunk, group); + trunk.position.set(0, 0.75, 0); + const lower = scene.mesh(scene.cone(1.1, 1.6, 8), crownMat, group); + lower.position.set(0, 1.9, 0); + const upper = scene.mesh(scene.cone(0.7, 1.1, 8), crownMat, group); + upper.position.set(0, 2.8, 0); + const snowCap = scene.mesh(scene.cone(0.5, 0.4, 8), m.snow, group); + snowCap.position.set(0, 3.3, 0); + return group; + } + + function makeObstaclePool(kind: ObstacleKind, count: number, build: () => SceneNode): ObstacleSlot[] { + const slots: ObstacleSlot[] = []; + for (let i = 0; i < count; i += 1) { + const group = build(); + group.visible = false; + slots.push({ active: false, kind, laneIndex: 0, forward: 0, group }); + } + return slots; + } + + const obstaclePools: Record = { + block: makeObstaclePool("block", POOL_BLOCK, buildBlock), + barrier: makeObstaclePool("barrier", POOL_BARRIER, buildBarrier), + lowBeam: makeObstaclePool("lowBeam", POOL_LOWBEAM, buildLowBeam), + }; + + function makeCollectiblePool(boost: boolean, count: number): CollectibleSlot[] { + const slots: CollectibleSlot[] = []; + for (let i = 0; i < count; i += 1) { + const group = boost ? buildBoost() : buildCoin(); + group.visible = false; + slots.push({ active: false, live: false, boost, laneIndex: 0, forward: 0, phase: 0, group }); + } + return slots; + } + + const coinPool = makeCollectiblePool(false, POOL_COIN); + const boostPool = makeCollectiblePool(true, POOL_BOOST); + + const treePool: TreeSlot[] = []; + for (let i = 0; i < POOL_TREE; i += 1) { + const group = buildTree(m.crowns[i % m.crowns.length]); + group.visible = false; + treePool.push({ active: false, forward: 0, group }); + } + + // -- snowfall ------------------------------------------------------------------- + const snowMat = scene.material(rgbToAbgr(0xffffff, 0.78), MAT.unlit | MAT.transparent); + const snowPool: SpritePool = scene.spritePool(SNOWFLAKE_COUNT, snowMat); + const flakeRight = new Float32Array(SNOWFLAKE_COUNT); + const flakeUp = new Float32Array(SNOWFLAKE_COUNT); + const flakeForward = new Float32Array(SNOWFLAKE_COUNT); + { + const snowRng = new RandomGenerator(SNOW_SEED); + for (let i = 0; i < SNOWFLAKE_COUNT; i += 1) { + flakeRight[i] = snowRng.uniform(-42, 42); + flakeUp[i] = snowRng.uniform(4, 26); + flakeForward[i] = snowRng.uniform(-80, 120); + snowPool.buf[i * 4 + 3] = snowRng.uniform(0.1, 0.24); + snowPool.colors[i] = rgbToAbgr(0xffffff, 0.78); + } + snowPool.count = SNOWFLAKE_COUNT; + } + + function stepSnow(playerForward: number, dt: number): void { + for (let i = 0; i < SNOWFLAKE_COUNT; i += 1) { + flakeUp[i] -= (1.2 + (i % 7) * 0.12) * dt; + flakeRight[i] += Math.sin(playerForward * 0.04 + i) * 0.012; + if (flakeUp[i] < 1.6) flakeUp[i] = 24 + (i % 11) * 0.18; + const o = i * 4; + snowPool.buf[o] = flakeRight[i]; + snowPool.buf[o + 1] = flakeUp[i]; + snowPool.buf[o + 2] = -(playerForward + 28 + flakeForward[i]); + } + snowPool.count = SNOWFLAKE_COUNT; + } + + // -- camera ------------------------------------------------------------------ + const cameraRig = new PositionFollowCameraRig({ + azimuth: 0, + distance: 11, + height: 6.4, + lookHeight: 1.2, + positionLag: 0.06, + lookLag: 0.04, + }); + + // -- run state ----------------------------------------------------------------- + const player: PlayerState = { + laneIndex: 1, + targetLaneIndex: 1, + laneRight: LANES[1], + forward: 0, + height: 0, + verticalVelocity: 0, + grounded: true, + slideTimer: 0, + bobTime: 0, + }; + + let status: RunnerStatus = "ready"; + let score = 0; + let coins = 0; + let boosts = 0; + let finalScore = 0; + let speed = BASE_SPEED; + let effectiveSpeed = BASE_SPEED; + let boostTimer = 0; + let nextSpawnForward = FIRST_SPAWN_FORWARD; + let nextSceneryForward = FIRST_SCENERY_FORWARD; + let prevButtons = 0; + let cameraSnapped = false; + + const probe: RunnerProbe = { + sceneId: scene.__scene, + playerNodeId: rig.__id, + steps: 0, + status, + score: 0, + coins: 0, + boosts: 0, + distance: 0, + laneIndex: 1, + collisions: 0, + gameOvers: 0, + runsStarted: 0, + playerPosition: { x: LANES[1], y: 0, z: 0 }, + upcoming: [], + }; + (globalThis as Record).__runnerProbe = probe; + + function releaseAll(): void { + for (const pool of [obstaclePools.block, obstaclePools.barrier, obstaclePools.lowBeam]) { + for (const slot of pool) { + if (!slot.active) continue; + slot.active = false; + slot.group.visible = false; + } + } + for (const pool of [coinPool, boostPool]) { + for (const slot of pool) { + if (!slot.active) continue; + slot.active = false; + slot.live = false; + slot.group.visible = false; + } + } + for (const slot of treePool) { + if (!slot.active) continue; + slot.active = false; + slot.group.visible = false; + } + } + + function resetRun(): void { + releaseAll(); + rng.seed(WORLD_SEED); + player.laneIndex = 1; + player.targetLaneIndex = 1; + player.laneRight = LANES[1]; + player.forward = 0; + player.height = 0; + player.verticalVelocity = 0; + player.grounded = true; + player.slideTimer = 0; + player.bobTime = 0; + score = 0; + coins = 0; + boosts = 0; + speed = BASE_SPEED; + effectiveSpeed = BASE_SPEED; + boostTimer = 0; + nextSpawnForward = FIRST_SPAWN_FORWARD; + nextSceneryForward = FIRST_SCENERY_FORWARD; + } + + // -- spawning --------------------------------------------------------------- + function takeObstacle(kind: ObstacleKind, laneIndex: number, forward: number): void { + const pool = obstaclePools[kind]; + const slot = pool.find((s) => !s.active); + if (!slot) return; // pool exhausted — skip (deterministic) + slot.active = true; + slot.laneIndex = laneIndex; + slot.forward = forward; + slot.group.position.set(LANES[laneIndex], 0, -forward); + slot.group.visible = true; + } + + function takeCollectible(boost: boolean, laneIndex: number, forward: number): void { + const pool = boost ? boostPool : coinPool; + const slot = pool.find((s) => !s.active); + if (!slot) return; + slot.active = true; + slot.live = true; + slot.laneIndex = laneIndex; + slot.forward = forward; + slot.phase = rng.uniform(0, Math.PI * 2); + const base = boost ? BOOST_BASE : COIN_BASE; + slot.group.position.set(LANES[laneIndex], base + COLLECTIBLE_SIZE / 2, -forward); + slot.group.visible = true; + } + + const OBSTACLE_KINDS: readonly ObstacleKind[] = ["block", "barrier", "lowBeam"]; + const SECOND_KINDS: readonly ObstacleKind[] = ["block", "barrier"]; + + function spawnSet(forward: number): void { + const blockedLane = rng.randint(0, 2); + takeObstacle(rng.choice(OBSTACLE_KINDS), blockedLane, forward); + + if (speed > SECOND_OBSTACLE_SPEED && rng.random() > 0.48) { + const others = [0, 1, 2].filter((lane) => lane !== blockedLane); + takeObstacle(rng.choice(SECOND_KINDS), rng.choice(others), forward + rng.uniform(1.6, 3.2)); + } + + const openLanes = [0, 1, 2].filter((lane) => lane !== blockedLane); + const lane = rng.choice(openLanes); + const isBoost = rng.random() > 0.84; + const count = isBoost ? 1 : rng.randint(2, 5); + for (let i = 0; i < count; i += 1) { + takeCollectible(isBoost, lane, forward + 3 + i * 2.1); + } + } + + function spawnScenery(forward: number): void { + for (const side of [-1, 1]) { + if (rng.random() < 0.2) continue; + const right = side * rng.uniform(8.5, 24); + const slot = treePool.find((s) => !s.active); + const scale = rng.uniform(0.82, 1.35); + const jitter = rng.uniform(-2, 2); + if (!slot) continue; // draws above keep the stream aligned + slot.active = true; + slot.forward = forward + jitter; + slot.group.position.set(right, 0, -slot.forward); + slot.group.scale.set(scale, scale, scale); + slot.group.visible = true; + } + } + + function streamWorld(): void { + while (nextSpawnForward < player.forward + SPAWN_AHEAD) { + spawnSet(nextSpawnForward); + nextSpawnForward += rng.uniform(SPAWN_GAP_MIN, SPAWN_GAP_MAX); + } + while (nextSceneryForward < player.forward + SCENERY_AHEAD) { + spawnScenery(nextSceneryForward); + nextSceneryForward += rng.uniform(SCENERY_GAP_MIN, SCENERY_GAP_MAX); + } + const minForward = player.forward - PRUNE_BEHIND; + for (const kind of OBSTACLE_KINDS) { + for (const slot of obstaclePools[kind]) { + if (slot.active && slot.forward <= minForward) { + slot.active = false; + slot.group.visible = false; + } + } + } + for (const pool of [coinPool, boostPool]) { + for (const slot of pool) { + if (slot.active && (slot.forward <= minForward || !slot.live)) { + slot.active = false; + slot.live = false; + slot.group.visible = false; + } + } + } + for (const slot of treePool) { + if (slot.active && slot.forward <= minForward) { + slot.active = false; + slot.group.visible = false; + } + } + } + + // -- collision ---------------------------------------------------------------- + const playerBox: AabbLike = { + laneRight: 0, + base: 0, + height: PLAYER_BASE_HEIGHT, + width: PLAYER_WIDTH, + depth: PLAYER_DEPTH, + forward: 0, + }; + const otherBox: AabbLike = { laneRight: 0, base: 0, height: 1, width: 1, depth: 1, forward: 0 }; + + function resolveCollisions(): void { + playerBox.laneRight = player.laneRight; + playerBox.base = player.height; + playerBox.height = player.slideTimer > 0 ? PLAYER_SLIDE_HEIGHT : PLAYER_BASE_HEIGHT; + playerBox.forward = player.forward; + + for (const kind of OBSTACLE_KINDS) { + const spec = OBSTACLE_SPECS[kind]; + for (const slot of obstaclePools[kind]) { + if (!slot.active) continue; + otherBox.laneRight = LANES[slot.laneIndex]; + otherBox.base = spec.base; + otherBox.height = spec.height; + otherBox.width = spec.width; + otherBox.depth = spec.depth; + otherBox.forward = slot.forward; + if (aabbOverlap(playerBox, otherBox)) { + probe.collisions += 1; + probe.gameOvers += 1; + finalScore = Math.floor(score); + status = "gameOver"; + return; + } + } + } + + for (const pool of [coinPool, boostPool]) { + for (const slot of pool) { + if (!slot.active || !slot.live) continue; + otherBox.laneRight = LANES[slot.laneIndex]; + otherBox.base = slot.boost ? BOOST_BASE : COIN_BASE; + otherBox.height = COLLECTIBLE_SIZE; + otherBox.width = COLLECTIBLE_SIZE; + otherBox.depth = COLLECTIBLE_SIZE; + otherBox.forward = slot.forward; + if (!aabbOverlap(playerBox, otherBox)) continue; + slot.live = false; + slot.group.visible = false; + if (slot.boost) { + boostTimer = BOOST_DURATION; + score += BOOST_SCORE; + boosts += 1; + } else { + coins += 1; + score += COIN_SCORE; + } + } + } + } + + // -- input --------------------------------------------------------------------- + function pressed(buttons: number, mask: number): boolean { + return (buttons & mask) !== 0 && (prevButtons & mask) === 0; + } + + function handleInput(buttons: number): void { + if (status !== "running") { + if (pressed(buttons, BTN.CROSS) || pressed(buttons, BTN.START)) { + if (status === "gameOver") resetRun(); + status = "running"; + probe.runsStarted += 1; + } + return; + } + if (pressed(buttons, BTN.LEFT)) { + player.targetLaneIndex = clamp(player.targetLaneIndex - 1, 0, LANES.length - 1); + } + if (pressed(buttons, BTN.RIGHT)) { + player.targetLaneIndex = clamp(player.targetLaneIndex + 1, 0, LANES.length - 1); + } + if (pressed(buttons, BTN.CROSS) && player.grounded && player.slideTimer <= 0) { + player.verticalVelocity = JUMP_VELOCITY; + player.grounded = false; + } + if (pressed(buttons, BTN.DOWN) && player.grounded) { + player.slideTimer = SLIDE_DURATION; + } + } + + // -- per-step player integration -------------------------------------------------- + function stepPlayer(dt: number): void { + boostTimer = Math.max(0, boostTimer - dt); + speed += SPEED_RAMP_PER_SECOND * dt; + effectiveSpeed = speed * (boostTimer > 0 ? BOOST_MULTIPLIER : 1); + const forwardDelta = effectiveSpeed * dt; + player.forward += forwardDelta; + player.bobTime += dt * BOB_RATE; + score += forwardDelta * SCORE_PER_UNIT; + + const targetRight = LANES[player.targetLaneIndex]; + player.laneRight += (targetRight - player.laneRight) * smoothingAlpha(1 / LANE_DAMP_LAMBDA, dt); + if (Math.abs(player.laneRight - targetRight) < LANE_SNAP_EPSILON) { + player.laneRight = targetRight; + player.laneIndex = player.targetLaneIndex; + } + + if (!player.grounded) { + player.verticalVelocity -= GRAVITY * dt; + player.height += player.verticalVelocity * dt; + if (player.height <= 0) { + player.height = 0; + player.verticalVelocity = 0; + player.grounded = true; + } + } + if (player.slideTimer > 0) player.slideTimer = Math.max(0, player.slideTimer - dt); + } + + // -- visual sync ------------------------------------------------------------------- + const limbEuler = new Euler(); + + function syncVisuals(dt: number): void { + const bob = player.grounded && status === "running" ? Math.sin(player.bobTime) * BOB_AMPLITUDE : 0; + rig.position.set(player.laneRight, player.height + bob, -player.forward); + const squash = player.slideTimer > 0 ? SLIDE_SQUASH : 1; + rig.scale.set(1, squash, 1); + + // Procedural run cycle: opposite-phase arm/leg swing; tucked in the air. + const running = status === "running"; + const swing = running && player.grounded ? Math.sin(player.bobTime) * 0.7 : 0; + const airTuck = player.grounded ? 0 : 0.55; + armL.pivot.quaternion.setFromEuler(limbEuler.set(swing - airTuck, 0, -0.15)); + armR.pivot.quaternion.setFromEuler(limbEuler.set(-swing - airTuck, 0, 0.15)); + legL.pivot.quaternion.setFromEuler(limbEuler.set(-swing + airTuck, 0, 0)); + legR.pivot.quaternion.setFromEuler(limbEuler.set(swing + airTuck, 0, 0)); + + updateBlobShadow(playerShadow, 0, { x: player.laneRight, z: -player.forward }); + + // Collectible idle animation: spin + gentle vertical wave. + for (const pool of [coinPool, boostPool]) { + for (const slot of pool) { + if (!slot.active || !slot.live) continue; + slot.phase += dt * 4; + const base = (slot.boost ? BOOST_BASE : COIN_BASE) + COLLECTIBLE_SIZE / 2; + slot.group.position.y = base + Math.sin(slot.phase) * 0.08; + slot.group.quaternion.setFromEuler(limbEuler.set(0, slot.phase * 1.2, 0)); + } + } + + stepRecyclers(player.forward); + stepSnow(player.forward, dt); + + cameraRig.step({ + targetPosition: { x: player.laneRight, y: player.height, z: -player.forward }, + snapToTarget: !cameraSnapped, + deltaSeconds: dt, + camera: scene.camera, + }); + cameraSnapped = true; + } + + // -- the fixed step ------------------------------------------------------------------ + function step(dt: number, input: GameInput): void { + handleInput(input.buttons); + prevButtons = input.buttons; + + if (status === "running") { + stepPlayer(dt); + streamWorld(); + resolveCollisions(); + } + + syncVisuals(dt); + + probe.steps += 1; + probe.status = status; + probe.score = Math.floor(score); + probe.coins = coins; + probe.boosts = boosts; + probe.distance = Math.floor(player.forward); + probe.laneIndex = player.targetLaneIndex; + probe.playerPosition = vec(rig.position); + probe.upcoming.length = 0; + for (const kind of OBSTACLE_KINDS) { + for (const slot of obstaclePools[kind]) { + if (!slot.active || slot.forward < player.forward - 2) continue; + probe.upcoming.push({ kind: slot.kind, laneIndex: slot.laneIndex, forward: slot.forward }); + } + } + probe.upcoming.sort((a, b) => a.forward - b.forward); + probe.upcoming.length = Math.min(probe.upcoming.length, 6); + } + + function hudState(): RunnerHudState { + return { + status, + score: Math.floor(score), + coins, + distance: Math.floor(player.forward), + speed: Math.round(effectiveSpeed), + boostActive: boostTimer > 0, + finalScore, + }; + } + + return { scene, step, hudState }; +} diff --git a/demos/runner/main.tsx b/demos/runner/main.tsx new file mode 100644 index 0000000..43c790f --- /dev/null +++ b/demos/runner/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: Runner +import Runner from "./app.tsx"; +import { mount } from "@pocketjs/framework"; + +mount(() => ); diff --git a/demos/runner/screenshot-f10.png b/demos/runner/screenshot-f10.png new file mode 100644 index 0000000..89d9864 Binary files /dev/null and b/demos/runner/screenshot-f10.png differ diff --git a/demos/runner/screenshot-f150.png b/demos/runner/screenshot-f150.png new file mode 100644 index 0000000..96ddb93 Binary files /dev/null and b/demos/runner/screenshot-f150.png differ diff --git a/demos/runner/screenshot-f240.png b/demos/runner/screenshot-f240.png new file mode 100644 index 0000000..612c431 Binary files /dev/null and b/demos/runner/screenshot-f240.png differ diff --git a/demos/snake/app.tsx b/demos/snake/app.tsx new file mode 100644 index 0000000..654c4b2 --- /dev/null +++ b/demos/snake/app.tsx @@ -0,0 +1,81 @@ +// demos/snake/app.tsx — "Pocket Snake": the playset grid-game demo app. +// +// The pure sim lives in game.ts; this component wires it to the runtime: +// createGameLoop steps the game at a fixed 1/60 s on the virtual clock +// (hz-invariant, DETERMINISM.md), the render callback flushes the Scene3D +// and refreshes one HUD signal, and the HUD (score panel, length readout, +// game-over card) composes as ordinary flex children over the . +// On hosts without a 3D core the viewport is an empty box and the HUD — +// driven by the same deterministic sim — still renders. + +import { Show, createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { createGameLoop } from "../../playset/loop.ts"; +import { Viewport3D } from "../../playset/scene3d/viewport.ts"; +import { createSnakeGame } from "./game.ts"; + +const INK = "#eef3f7"; +const DIM = "#9ca8b3"; +const RED = "#f04f5d"; +const BLUE = "#6fd0ff"; +const GREEN = "#35b34a"; + +export default function Snake() { + const game = createSnakeGame(); + const [hud, setHud] = createSignal(game.hudState()); + + createGameLoop({ + step: (dt, input) => game.step(dt, input), + render: () => { + game.scene.flush(); + setHud(game.hudState()); + }, + }); + + // No bgColor on the viewport: on native hosts the 3D scene composites + // UNDER the ui layer, so the viewport (and its ancestors) stay unpainted. + return ( + + + {/* Top bar: player score + best left, rival score right */} + + + + {`SCORE ${hud().score}`} + + + {`BEST ${hud().bestScore}`} + + + + {`RIVAL ${hud().rivalScore}`} + + + + {/* Center: game-over card (restart hint) */} + + + + + GAME OVER + + + {`SCORE ${hud().score} · PRESS × TO RESTART`} + + + + + + {/* Bottom bar: length left, controls hint right */} + + + {`LENGTH ${hud().length}`} + + + D-PAD TURN + + + + + ); +} diff --git a/demos/snake/game.ts b/demos/snake/game.ts new file mode 100644 index 0000000..4cae476 --- /dev/null +++ b/demos/snake/game.ts @@ -0,0 +1,662 @@ +// demos/snake/game.ts — "Pocket Snake": a grid-snake arena duel (player vs. +// one AI rival) composed entirely from playset modules. +// +// Reference: the GameBlocks demo "Snake Clash" (https://gb-snake-clash.vercel.app/). +// The library modules it builds on are the MIT-licensed playset ports used +// below (SnakeMotionController, SnakePlay, GridPathPlanner, BoardEnvironment, +// PositionFollowCameraRig, RandomGenerator). The behavior spec — grid +// dimensions, tick cadence, growth/score rules, AI scoring weights, palette, +// geometry sizes, and camera framing — is derived from the gb-snake-clash +// demo sources; the glue itself is a fresh implementation written for +// Pocket's deterministic fixed-step machine. +// +// Everything here is deterministic fixed-step state (DETERMINISM.md): the +// only inputs are the per-step button mask and FIXED_DT — no wall clock, no +// Math.random (one seeded RandomGenerator drives item spawns). The grid +// advances every N sim steps, N derived from the reference cadence +// (150 ms base → 9 steps at 60 Hz, speeding up 4 ms per point to a 70 ms +// floor → 4 steps). The composition under test: +// +// BoardEnvironment 16×16 board plane + grid + lighting, +// cellToWorldPoint for all placement +// SnakeMotionController ×2 segment queues + pending growth +// (cardinal mode: reversals impossible) +// SnakePlay (fresh per grid tick) wall/self/snake-vs-snake collisions +// + item pickups → events +// GridPathPlanner the rival brain's flood fills and +// A* routes to the nearest apple +// PositionFollowCameraRig one fixed tilted pose framing the board +// +// A debug probe rides on globalThis.__snakeProbe so the headless E2E +// (playset/test/snake-sim.test.ts) can assert pickups/deaths without +// scraping HUD pixels. + +import { BTN } from "@pocketjs/framework/input"; +import { Euler } from "../../playset/math/index.ts"; +import { Scene3D, type SceneNode } from "../../playset/scene3d/client.ts"; +import type { GameInput } from "../../playset/loop.ts"; +import { RandomGenerator } from "../../playset/modules/math/random-utils.ts"; +import { rgbToAbgr } from "../../playset/modules/world/color-utils.ts"; +import { BoardEnvironment } from "../../playset/modules/world/environment/board-environment.ts"; +import { PositionFollowCameraRig } from "../../playset/modules/camera/position-follow-camera-rig.ts"; +import { + SnakeMotionController, + type SnakeCell, +} from "../../playset/modules/actor-motion/snake-motion-controller.ts"; +import { + SNAKE_PLAY_EVENTS, + SnakePlay, + type SnakeDeathReason, +} from "../../playset/modules/gameplay/snake-play.ts"; +import { + GridPathPlanner, + gridCellKey, + type GridNavigation, +} from "../../playset/modules/behavior/grid-path-planner.ts"; + +// --------------------------------------------------------------------------- +// Behavior spec (numbers observed from the reference demo) +// --------------------------------------------------------------------------- + +export const COLUMNS = 16; +export const ROWS = 16; +const CELL_SIZE = 1; +const INITIAL_LENGTH = 4; + +/** Reference cadence: 150 ms/tick base, −4 ms per player point, 70 ms floor. + * Folded onto the fixed step as whole sim-step counts (60 steps = 1 s). */ +const BASE_TICK_MS = 150; +const MIN_TICK_MS = 70; +const SPEEDUP_MS_PER_POINT = 4; +const STEP_MS = 1000 / 60; + +const PLAYER_START: SnakeCell = { right: 3, forward: 8 }; // floor(rows/2) +const RIVAL_START: SnakeCell = { right: COLUMNS - 4, forward: 3 }; + +/** Segment-node pool size per snake — growth beyond this still plays out in + * the sim; only the visual chain clamps (no per-growth allocation ever). */ +const MAX_SEGMENTS = 64; + +// Palette (reference CSS/material colors). +const PLAYER_BODY = 0xd72638; +const PLAYER_HEAD = 0xf04f5d; +const RIVAL_BODY = 0x2687c9; +const RIVAL_HEAD = 0x6fd0ff; +const EYE_COLOR = 0xfff5f6; +const APPLE_BODY = 0x35b34a; +const APPLE_LEAF = 0x7ce084; +const APPLE_STEM = 0x6f431f; +const RIM_COLOR = 0x364a5a; +const BOARD_GROUND = 0x23303c; +const BOARD_GRID = 0x507893; +const BACKGROUND = 0x1b242c; + +export type DirectionName = "up" | "right" | "down" | "left"; + +const DIRECTION_NAMES: readonly DirectionName[] = ["up", "right", "down", "left"]; + +const DIRECTION_VECTORS: Readonly> = Object.freeze({ + up: Object.freeze({ right: 0, forward: 1 }), + right: Object.freeze({ right: 1, forward: 0 }), + down: Object.freeze({ right: 0, forward: -1 }), + left: Object.freeze({ right: -1, forward: 0 }), +}); + +const OPPOSITE: Readonly> = Object.freeze({ + up: "down", + right: "left", + down: "up", + left: "right", +}); + +/** Head yaw per travel direction (basis forward is −z; eyes sit at +z). */ +const HEAD_YAW: Readonly> = Object.freeze({ + up: Math.PI, + down: 0, + left: Math.PI * 0.5, + right: -Math.PI * 0.5, +}); + +function directionName(vector: SnakeCell): DirectionName { + if (vector.right < 0) return "left"; + if (vector.right > 0) return "right"; + if (vector.forward > 0) return "up"; + return "down"; +} + +/** Cardinal-mode move flags for an absolute direction (d-pad semantics). */ +function moveFlags(direction: DirectionName): { + left: boolean; + right: boolean; + forward: boolean; + backward: boolean; +} { + return { + left: direction === "left", + right: direction === "right", + forward: direction === "up", + backward: direction === "down", + }; +} + +function tickSteps(score: number): number { + const ms = Math.max(MIN_TICK_MS, BASE_TICK_MS - score * SPEEDUP_MS_PER_POINT); + return Math.max(1, Math.round(ms / STEP_MS)); +} + +// --------------------------------------------------------------------------- +// The rival brain — one grid decision per tick over GridPathPlanner +// --------------------------------------------------------------------------- +// +// Observed reference behavior: consider the three non-reversal directions, +// discard off-board / occupied moves, then score each candidate by +// reachable space (flood fill) × spaceWeight +// − A* distance to the nearest apple × appleDistanceWeight +// (unreachable apple costs the whole board: columns × rows) +// + tailReachBonus when its own tail stays reachable +// + straightBias when the candidate keeps the current heading +// picking the highest score (ties resolve in up/right/down/left order). + +interface BrainWeights { + spaceWeight: number; + appleDistanceWeight: number; + tailReachBonus: number; + straightBias: number; +} + +/** Rival tuning observed in the reference demo. */ +const RIVAL_WEIGHTS: BrainWeights = { + spaceWeight: 2, + appleDistanceWeight: 3, + tailReachBonus: 3.5, + straightBias: 0.5, +}; + +interface BrainSenses { + segments: SnakeCell[]; + direction: DirectionName; + pendingGrowth: number; + opponentSegments: SnakeCell[]; + apples: SnakeCell[]; +} + +const GRID_NAVIGATION: GridNavigation = { + vectors: DIRECTION_VECTORS, + neighborOrder: DIRECTION_NAMES, +}; + +function stepInBounds(cell: SnakeCell, direction: DirectionName): SnakeCell | null { + const vector = DIRECTION_VECTORS[direction]; + const next = { right: cell.right + vector.right, forward: cell.forward + vector.forward }; + if (next.right < 0 || next.right >= COLUMNS || next.forward < 0 || next.forward >= ROWS) { + return null; + } + return next; +} + +/** Cells of a snake body that block movement this tick: everything but the + * tail cell, because the tail vacates unless the snake is mid-growth. */ +function blockingKeys(segments: SnakeCell[], pendingGrowth: number, into: Set): Set { + const stop = pendingGrowth > 0 ? segments.length : segments.length - 1; + for (let i = 0; i < stop; i += 1) into.add(gridCellKey(segments[i])); + return into; +} + +class SnakeBrain { + private readonly planner: GridPathPlanner; + + constructor(private readonly weights: BrainWeights) { + this.planner = new GridPathPlanner({ + navigation: GRID_NAVIGATION, + columns: COLUMNS, + rows: ROWS, + wrap: false, + }); + } + + choose(senses: BrainSenses): DirectionName { + const head = senses.segments[0]; + const target = this.nearestApple(head, senses.apples); + const opponentKeys = new Set(); + for (const cell of senses.opponentSegments) opponentKeys.add(gridCellKey(cell)); + + let best: DirectionName | null = null; + let bestScore = -Infinity; + for (const direction of DIRECTION_NAMES) { + if (direction === OPPOSITE[senses.direction]) continue; + const score = this.scoreMove(direction, senses, target, opponentKeys); + if (score === null || score <= bestScore) continue; + best = direction; + bestScore = score; + } + return best ?? senses.direction; + } + + private nearestApple(head: SnakeCell, apples: SnakeCell[]): SnakeCell | null { + let nearest: SnakeCell | null = null; + let nearestDistance = Infinity; + for (const apple of apples) { + const distance = this.planner.heuristic(head, apple); + if (distance >= nearestDistance) continue; + nearest = apple; + nearestDistance = distance; + } + return nearest; + } + + private scoreMove( + direction: DirectionName, + senses: BrainSenses, + target: SnakeCell | null, + opponentKeys: ReadonlySet, + ): number | null { + const head = senses.segments[0]; + const nextHead = stepInBounds(head, direction); + if (!nextHead) return null; + + const blockedNow = blockingKeys(senses.segments, senses.pendingGrowth, new Set(opponentKeys)); + if (blockedNow.has(gridCellKey(nextHead))) return null; + + // Simulate the move, then judge the world the snake would wake up in. + const nextSegments = [nextHead, ...senses.segments]; + if (senses.pendingGrowth <= 0) nextSegments.pop(); + const growthAfter = senses.pendingGrowth > 0 ? senses.pendingGrowth - 1 : 0; + const blockedNext = blockingKeys(nextSegments, growthAfter, new Set(opponentKeys)); + + const flood = this.planner.floodFill(nextHead, blockedNext, true, false); + let appleCost = COLUMNS * ROWS; + if (target) { + const path = this.planner.findPath(nextHead, target, blockedNext, true, true, false); + if (path) appleCost = Math.max(0, path.length - 1) * this.weights.appleDistanceWeight; + } + const tail = nextSegments[nextSegments.length - 1] ?? nextHead; + const tailPath = this.planner.findPath(nextHead, tail, blockedNext, true, true, false); + + return ( + flood.count * this.weights.spaceWeight - + appleCost + + (tailPath ? this.weights.tailReachBonus : 0) + + (direction === senses.direction ? this.weights.straightBias : 0) + ); + } +} + +// --------------------------------------------------------------------------- +// Public shapes +// --------------------------------------------------------------------------- + +export type SnakeGameStatus = "running" | "gameover"; + +export interface SnakeProbe { + /** scene3d handle of the game's one scene (0 in pure-mirror mode). */ + sceneId: number; + /** Player head node id — the test reads its serialized pose. */ + playerHeadNodeId: number; + steps: number; + gridTicks: number; + status: SnakeGameStatus; + score: number; + rivalScore: number; + bestScore: number; + playerLength: number; + playerHead: SnakeCell; + rivalLength: number; + rivalHead: SnakeCell; + /** Item pickups this boot, both snakes (playerItems + rivalItems). */ + itemsEaten: number; + playerItems: number; + rivalItems: number; + playerDeaths: number; + rivalDeaths: number; + lastPlayerDeathReason: SnakeDeathReason | null; + restarts: number; +} + +export interface SnakeHudState { + status: SnakeGameStatus; + score: number; + rivalScore: number; + bestScore: number; + length: number; +} + +export interface SnakeGame { + scene: Scene3D; + /** One fixed 1/60 s simulation step (createGameLoop's `step`). */ + step(dt: number, input: GameInput): void; + /** Fresh HUD snapshot (call from the loop's `render`). */ + hudState(): SnakeHudState; +} + +interface SnakeVisual { + root: SceneNode; + /** Pooled chain: [0] is the head (eyes attached), rest are body cubes. */ + nodes: SceneNode[]; + shown: number; +} + +interface AppleState extends SnakeCell { + growth: number; +} + +// --------------------------------------------------------------------------- +// The game +// --------------------------------------------------------------------------- + +export function createSnakeGame(): SnakeGame { + const scene = new Scene3D(); + const prng = new RandomGenerator(1337); + + // -- world: board plane, translucent grid, rim walls, lighting --------------- + const board = new BoardEnvironment({ + scene, + columns: COLUMNS, + rows: ROWS, + cellSize: CELL_SIZE, + backgroundScale: 1.08, + boardUp: -0.7, + gridUp: -0.68, + groundColor: BOARD_GROUND, + gridColor: BOARD_GRID, + gridOpacity: 0.65, + // Fixed-function lighting has no physical intensity; these are tuned so + // ambient + sun sit inside the clamp (reference: 1.8 / 2.5 PBR). + ambientIntensity: 0.62, + keyLightColor: 0xfff2db, + keyLightIntensity: 0.85, + keyLightPosition: { right: 8, up: 18, forward: 10 }, + }).create(); + + scene.sky(rgbToAbgr(BACKGROUND), rgbToAbgr(BACKGROUND)); + scene.fog(rgbToAbgr(BACKGROUND), 16, 38); + scene.camera.fovY = (42 * Math.PI) / 180; + scene.camera.zfar = 100; + + // Rim walls hugging the playfield (reference: 0.45 thick, 0.6 tall boxes). + { + const rimMaterial = scene.material(rgbToAbgr(RIM_COLOR), 0); + const width = COLUMNS * CELL_SIZE; + const depth = ROWS * CELL_SIZE; + const centerRight = (COLUMNS - 1) * CELL_SIZE * 0.5; + const centerForward = (ROWS - 1) * CELL_SIZE * 0.5; + const alongRight = scene.box((width + 0.9) / 2, 0.3, 0.225); + const alongForward = scene.box(0.225, 0.3, depth / 2); + const placements: [number, number, number][] = [ + [centerRight, -0.65, 0], + [centerRight, depth - 0.35, 0], + [-0.65, centerForward, 1], + [width - 0.35, centerForward, 1], + ]; + for (const [right, forward, axis] of placements) { + scene + .mesh(axis === 0 ? alongRight : alongForward, rimMaterial) + .position.copy(board.worldPoint(right, -0.4, forward)); + } + } + + // -- snake + apple visuals (pooled; nothing allocates after boot) ------------ + const eulerScratch = new Euler(); + + function buildSnakeVisual(bodyColor: number, headColor: number): SnakeVisual { + const root = scene.node(); + const bodyGeom = scene.box(0.39, 0.31, 0.39); // reference 0.78 × 0.62 × 0.78 + const headGeom = scene.box(0.42, 0.34, 0.42); // reference 0.84 × 0.68 × 0.84 + const bodyMat = scene.material(rgbToAbgr(bodyColor), 0); + const headMat = scene.material(rgbToAbgr(headColor), 0); + const eyeGeom = scene.sphere(0.055, 10); + const eyeMat = scene.material(rgbToAbgr(EYE_COLOR), 0); + + const nodes: SceneNode[] = []; + for (let i = 0; i < MAX_SEGMENTS; i += 1) { + const node = scene.mesh(i === 0 ? headGeom : bodyGeom, i === 0 ? headMat : bodyMat, root); + if (i === 0) { + scene.mesh(eyeGeom, eyeMat, node).position.set(-0.17, 0.12, 0.33); + scene.mesh(eyeGeom, eyeMat, node).position.set(0.17, 0.12, 0.33); + } + node.visible = false; + nodes.push(node); + } + return { root, nodes, shown: 0 }; + } + + const playerVisual = buildSnakeVisual(PLAYER_BODY, PLAYER_HEAD); + const rivalVisual = buildSnakeVisual(RIVAL_BODY, RIVAL_HEAD); + + // One apple on the board at a time (reference invariant), so one node group. + const apple = scene.node(); + scene.mesh(scene.sphere(0.28, 18), scene.material(rgbToAbgr(APPLE_BODY), 0), apple); + const stem = scene.mesh(scene.cylinder(0.03, 0.04, 0.2, 10), scene.material(rgbToAbgr(APPLE_STEM), 0), apple); + stem.position.y = 0.28; + stem.quaternion.setFromEuler(eulerScratch.set(0, 0, -0.28)); + const leaf = scene.mesh(scene.sphere(0.12, 12), scene.material(rgbToAbgr(APPLE_LEAF), 0), apple); + leaf.position.set(0.12, 0.34, 0.02); + leaf.scale.set(1.45, 0.55, 1); + leaf.quaternion.setFromEuler(eulerScratch.set(0, 0, 0.6)); + + // -- fixed camera: one tilted pose framing the whole board ------------------- + new PositionFollowCameraRig({ + azimuth: 0, + distance: 13.5, + height: 17.5, + }).step({ targetPosition: board.center, snapToTarget: true, camera: scene.camera }); + + // -- sim state ---------------------------------------------------------------- + const playerMotion = new SnakeMotionController({ + initialLength: INITIAL_LENGTH, + initialDirection: DIRECTION_VECTORS.right, + startCell: PLAYER_START, + mode: "cardinal", + }); + const rivalMotion = new SnakeMotionController({ + initialLength: INITIAL_LENGTH, + initialDirection: DIRECTION_VECTORS.left, + startCell: RIVAL_START, + mode: "cardinal", + }); + const rivalBrain = new SnakeBrain(RIVAL_WEIGHTS); + + let status: SnakeGameStatus = "running"; + let score = 0; + let rivalScore = 0; + let bestScore = 0; + let apples: AppleState[] = []; + let pendingDirection: DirectionName | null = null; + let stepsUntilTick = tickSteps(0); + let previousButtons = 0; + + const probe: SnakeProbe = { + sceneId: scene.__scene, + playerHeadNodeId: playerVisual.nodes[0].__id, + steps: 0, + gridTicks: 0, + status, + score: 0, + rivalScore: 0, + bestScore: 0, + playerLength: INITIAL_LENGTH, + playerHead: { ...PLAYER_START }, + rivalLength: INITIAL_LENGTH, + rivalHead: { ...RIVAL_START }, + itemsEaten: 0, + playerItems: 0, + rivalItems: 0, + playerDeaths: 0, + rivalDeaths: 0, + lastPlayerDeathReason: null, + restarts: 0, + }; + (globalThis as Record).__snakeProbe = probe; + + function spawnAppleIfNeeded(): void { + if (apples.length > 0 || status === "gameover") return; + const occupied = new Set(); + for (const cell of playerMotion.segments) occupied.add(gridCellKey(cell)); + for (const cell of rivalMotion.segments) occupied.add(gridCellKey(cell)); + const freeCells: SnakeCell[] = []; + for (let forward = 0; forward < ROWS; forward += 1) { + for (let right = 0; right < COLUMNS; right += 1) { + const cell = { right, forward }; + if (!occupied.has(gridCellKey(cell))) freeCells.push(cell); + } + } + if (freeCells.length === 0) return; + const pick = freeCells[Math.floor(prng.random() * freeCells.length)]; + apples.push({ ...pick, growth: 1 }); + } + + function resetRun(): void { + playerMotion.reset({}); + rivalMotion.reset({}); + status = "running"; + score = 0; + rivalScore = 0; + apples = []; + pendingDirection = null; + stepsUntilTick = tickSteps(0); + spawnAppleIfNeeded(); + } + + spawnAppleIfNeeded(); + + function respawnRival(): void { + rivalMotion.reset({}); + probe.rivalDeaths += 1; + } + + function gridTick(): void { + // The rival decides against the player's pre-move body (reference order). + const rivalDirection = rivalBrain.choose({ + segments: rivalMotion.getSegments(), + direction: directionName(rivalMotion.getDirection()), + pendingGrowth: rivalMotion.pendingGrowth, + opponentSegments: playerMotion.getSegments(), + apples, + }); + const playerDirection = pendingDirection ?? directionName(playerMotion.getDirection()); + pendingDirection = null; + + playerMotion.move(moveFlags(playerDirection)); + rivalMotion.move(moveFlags(rivalDirection)); + + // Fresh referee over the post-move boards (SnakePlay is per-tick state). + const play = new SnakePlay({ minRight: 0, maxRight: COLUMNS - 1, minForward: 0, maxForward: ROWS - 1 }); + play.addPlayer({ playerId: "player", segments: playerMotion.getSegments() }); + play.addPlayer({ playerId: "rival", segments: rivalMotion.getSegments() }); + for (const item of apples) play.addItem({ cell: item, growth: item.growth }); + + const events = play.step(); + apples = play.getItemState().map((item) => ({ ...item.cell, growth: item.growth })); + + let rivalDied = false; + for (const event of events) { + if (event.type === SNAKE_PLAY_EVENTS.ITEM_PICKED_UP) { + const points = Math.max(1, Math.floor(event.growBy)); + if (event.playerId === "player") { + score += points; + bestScore = Math.max(bestScore, score); + playerMotion.grow(points); + probe.playerItems += 1; + } else { + rivalScore += points; + rivalMotion.grow(points); + probe.rivalItems += 1; + } + probe.itemsEaten += 1; + } else if (event.playerId === "player") { + status = "gameover"; + bestScore = Math.max(bestScore, score); + probe.playerDeaths += 1; + probe.lastPlayerDeathReason = event.reason; + } else { + rivalDied = true; + } + } + if (rivalDied && status === "running") respawnRival(); + + spawnAppleIfNeeded(); + probe.gridTicks += 1; + } + + // -- presentation (guest-side mirrors; render() flushes once a frame) -------- + function syncSnakeVisual(visual: SnakeVisual, motion: SnakeMotionController, nowMs: number): void { + const segments = motion.segments; + const shown = Math.min(segments.length, MAX_SEGMENTS); + for (let i = visual.shown; i < shown; i += 1) visual.nodes[i].visible = true; + for (let i = shown; i < visual.shown; i += 1) visual.nodes[i].visible = false; + visual.shown = shown; + + const yaw = HEAD_YAW[directionName(motion.getDirection())]; + for (let i = 0; i < shown; i += 1) { + const node = visual.nodes[i]; + // Reference idle motion: gentle bob, head slightly proud of the body. + const bob = + i === 0 + ? Math.sin(nowMs * 0.008) * 0.035 + : Math.sin(nowMs * 0.006 - i * 0.55) * 0.02; + node.position.copy(board.cellToWorldPoint(segments[i], 0.12 + bob)); + if (i === 0) { + node.quaternion.setFromEuler(eulerScratch.set(0, yaw, 0)); + } else { + const shrink = Math.max(0.82, 1 - i * 0.015); + node.scale.set(shrink, shrink, shrink); + } + } + } + + function syncAppleVisual(nowMs: number): void { + const item = apples[0]; + apple.visible = item !== undefined; + if (!item) return; + apple.position.copy(board.cellToWorldPoint(item, 0.25 + Math.sin(nowMs * 0.005) * 0.08)); + apple.quaternion.setFromEuler(eulerScratch.set(0, nowMs * 0.0012, 0)); + } + + // -- fixed step --------------------------------------------------------------- + function step(_dt: number, input: GameInput): void { + const pressed = input.buttons & ~previousButtons; + previousButtons = input.buttons; + + if (status === "running") { + if (pressed & BTN.UP) pendingDirection = "up"; + else if (pressed & BTN.RIGHT) pendingDirection = "right"; + else if (pressed & BTN.DOWN) pendingDirection = "down"; + else if (pressed & BTN.LEFT) pendingDirection = "left"; + + stepsUntilTick -= 1; + if (stepsUntilTick <= 0) { + gridTick(); + stepsUntilTick = tickSteps(score); + } + } else if (pressed & (BTN.CROSS | BTN.START)) { + resetRun(); + probe.restarts += 1; + } + + probe.steps += 1; + const nowMs = probe.steps * STEP_MS; // virtual time for idle animation + syncSnakeVisual(playerVisual, playerMotion, nowMs); + syncSnakeVisual(rivalVisual, rivalMotion, nowMs); + syncAppleVisual(nowMs); + + probe.status = status; + probe.score = score; + probe.rivalScore = rivalScore; + probe.bestScore = bestScore; + probe.playerLength = playerMotion.length; + probe.playerHead = playerMotion.head ?? probe.playerHead; + probe.rivalLength = rivalMotion.length; + probe.rivalHead = rivalMotion.head ?? probe.rivalHead; + } + + function hudState(): SnakeHudState { + return { + status, + score, + rivalScore, + bestScore, + length: playerMotion.length, + }; + } + + return { scene, step, hudState }; +} diff --git a/demos/snake/main.tsx b/demos/snake/main.tsx new file mode 100644 index 0000000..ab7b57c --- /dev/null +++ b/demos/snake/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: Snake +import Snake from "./app.tsx"; +import { mount } from "@pocketjs/framework"; + +mount(() => ); diff --git a/demos/snake/screenshot-f10.png b/demos/snake/screenshot-f10.png new file mode 100644 index 0000000..6cbb036 Binary files /dev/null and b/demos/snake/screenshot-f10.png differ diff --git a/demos/snake/screenshot-f300.png b/demos/snake/screenshot-f300.png new file mode 100644 index 0000000..e8bb737 Binary files /dev/null and b/demos/snake/screenshot-f300.png differ diff --git a/demos/snake/screenshot-f50.png b/demos/snake/screenshot-f50.png new file mode 100644 index 0000000..19ecff9 Binary files /dev/null and b/demos/snake/screenshot-f50.png differ diff --git a/native/Cargo.lock b/native/Cargo.lock index 2c86406..0022e36 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -39,6 +39,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "glam" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" +dependencies = [ + "libm", +] + [[package]] name = "libc" version = "0.2.186" @@ -87,6 +96,14 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pocket-scene3d" +version = "0.1.0" +dependencies = [ + "glam", + "libm", +] + [[package]] name = "pocketjs-core" version = "0.1.0" @@ -98,7 +115,10 @@ dependencies = [ name = "pocketjs-psp" version = "0.1.0" dependencies = [ + "glam", + "libm", "libquickjs-sys", + "pocket-scene3d", "pocketjs-core", "psp", ] diff --git a/native/Cargo.toml b/native/Cargo.toml index f596236..497cac3 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -24,6 +24,11 @@ edition = "2021" psp = { path = "../../rust-psp/psp", features = ["external-c-heap", "abort-only", "external-global-alloc"] } libquickjs-sys = { path = "../../quickjs-rs/libquickjs-sys" } pocketjs-core = { path = "../core" } +# The scene3d retained store, SHARED with the desktop core (core-only build: +# no_std + alloc, no wgpu/rquickjs). Renderer is ge3d.rs; mount is scene3d.rs. +pocket-scene3d = { path = "../pocket3d/crates/pocket-scene3d", default-features = false } +glam = { version = "0.33", default-features = false, features = ["libm"] } +libm = "0.2" [features] # E2E frame-dump build for test/e2e-ppsspp.ts (dumps frames to ms0:/dc_cap). diff --git a/native/src/ffi.rs b/native/src/ffi.rs index ab00ec7..cacb529 100644 --- a/native/src/ffi.rs +++ b/native/src/ffi.rs @@ -60,9 +60,10 @@ pub unsafe fn arg_f64(ctx: *mut JSContext, argc: i32, argv: *mut JSValue, i: isi } /// Borrow the bytes behind an ArrayBuffer OR a typed-array view (host.ts -/// passes Uint8Arrays). The returned pointer is only valid until the next JS -/// allocation — callers must consume it before returning to JS. -unsafe fn buffer_bytes(ctx: *mut JSContext, val: JSValue) -> Option<(*const u8, usize)> { +/// passes Uint8Arrays; scene3d.rs passes Float32/Uint32Arrays). The returned +/// pointer is only valid until the next JS allocation — callers must consume +/// it before returning to JS. +pub(crate) unsafe fn buffer_bytes(ctx: *mut JSContext, val: JSValue) -> Option<(*const u8, usize)> { let mut len: size_t = 0; let p = JS_GetArrayBuffer(ctx, &mut len, val); if !p.is_null() { diff --git a/native/src/ge.rs b/native/src/ge.rs index 5d15c52..8afaaa6 100644 --- a/native/src/ge.rs +++ b/native/src/ge.rs @@ -111,7 +111,8 @@ struct FontTexture { static mut FONT_TEXTURES: Option>> = None; /// Bump-allocate `bytes` (16-byte aligned) valid until `reset_pool()`. -unsafe fn pool_alloc(bytes: usize) -> *mut u8 { +/// Shared with ge3d.rs for its per-frame transients (sky strip, pool quads). +pub(crate) unsafe fn pool_alloc(bytes: usize) -> *mut u8 { if POOL.is_none() { POOL = Some(Pool { blocks: Vec::new(), cur: 0, off: 0 }); } @@ -761,6 +762,20 @@ pub unsafe fn render_over(ui: &Ui, words: &[u32]) { } i += 1; } + spec::draw_op::SCENE_QUAD if i + 4 <= n => { + // Host-composited 3D backdrop: ge3d renders the bound scene3d + // scene into the rect (viewport+scissor scoped, depth-only + // clear) and returns 2D-clean except viewport/scissor — + // re-apply the scissor stack here. + let (x, y) = xy(words[i + 1]); + let (w, h) = wh(words[i + 2]); + crate::ge3d::composite(words[i + 3] as i32, x as i32, y as i32, w, h); + match scissors.last() { + Some(&(sx, sy, sx1, sy1)) => sys::sceGuScissor(sx, sy, sx1, sy1), + None => sys::sceGuScissor(0, 0, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32), + } + i += 4; + } _ => break, // unknown/truncated op: stop rather than desync } } diff --git a/native/src/ge3d.rs b/native/src/ge3d.rs new file mode 100644 index 0000000..8ae3c7d --- /dev/null +++ b/native/src/ge3d.rs @@ -0,0 +1,608 @@ +//! SCENE_QUAD -> sceGu: composites a bound scene3d scene (the shared +//! pocket-scene3d Store) into a DrawList rect — the PSP counterpart of +//! pocket-scene3d's wgpu SceneRenderer, pass for pass: (1) gradient sky, +//! (2) opaque vertex-lit meshes, (3) blended meshes back-to-front + +//! sprite/beam pools. Like ge.rs, this module never opens or kicks display +//! lists; it enqueues into the list the frame loop owns. +//! +//! Geometry buffers are baked ONCE at geom creation (`bake_geom`) into +//! GE-ready [color][normal][position] f32 vertices + u16 indices, dcache- +//! written-back at bake — ids are never reused, so baked entries never go +//! stale. Only per-frame transients (sky strip, pool quads) go through the +//! 2D backend's bump pool. +//! +//! Lighting is GE hardware lighting, one approximation removed from the +//! desktop shader: the per-vertex hemisphere term +//! `mix(ground, sky, n.y*0.5+0.5)` becomes the GE model ambient set to the +//! AVERAGE of the hemisphere colors (the GE has no hemisphere light). Sun +//! stays exact: one directional light, `max(dot(n, -sunDir), 0)`. Material +//! color x tint modulates through the light colors (ColorMaterial routes the +//! baked per-vertex color into the material ambient+diffuse, so +//! `vertexColor x matColor x (ambient + sun*ndotl)` matches the desktop +//! formula); unlit materials keep the lighting unit on with zero lights so +//! the same path yields `vertexColor x matColor` exactly. +//! +//! Fog is sceGuFog linear fog (same near/far semantics as the desktop +//! shader); additive meshes fog toward BLACK (they fade out with distance, +//! never toward fog color — desktop parity). Pools are unlit and unfogged; +//! their radial soft-falloff fragment term becomes a 32x32 glow texture +//! built once at first use. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use core::ffi::c_void; + +use glam::{Mat4, Quat, Vec3}; +use psp::sys::{ + self, BlendFactor, BlendOp, ClearBuffer, DepthFunc, FrontFaceDirection, GuPrimitive, GuState, + GuTexWrapMode, LightComponent, LightType, MipmapLevel, ScePspFVector3, TextureColorComponent, + TextureEffect, TextureFilter, TexturePixelFormat, VertexType, +}; +use psp::{SCREEN_HEIGHT, SCREEN_WIDTH}; +use pocket_scene3d::{mat_flags, PoolKind, Store, BEAM_STRIDE, SPRITE_STRIDE}; + +use crate::ge::pool_alloc; + +// --------------------------------------------------------------------------- +// Vertex formats (GE fixed component order: [uv][color][normal][pos]) +// --------------------------------------------------------------------------- + +/// COLOR_8888 | NORMAL_32BITF | VERTEX_32BITF — 28-byte stride. Baked once +/// per geom; f32 positions, so the i16 ÷32768 scale gotcha does not apply. +#[repr(C)] +#[derive(Copy, Clone)] +struct MeshVert { + color: u32, + nx: f32, + ny: f32, + nz: f32, + x: f32, + y: f32, + z: f32, +} + +/// TEXTURE_32BITF | COLOR_8888 | VERTEX_32BITF — 24-byte stride (pool quads). +#[repr(C)] +#[derive(Copy, Clone)] +struct PoolVert { + u: f32, + v: f32, + color: u32, + x: f32, + y: f32, + z: f32, +} + +/// COLOR_8888 | VERTEX_16BIT | TRANSFORM_2D (sky strip rows) — ge.rs VertC. +#[repr(C)] +#[derive(Copy, Clone)] +struct SkyVert { + color: u32, + x: i16, + y: i16, + z: i16, + _pad: i16, +} + +const VTYPE_MESH: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() + | VertexType::NORMAL_32BITF.bits() + | VertexType::VERTEX_32BITF.bits() + | VertexType::INDEX_16BIT.bits() + | VertexType::TRANSFORM_3D.bits(), +); +const VTYPE_POOL: VertexType = VertexType::from_bits_truncate( + VertexType::TEXTURE_32BITF.bits() + | VertexType::COLOR_8888.bits() + | VertexType::VERTEX_32BITF.bits() + | VertexType::TRANSFORM_3D.bits(), +); +const VTYPE_SKY: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() | VertexType::VERTEX_16BIT.bits() | VertexType::TRANSFORM_2D.bits(), +); + +/// GE PRIM packs the vertex count into 16 bits (see ge.rs MAX_PRIM_VERTS); +/// divisible by 3 preserves triangle granularity for indexed draws. +const MAX_PRIM_IDX: usize = 65532; + +// --------------------------------------------------------------------------- +// Baked geometry registry (ids never reused — entries never invalidate) +// --------------------------------------------------------------------------- + +struct BakedGeom { + verts: Vec, + indices: Vec, +} + +static mut GEOMS: Option> = None; + +unsafe fn geoms() -> &'static mut BTreeMap { + if GEOMS.is_none() { + GEOMS = Some(BTreeMap::new()); + } + GEOMS.as_mut().unwrap() +} + +#[inline] +fn rgb_byte(f: f32) -> u32 { + (f.clamp(0.0, 1.0) * 255.0 + 0.5) as u32 +} + +/// Build the GE buffers for a freshly created geom and write them back for +/// the GE. Meshes past u16 index range draw nothing (none of the closed +/// vocabulary's tessellations get there; a hostile geomMesh just goes empty). +pub unsafe fn bake_geom(id: i32, store: &Store) { + let Some(mesh) = store.geom(id) else { return }; + if mesh.indices.is_empty() || mesh.positions.len() > u16::MAX as usize { + return; + } + let mut verts = Vec::with_capacity(mesh.positions.len()); + for i in 0..mesh.positions.len() { + let p = mesh.positions[i]; + let n = mesh.normals[i]; + // Per-vertex RGB bakes into the color channel (alpha ff); material + // color x tint modulates at draw time through the light colors. + let color = match &mesh.colors { + Some(c) => { + 0xff00_0000 | (rgb_byte(c[i][2]) << 16) | (rgb_byte(c[i][1]) << 8) | rgb_byte(c[i][0]) + } + None => 0xffff_ffff, + }; + verts.push(MeshVert { color, nx: n[0], ny: n[1], nz: n[2], x: p[0], y: p[1], z: p[2] }); + } + let indices: Vec = mesh.indices.iter().map(|&i| i as u16).collect(); + sys::sceKernelDcacheWritebackRange( + verts.as_ptr() as *const c_void, + (verts.len() * core::mem::size_of::()) as u32, + ); + sys::sceKernelDcacheWritebackRange(indices.as_ptr() as *const c_void, (indices.len() * 2) as u32); + geoms().insert(id, BakedGeom { verts, indices }); +} + +pub unsafe fn free_geom(id: i32) { + geoms().remove(&id); +} + +// --------------------------------------------------------------------------- +// glow texture (pool quads' radial falloff, desktop fs_pool's 1 - d^2) +// --------------------------------------------------------------------------- + +const GLOW_DIM: usize = 32; + +static mut GLOW: Option> = None; + +unsafe fn glow_texture() -> *const u32 { + if GLOW.is_none() { + let mut px = Vec::with_capacity(GLOW_DIM * GLOW_DIM); + for y in 0..GLOW_DIM { + for x in 0..GLOW_DIM { + let dx = (x as f32 + 0.5) / GLOW_DIM as f32 * 2.0 - 1.0; + let dy = (y as f32 + 0.5) / GLOW_DIM as f32 * 2.0 - 1.0; + let soft = (1.0 - (dx * dx + dy * dy)).clamp(0.0, 1.0); + px.push((rgb_byte(soft) << 24) | 0x00ff_ffff); + } + } + sys::sceKernelDcacheWritebackRange(px.as_ptr() as *const c_void, (px.len() * 4) as u32); + GLOW = Some(px); + } + GLOW.as_ref().unwrap().as_ptr() +} + +// --------------------------------------------------------------------------- +// color helpers (u32 ABGR byte math, desktop renderer parity) +// --------------------------------------------------------------------------- + +/// Per-channel ABGR x ABGR. +fn abgr_mul(a: u32, b: u32) -> u32 { + let ch = |s: u32| ((a >> s & 0xff) * (b >> s & 0xff) / 255) << s; + ch(0) | ch(8) | ch(16) | ch(24) +} + +/// Per-channel average — the hemisphere-ambient approximation (module docs). +fn abgr_avg(a: u32, b: u32) -> u32 { + let ch = |s: u32| (((a >> s & 0xff) + (b >> s & 0xff)) / 2) << s; + ch(0) | ch(8) | ch(16) | ch(24) +} + +fn pack_rgb(r: f32, g: f32, b: f32) -> u32 { + 0xff00_0000 | (rgb_byte(b) << 16) | (rgb_byte(g) << 8) | rgb_byte(r) +} + +#[inline] +fn to_psp_matrix(m: Mat4) -> sys::ScePspFMatrix4 { + // Both are column-major [x_axis, y_axis, z_axis, w_axis] of vec4. + unsafe { core::mem::transmute::<[f32; 16], sys::ScePspFMatrix4>(m.to_cols_array()) } +} + +// --------------------------------------------------------------------------- +// the composite pass +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Blend { + Opaque, + Alpha, + Additive, +} + +struct Draw { + world: Mat4, + geom: i32, + /// Material color x per-node tint (u32 ABGR). + color: u32, + flags: u32, + blend: Blend, + /// View depth for back-to-front ordering of the blended set. + depth: f32, +} + +/// Composite `scene` into the DrawList rect. Enters with the 2D pass state +/// (blend on, texture off) and RETURNS with the same 2D-clean state, except +/// the scissor — the caller (ge.rs) re-applies its scissor stack. +pub unsafe fn composite(scene: i32, x: i32, y: i32, w: i32, h: i32) { + let store = crate::scene3d::store(); + let Some(sc) = store.scene(scene) else { return }; + // Clamp to the screen (the core's CPU clip stage guarantees this already). + let x0 = x.clamp(0, SCREEN_WIDTH as i32); + let y0 = y.clamp(0, SCREEN_HEIGHT as i32); + let x1 = (x + w).clamp(0, SCREEN_WIDTH as i32); + let y1 = (y + h).clamp(0, SCREEN_HEIGHT as i32); + let (rw, rh) = (x1 - x0, y1 - y0); + if rw <= 0 || rh <= 0 { + return; + } + + let env = &sc.env; + let cam = env.camera; + let znear = cam.znear.max(1e-3); + let zfar = cam.zfar.max(znear + 1e-3); + let aspect = rw as f32 / rh as f32; + let view = Mat4::from_rotation_translation(cam.q, cam.p).inverse(); + // GL-style -1..1 clip depth: what the GE consumes (pocket3d-gu parity). + let proj = glam::camera::rh::proj::opengl::perspective(cam.fov_y, aspect, znear, zfar); + let fwd = cam.q * Vec3::NEG_Z; + + // -- collect draws (world transforms top-down, visibility pruned) -------- + let mut draws: Vec = Vec::new(); + let mut stack: Vec<(i32, Mat4)> = + sc.root.iter().rev().map(|&id| (id, Mat4::IDENTITY)).collect(); + while let Some((id, parent_world)) = stack.pop() { + let Some(node) = store.node(id) else { continue }; + if !node.visible { + continue; // hidden nodes hide their subtree + } + let world = parent_world * Mat4::from_scale_rotation_translation(node.s, node.q, node.p); + for &c in node.children.iter().rev() { + stack.push((c, world)); + } + if node.geom == 0 || node.mat == 0 { + continue; // bare group + } + if !geoms().contains_key(&node.geom) { + continue; // dangling/degenerate: draws nothing + } + let Some(mat) = store.material(node.mat) else { continue }; + let blend = if mat.flags & mat_flags::ADDITIVE != 0 { + Blend::Additive + } else if mat.flags & mat_flags::TRANSPARENT != 0 { + Blend::Alpha + } else { + Blend::Opaque + }; + draws.push(Draw { + world, + geom: node.geom, + color: abgr_mul(mat.color, node.tint), + flags: mat.flags, + blend, + depth: (world.w_axis.truncate() - cam.p).dot(fwd), + }); + } + // Opaques keep tree order; the blended set draws after, far -> near. + let split = partition_opaque(&mut draws); + draws[split..].sort_by(|a, b| b.depth.total_cmp(&a.depth)); + + // -- rect viewport + depth clear ------------------------------------------ + sys::sceGuScissor(x0, y0, x1, y1); + // Viewport center in the 4096 virtual space, against init_graphics' + // fixed offset (2048 - screen/2): center the NDC box on the rect. + let ox = 2048 - (SCREEN_WIDTH as i32) / 2; + let oy = 2048 - (SCREEN_HEIGHT as i32) / 2; + sys::sceGuViewport(ox + x0 + rw / 2, oy + y0 + rh / 2, rw, rh); + // Clear depth only (color survives: the ui frame clear already ran, and + // the sky/backdrop owns the rect's color). Inverted 16-bit depth: 0 = far. + sys::sceGuClearDepth(0); + sys::sceGuClear(ClearBuffer::DEPTH_BUFFER_BIT); + + // -- sky (before any 3D state; screen-space gouraud strip) ---------------- + sys::sceGuDisable(GuState::Blend); + if let Some((zenith, horizon)) = env.sky { + draw_sky(x0, y0, rw, rh, cam.q, cam.fov_y, zenith, horizon); + } + + // -- 3D pass state --------------------------------------------------------- + sys::sceGuSetMatrix(sys::MatrixMode::Projection, &to_psp_matrix(proj)); + sys::sceGuSetMatrix(sys::MatrixMode::View, &to_psp_matrix(view)); + sys::sceGuDepthRange(65535, 0); + sys::sceGuDepthFunc(DepthFunc::GreaterOrEqual); + sys::sceGuDepthMask(0); // depth writes on for opaques + sys::sceGuEnable(GuState::DepthTest); + sys::sceGuEnable(GuState::ClipPlanes); + // Store meshes wind CCW-out (right-handed, +Y up); through the GL-style + // projection + the GE's y-flipping viewport they arrive CCW in screen + // space (VERIFIED against the desktop reference: CW here culls the + // heightfield and shows closed meshes inside-out). + sys::sceGuFrontFace(FrontFaceDirection::CounterClockwise); + // Vertex color -> material ambient + diffuse; light colors then carry + // the material x tint modulate (module docs). + sys::sceGuColorMaterial(LightComponent::AMBIENT | LightComponent::DIFFUSE); + sys::sceGuEnable(GuState::Lighting); + let sun = match env.sun { + Some((dir, color)) if dir != Vec3::ZERO => { + // GE directional lights point TOWARD the light source. + let to_light = ScePspFVector3 { x: -dir.x, y: -dir.y, z: -dir.z }; + sys::sceGuLight(0, LightType::Directional, LightComponent::DIFFUSE, &to_light); + Some(color) + } + _ => None, + }; + let ambient = env.ambient.map(|(sky, ground)| abgr_avg(sky, ground)); + let fog = env.fog; + + // -- meshes: opaques in tree order, then blended far -> near --------------- + let mut cur_blend: Option = None; + let mut cur_cull: Option = None; + for d in &draws { + let baked = &geoms()[&d.geom]; + if cur_blend != Some(d.blend) { + apply_blend(d.blend, fog); + cur_blend = Some(d.blend); + } + let cull = d.flags & mat_flags::DOUBLE_SIDED == 0; + if cur_cull != Some(cull) { + if cull { + sys::sceGuEnable(GuState::CullFace); + } else { + sys::sceGuDisable(GuState::CullFace); + } + cur_cull = Some(cull); + } + // Lighting-unit modulate: out = vtxColor x (ambient' + sun' x ndotl). + if d.flags & mat_flags::UNLIT != 0 { + sys::sceGuDisable(GuState::Light0); + sys::sceGuAmbient(d.color); + } else { + // Ambient rgb = avg hemisphere x material; alpha rides the + // ambient path on the GE, so it carries the material alpha. + let amb_rgb = abgr_mul(ambient.unwrap_or(0), d.color) & 0x00ff_ffff; + sys::sceGuAmbient(amb_rgb | (d.color & 0xff00_0000)); + match sun { + Some(color) => { + sys::sceGuLightColor(0, LightComponent::DIFFUSE, abgr_mul(color, d.color)); + sys::sceGuEnable(GuState::Light0); + } + None => sys::sceGuDisable(GuState::Light0), + } + } + sys::sceGuSetMatrix(sys::MatrixMode::Model, &to_psp_matrix(d.world)); + let mut done = 0usize; + while done < baked.indices.len() { + let n = (baked.indices.len() - done).min(MAX_PRIM_IDX); + sys::sceGuDrawArray( + GuPrimitive::Triangles, + VTYPE_MESH, + n as i32, + baked.indices.as_ptr().add(done) as *const c_void, + baked.verts.as_ptr() as *const c_void, + ); + done += n; + } + } + sys::sceGuSetMatrix(sys::MatrixMode::Model, &to_psp_matrix(Mat4::IDENTITY)); + + // -- pools: camera-facing sprites + view-aligned beams (unlit, unfogged) --- + sys::sceGuDisable(GuState::Lighting); + sys::sceGuDisable(GuState::Light0); + sys::sceGuDisable(GuState::Fog); + sys::sceGuDisable(GuState::CullFace); + sys::sceGuDepthMask(1); // pools never write depth + let right = cam.q * Vec3::X; + let up = cam.q * Vec3::Y; + let mut bound_glow = false; + for &pid in &sc.pools { + let Some(pool) = store.pool(pid) else { continue }; + if pool.count == 0 { + continue; + } + let Some(mat) = store.material(pool.mat) else { continue }; + if !bound_glow { + bind_glow(); + bound_glow = true; + } + if mat.flags & mat_flags::ADDITIVE != 0 { + sys::sceGuBlendFunc(BlendOp::Add, BlendFactor::SrcAlpha, BlendFactor::Fix, 0, 0xffffff); + } else { + sys::sceGuBlendFunc( + BlendOp::Add, + BlendFactor::SrcAlpha, + BlendFactor::OneMinusSrcAlpha, + 0, + 0, + ); + } + sys::sceGuEnable(GuState::Blend); + let bytes = pool.count * 6 * core::mem::size_of::(); + let verts = pool_alloc(bytes) as *mut PoolVert; + let mut vi = 0usize; + let mut quad = |corners: [Vec3; 4], uvs: [[f32; 2]; 4], color: u32| { + for i in [0usize, 1, 2, 0, 2, 3] { + *verts.add(vi) = PoolVert { + u: uvs[i][0], + v: uvs[i][1], + color, + x: corners[i].x, + y: corners[i].y, + z: corners[i].z, + }; + vi += 1; + } + }; + match pool.kind { + PoolKind::Sprite => { + for i in 0..pool.count { + let e = &pool.live[i * SPRITE_STRIDE..(i + 1) * SPRITE_STRIDE]; + let p = Vec3::new(e[0], e[1], e[2]); + let half = e[3] * 0.5; + quad( + [ + p - right * half + up * half, + p + right * half + up * half, + p + right * half - up * half, + p - right * half - up * half, + ], + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + abgr_mul(mat.color, pool.colors[i]), + ); + } + } + PoolKind::Beam => { + for i in 0..pool.count { + let e = &pool.live[i * BEAM_STRIDE..(i + 1) * BEAM_STRIDE]; + let a = Vec3::new(e[0], e[1], e[2]); + let b = Vec3::new(e[3], e[4], e[5]); + let mid = (a + b) * 0.5; + let side = (b - a).cross(cam.p - mid).normalize_or_zero() * (e[6] * 0.5); + // v pinned to 0.5: the soft falloff runs across width. + quad( + [a - side, b - side, b + side, a + side], + [[0.0, 0.5], [0.0, 0.5], [1.0, 0.5], [1.0, 0.5]], + abgr_mul(mat.color, pool.colors[i]), + ); + } + } + } + sys::sceKernelDcacheWritebackRange(verts as *const c_void, bytes as u32); + sys::sceGuDrawArray( + GuPrimitive::Triangles, + VTYPE_POOL, + vi as i32, + core::ptr::null(), + verts as *const c_void, + ); + } + + // -- restore the 2D pass state (ge.rs contract) ------------------------------ + sys::sceGuDisable(GuState::DepthTest); + sys::sceGuDisable(GuState::ClipPlanes); + sys::sceGuDisable(GuState::Fog); + sys::sceGuDepthMask(0); + sys::sceGuDisable(GuState::Texture2D); + if bound_glow { + sys::sceGuTexWrap(GuTexWrapMode::Repeat, GuTexWrapMode::Repeat); // sceGuInit default + } + sys::sceGuViewport(2048, 2048, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); + sys::sceGuEnable(GuState::Blend); + sys::sceGuBlendFunc(BlendOp::Add, BlendFactor::SrcAlpha, BlendFactor::OneMinusSrcAlpha, 0, 0); +} + +/// Split draws into [opaque..][blended..] preserving opaque tree order +/// (stable partition; the blended tail is re-sorted by depth right after). +fn partition_opaque(draws: &mut Vec) -> usize { + let mut opaque: Vec = Vec::with_capacity(draws.len()); + let mut blended: Vec = Vec::new(); + for d in draws.drain(..) { + if d.blend == Blend::Opaque { + opaque.push(d); + } else { + blended.push(d); + } + } + let split = opaque.len(); + opaque.extend(blended); + *draws = opaque; + split +} + +unsafe fn apply_blend(blend: Blend, fog: Option<(u32, f32, f32)>) { + match blend { + Blend::Opaque => { + sys::sceGuDisable(GuState::Blend); + sys::sceGuDepthMask(0); + } + Blend::Alpha => { + sys::sceGuEnable(GuState::Blend); + sys::sceGuBlendFunc( + BlendOp::Add, + BlendFactor::SrcAlpha, + BlendFactor::OneMinusSrcAlpha, + 0, + 0, + ); + sys::sceGuDepthMask(1); + } + Blend::Additive => { + sys::sceGuEnable(GuState::Blend); + sys::sceGuBlendFunc(BlendOp::Add, BlendFactor::SrcAlpha, BlendFactor::Fix, 0, 0xffffff); + sys::sceGuDepthMask(1); + } + } + match fog { + Some((color, near, far)) => { + // Additive fades OUT with distance, never toward fog color. + let c = if blend == Blend::Additive { 0 } else { color }; + sys::sceGuFog(near, far, c); + sys::sceGuEnable(GuState::Fog); + } + None => sys::sceGuDisable(GuState::Fog), + } +} + +/// Screen-space gouraud strip evaluating the desktop fs_sky per row: +/// ray elevation at the rect's horizontal center, `pow(1 - up, 2.5)` blend. +unsafe fn draw_sky(x: i32, y: i32, w: i32, h: i32, q: Quat, fov_y: f32, zenith: u32, horizon: u32) { + const ROWS: usize = 8; + let fwd = q * Vec3::NEG_Z; + let up_axis = q * Vec3::Y; + let tan_f = libm::tanf(fov_y * 0.5); + let chan = |c: u32, s: u32| (c >> s & 0xff) as f32 / 255.0; + let bytes = (ROWS + 1) * 2 * core::mem::size_of::(); + let verts = pool_alloc(bytes) as *mut SkyVert; + for r in 0..=ROWS { + let t = r as f32 / ROWS as f32; + let ndc_y = 1.0 - 2.0 * t; // +1 = rect top + let ray = (fwd + up_axis * (tan_f * ndc_y)).normalize_or_zero(); + let up_c = ray.y.clamp(0.0, 1.0); + let blend = libm::powf(1.0 - up_c, 2.5); + let mix = |s: u32| chan(zenith, s) + (chan(horizon, s) - chan(zenith, s)) * blend; + let color = pack_rgb(mix(0), mix(8), mix(16)); + let ry = y + ((t * h as f32) as i32).min(h); + *verts.add(r * 2) = SkyVert { color, x: x as i16, y: ry as i16, z: 0, _pad: 0 }; + *verts.add(r * 2 + 1) = + SkyVert { color, x: (x + w) as i16, y: ry as i16, z: 0, _pad: 0 }; + } + sys::sceKernelDcacheWritebackRange(verts as *const c_void, bytes as u32); + sys::sceGuDrawArray( + GuPrimitive::TriangleStrip, + VTYPE_SKY, + ((ROWS + 1) * 2) as i32, + core::ptr::null(), + verts as *const c_void, + ); +} + +unsafe fn bind_glow() { + sys::sceGuEnable(GuState::Texture2D); + sys::sceGuTexMode(TexturePixelFormat::Psm8888, 0, 0, 0); + sys::sceGuTexImage( + MipmapLevel::None, + GLOW_DIM as i32, + GLOW_DIM as i32, + GLOW_DIM as i32, + glow_texture() as *const c_void, + ); + sys::sceGuTexFunc(TextureEffect::Modulate, TextureColorComponent::Rgba); + sys::sceGuTexFilter(TextureFilter::Linear, TextureFilter::Linear); + sys::sceGuTexWrap(GuTexWrapMode::Clamp, GuTexWrapMode::Clamp); + sys::sceGuTexScale(1.0, 1.0); + sys::sceGuTexOffset(0.0, 0.0); +} diff --git a/native/src/lib.rs b/native/src/lib.rs index af651a6..93d20fe 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -26,6 +26,8 @@ pub mod c_heap; pub mod dbg; pub mod ffi; pub mod ge; +pub mod ge3d; pub mod host; pub mod pak; pub mod qjs_alloc; +pub mod scene3d; diff --git a/native/src/main.rs b/native/src/main.rs index c4b6e9a..22bf556 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -26,7 +26,7 @@ use psp::sys::DisplaySetBufSync; use psp::sys::DisplayPixelFormat; use psp::sys::{self, CtrlMode, GuContextType, GuSyncBehavior, GuSyncMode, IoOpenFlags, SceCtrlData}; -use pocketjs_psp::{dbg, ffi, ge, host, pak}; +use pocketjs_psp::{dbg, ffi, ge, host, pak, scene3d}; #[cfg(feature = "bench")] use pocketjs_psp::arena; @@ -127,6 +127,57 @@ unsafe fn trace_pair(prefix: &[u8], msg: &str) { } } +// --------------------------------------------------------------------------- +// Always-on perf probe (PSP.md §8 avg_work_us pattern, without the bench +// feature's file plumbing): per-frame guest-turn µs + GE-list-wait µs, +// summarized to stdout every PERF_WINDOW frames. Under PPSSPP the line lands +// in the emulator log; under PSPLINK it reaches the shell console. Answers +// the standing hardware question — is QuickJS per-frame 3D sim fast enough +// at 333 MHz — once someone runs it on the metal. +// --------------------------------------------------------------------------- + +const PERF_WINDOW: u32 = 300; + +struct PerfState { + frames: u32, + js_sum_us: u64, + work_sum_us: u64, + max_work_us: u64, + gu_wait_sum_us: u64, +} + +static mut PERF: PerfState = + PerfState { frames: 0, js_sum_us: 0, work_sum_us: 0, max_work_us: 0, gu_wait_sum_us: 0 }; + +#[inline] +unsafe fn perf_now() -> u64 { + sys::sceKernelGetSystemTimeWide() as u64 +} + +unsafe fn perf_record(js_us: u64, work_us: u64, gu_wait_us: u64) { + PERF.frames += 1; + PERF.js_sum_us += js_us; + PERF.work_sum_us += work_us; + PERF.gu_wait_sum_us += gu_wait_us; + if work_us > PERF.max_work_us { + PERF.max_work_us = work_us; + } + if PERF.frames < PERF_WINDOW { + return; + } + let n = PERF.frames as u64; + let line = alloc::format!( + "[pocketjs perf] frames={} avg_js_us={} avg_work_us={} max_work_us={} avg_gu_wait_us={} (budget 16667)\n", + n, + PERF.js_sum_us / n, + PERF.work_sum_us / n, + PERF.max_work_us, + PERF.gu_wait_sum_us / n, + ); + sys::sceIoWrite(sys::SceUid(1), line.as_ptr() as *const c_void, line.len()); + PERF = PerfState { frames: 0, js_sum_us: 0, work_sum_us: 0, max_work_us: 0, gu_wait_sum_us: 0 }; +} + #[cfg(feature = "bench")] #[derive(Clone, Copy)] struct BenchState { @@ -361,7 +412,9 @@ unsafe fn run() { trace("run: entered"); psp::enable_home_button(); trace("run: home button enabled"); - host::init_graphics(host::GfxConfig::default()); + // depth: scene3d composites (ge3d.rs) z-test inside their rects; the 2D + // pass never enables DepthTest, so ui-only output stays byte-identical. + host::init_graphics(host::GfxConfig { depth: true }); trace("run: graphics initialized"); // ---- Controller ---- @@ -410,6 +463,10 @@ unsafe fn run() { trace("run: register ui begin"); ffi::register(ctx, global, &textures, &sprites); trace("run: register ui ok"); + // globalThis.s3 — the Scene3dOps surface (scene3d.rs), before bundle eval + // so playset's detectScene3d finds it (graceful absence otherwise). + scene3d::register(ctx, global); + trace("run: register s3 ok"); // Expose the asset pack read-only as globalThis.__pak (zero-copy over // .rodata; free_func = None). Web/test hosts feed core through loadStyles/ @@ -460,6 +517,7 @@ unsafe fn run() { #[cfg_attr(not(feature = "capture"), allow(unused_variables, unused_mut))] let mut frame_count: u32 = 0; loop { + let perf_t0 = perf_now(); #[cfg(feature = "bench")] let bench_frame_start = bench_now_us(); if frame_count == 0 { @@ -485,6 +543,7 @@ unsafe fn run() { let mut args = [JS_NewInt32(ctx, mask), JS_NewInt32(ctx, analog)]; let r = JS_Call(ctx, frame_fn, global, 2, args.as_mut_ptr()); + let perf_after_js = perf_now(); #[cfg(feature = "bench")] let bench_after_js = bench_now_us(); if frame_count == 0 { @@ -510,6 +569,9 @@ unsafe fn run() { // borrowck happy about the single static-mut Ui (one thread; render // only reads atlases/textures, never the DrawList's owner mutably). let ui = ffi::ui(); + // scene3d bindViewport events -> PROP.scene3d writes, before tick + // lays out (uihost's apply_scene_bindings order). + scene3d::apply_bindings(ui); ui.tick(); #[cfg(feature = "bench")] let bench_after_tick = bench_now_us(); @@ -533,9 +595,11 @@ unsafe fn run() { // latency, the standard PSP double-buffered-list pattern. The vertex // pool and the display-list buffer are reused only after the sync, so // single instances of both stay sufficient. + let perf_before_sync = perf_now(); #[cfg(feature = "bench")] let bench_before_sync = bench_now_us(); sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait); + let perf_after_sync = perf_now(); #[cfg(feature = "bench")] bench_record_gpu(frame_count, bench_now_us().saturating_sub(bench_before_sync)); if frame_count == 0 { @@ -546,6 +610,7 @@ unsafe fn run() { trace("frame 0: vblank ok"); } sys::sceGuSwapBuffers(); + let perf_after_present = perf_now(); #[cfg(feature = "bench")] let bench_after_present = bench_now_us(); if frame_count == 0 { @@ -570,6 +635,13 @@ unsafe fn run() { trace("frame 0: gu start ok"); } ge::render(ffi::ui(), core::slice::from_raw_parts(words_ptr, words_len)); + perf_record( + perf_after_js.saturating_sub(perf_t0), + perf_now() + .saturating_sub(perf_t0) + .saturating_sub(perf_after_present.saturating_sub(perf_before_sync)), + perf_after_sync.saturating_sub(perf_before_sync), + ); #[cfg(feature = "bench")] let bench_after_render = bench_now_us(); #[cfg(feature = "bench")] diff --git a/native/src/scene3d.rs b/native/src/scene3d.rs new file mode 100644 index 0000000..c470003 --- /dev/null +++ b/native/src/scene3d.rs @@ -0,0 +1,440 @@ +//! QuickJS bindings: the `globalThis.s3` namespace — the PSP side of the +//! Scene3dOps contract (playset/scene3d/ops.ts). The retained state is the +//! SAME [`pocket_scene3d::Store`] the desktop uihost core uses (built here +//! with `default-features = false`: no_std + alloc, store only), so op +//! semantics cannot drift between hosts. +//! +//! Registration mirrors ffi.rs's `ui` pattern: JS_NewCFunction2 + +//! JS_SetPropertyStr onto one object installed on the global, one static +//! store, one JS thread. Batched writes (writePoses/writeSprites/writeBeams) +//! and geometry buffers arrive as typed arrays and are copied out before +//! returning to JS (alignment-safe; the desktop mount copies too); colors +//! and flags ride as f64 and convert through i64 (guest-side `>>> 0` may +//! exceed i32 range). +//! +//! Geometries are tessellated by the store at creation; ge3d bakes the GE +//! vertex/index buffers immediately after (ids are never reused, so the +//! baked registry never invalidates). + +use alloc::vec::Vec; + +use libquickjs_sys::*; +use pocket_scene3d::{PoolKind, Store}; +use pocketjs_core::{spec, Ui}; + +use crate::ffi::{add_fn, arg_f64, arg_i32, buffer_bytes}; + +static mut STORE: Option = None; + +/// The single scene3d store. Lazily created (plain-ui apps never touch it). +pub unsafe fn store() -> &'static mut Store { + if STORE.is_none() { + STORE = Some(Store::new()); + } + STORE.as_mut().unwrap() +} + +/// Forward queued bindViewport events into the ui core as PROP.scene3d +/// writes (spec op semantics: the core then emits SCENE_QUAD at the node's +/// laid-out rect). Call once per frame BEFORE ui.tick() — uihost's +/// apply_scene_bindings, ported. +pub unsafe fn apply_bindings(ui: &mut Ui) { + for (node, scene) in store().drain_binding_events() { + ui.set_prop(node, spec::prop::SCENE3D, scene as f64); + } +} + +// --------------------------------------------------------------------------- +// arg helpers +// --------------------------------------------------------------------------- + +#[inline] +unsafe fn arg_f32(ctx: *mut JSContext, argc: i32, argv: *mut JSValue, i: isize) -> f32 { + arg_f64(ctx, argc, argv, i) as f32 +} + +/// u32 payloads (colors, flags) arrive as JS numbers that may exceed i32 +/// range (`>>> 0` guest-side); route through f64 -> i64 -> u32 (desktop +/// mount.rs as_u32 parity). +#[inline] +unsafe fn arg_u32(ctx: *mut JSContext, argc: i32, argv: *mut JSValue, i: isize) -> u32 { + arg_f64(ctx, argc, argv, i) as i64 as u32 +} + +/// Copy a typed-array/ArrayBuffer arg out as f32s (byte copy, so any view +/// alignment is fine). Missing/non-buffer args decode empty. +unsafe fn arg_f32s(ctx: *mut JSContext, argc: i32, argv: *mut JSValue, i: isize) -> Vec { + arg_pods(ctx, argc, argv, i) +} + +/// Copy a typed-array/ArrayBuffer arg out as u32s. +unsafe fn arg_u32s(ctx: *mut JSContext, argc: i32, argv: *mut JSValue, i: isize) -> Vec { + arg_pods(ctx, argc, argv, i) +} + +unsafe fn arg_pods( + ctx: *mut JSContext, + argc: i32, + argv: *mut JSValue, + i: isize, +) -> Vec { + if (i as i32) >= argc { + return Vec::new(); + } + let Some((p, len)) = buffer_bytes(ctx, *argv.offset(i)) else { + return Vec::new(); + }; + let n = len / core::mem::size_of::(); + let mut out: Vec = Vec::with_capacity(n); + core::ptr::copy_nonoverlapping(p, out.as_mut_ptr() as *mut u8, n * core::mem::size_of::()); + out.set_len(n); + out +} + +/// `Float32Array | null` (geomMesh/geomHeightfield colors). +unsafe fn arg_f32s_opt( + ctx: *mut JSContext, + argc: i32, + argv: *mut JSValue, + i: isize, +) -> Option> { + if (i as i32) >= argc { + return None; + } + let v = *argv.offset(i); + if JS_IsNull(v) || JS_IsUndefined(v) { + return None; + } + buffer_bytes(ctx, v).map(|(p, len)| { + let n = len / 4; + let mut out: Vec = Vec::with_capacity(n); + core::ptr::copy_nonoverlapping(p, out.as_mut_ptr() as *mut u8, n * 4); + out.set_len(n); + out + }) +} + +// --------------------------------------------------------------------------- +// ops +// --------------------------------------------------------------------------- + +macro_rules! js_op { + ($name:ident, |$ctx:ident, $argc:ident, $argv:ident| $body:expr) => { + unsafe extern "C" fn $name( + $ctx: *mut JSContext, + _this: JSValue, + $argc: i32, + $argv: *mut JSValue, + ) -> JSValue { + $body + } + }; +} + +js_op!(js_scene_create, |ctx, _argc, _argv| JS_NewInt32(ctx, store().scene_create())); +js_op!(js_scene_destroy, |ctx, argc, argv| { + store().scene_destroy(arg_i32(ctx, argc, argv, 0)); + JS_UNDEFINED +}); + +js_op!(js_node_create, |ctx, argc, argv| JS_NewInt32( + ctx, + store().node_create(arg_i32(ctx, argc, argv, 0), arg_i32(ctx, argc, argv, 1)), +)); +js_op!(js_node_destroy, |ctx, argc, argv| { + store().node_destroy(arg_i32(ctx, argc, argv, 0)); + JS_UNDEFINED +}); +js_op!(js_node_set_parent, |ctx, argc, argv| { + store().node_set_parent(arg_i32(ctx, argc, argv, 0), arg_i32(ctx, argc, argv, 1)); + JS_UNDEFINED +}); +js_op!(js_node_set_visible, |ctx, argc, argv| { + store().node_set_visible(arg_i32(ctx, argc, argv, 0), arg_i32(ctx, argc, argv, 1) != 0); + JS_UNDEFINED +}); +js_op!(js_node_set_pose, |ctx, argc, argv| { + store().node_set_pose( + arg_i32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + arg_f32(ctx, argc, argv, 3), + arg_f32(ctx, argc, argv, 4), + arg_f32(ctx, argc, argv, 5), + arg_f32(ctx, argc, argv, 6), + arg_f32(ctx, argc, argv, 7), + ); + JS_UNDEFINED +}); +js_op!(js_node_set_scale, |ctx, argc, argv| { + store().node_set_scale( + arg_i32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + arg_f32(ctx, argc, argv, 3), + ); + JS_UNDEFINED +}); +js_op!(js_write_poses, |ctx, argc, argv| { + let buf = arg_f32s(ctx, argc, argv, 0); + let count = arg_i32(ctx, argc, argv, 1).max(0) as usize; + store().write_poses(&buf, count); + JS_UNDEFINED +}); + +js_op!(js_geom_box, |ctx, argc, argv| bake( + ctx, + store().geom_box( + arg_f32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + ), +)); +js_op!(js_geom_sphere, |ctx, argc, argv| bake( + ctx, + store().geom_sphere(arg_f32(ctx, argc, argv, 0), arg_i32(ctx, argc, argv, 1)), +)); +js_op!(js_geom_cylinder, |ctx, argc, argv| bake( + ctx, + store().geom_cylinder( + arg_f32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + arg_i32(ctx, argc, argv, 3), + ), +)); +js_op!(js_geom_cone, |ctx, argc, argv| bake( + ctx, + store().geom_cone( + arg_f32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_i32(ctx, argc, argv, 2), + ), +)); +js_op!(js_geom_plane, |ctx, argc, argv| bake( + ctx, + store().geom_plane(arg_f32(ctx, argc, argv, 0), arg_f32(ctx, argc, argv, 1)), +)); +js_op!(js_geom_torus, |ctx, argc, argv| bake( + ctx, + store().geom_torus( + arg_f32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_i32(ctx, argc, argv, 2), + arg_i32(ctx, argc, argv, 3), + ), +)); +js_op!(js_geom_mesh, |ctx, argc, argv| { + let positions = arg_f32s(ctx, argc, argv, 0); + let indices = arg_u32s(ctx, argc, argv, 1); + let colors = arg_f32s_opt(ctx, argc, argv, 2); + bake(ctx, store().geom_mesh(&positions, &indices, colors.as_deref())) +}); +js_op!(js_geom_heightfield, |ctx, argc, argv| { + let heights = arg_f32s(ctx, argc, argv, 4); + let colors = arg_f32s_opt(ctx, argc, argv, 5); + bake( + ctx, + store().geom_heightfield( + arg_f32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_i32(ctx, argc, argv, 2), + arg_i32(ctx, argc, argv, 3), + &heights, + colors.as_deref(), + ), + ) +}); +js_op!(js_geom_free, |ctx, argc, argv| { + let id = arg_i32(ctx, argc, argv, 0); + store().geom_free(id); + crate::ge3d::free_geom(id); + JS_UNDEFINED +}); + +/// Bake the GE buffers for a freshly created geom, then return its handle. +unsafe fn bake(ctx: *mut JSContext, id: i32) -> JSValue { + crate::ge3d::bake_geom(id, store()); + JS_NewInt32(ctx, id) +} + +js_op!(js_material, |ctx, argc, argv| JS_NewInt32( + ctx, + store().material_create(arg_u32(ctx, argc, argv, 0), arg_u32(ctx, argc, argv, 1)), +)); +js_op!(js_material_set_color, |ctx, argc, argv| { + store().material_set_color(arg_i32(ctx, argc, argv, 0), arg_u32(ctx, argc, argv, 1)); + JS_UNDEFINED +}); +js_op!(js_material_free, |ctx, argc, argv| { + store().material_free(arg_i32(ctx, argc, argv, 0)); + JS_UNDEFINED +}); + +js_op!(js_mesh_set, |ctx, argc, argv| { + store().mesh_set( + arg_i32(ctx, argc, argv, 0), + arg_i32(ctx, argc, argv, 1), + arg_i32(ctx, argc, argv, 2), + ); + JS_UNDEFINED +}); +js_op!(js_node_set_tint, |ctx, argc, argv| { + store().node_set_tint(arg_i32(ctx, argc, argv, 0), arg_u32(ctx, argc, argv, 1)); + JS_UNDEFINED +}); + +js_op!(js_sun, |ctx, argc, argv| { + store().sun( + arg_i32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + arg_f32(ctx, argc, argv, 3), + arg_u32(ctx, argc, argv, 4), + ); + JS_UNDEFINED +}); +js_op!(js_ambient, |ctx, argc, argv| { + store().ambient( + arg_i32(ctx, argc, argv, 0), + arg_u32(ctx, argc, argv, 1), + arg_u32(ctx, argc, argv, 2), + ); + JS_UNDEFINED +}); +js_op!(js_fog, |ctx, argc, argv| { + store().fog( + arg_i32(ctx, argc, argv, 0), + arg_u32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + arg_f32(ctx, argc, argv, 3), + ); + JS_UNDEFINED +}); +js_op!(js_sky, |ctx, argc, argv| { + store().sky( + arg_i32(ctx, argc, argv, 0), + arg_u32(ctx, argc, argv, 1), + arg_u32(ctx, argc, argv, 2), + ); + JS_UNDEFINED +}); + +js_op!(js_camera, |ctx, argc, argv| { + store().camera( + arg_i32(ctx, argc, argv, 0), + arg_f32(ctx, argc, argv, 1), + arg_f32(ctx, argc, argv, 2), + arg_f32(ctx, argc, argv, 3), + arg_f32(ctx, argc, argv, 4), + arg_f32(ctx, argc, argv, 5), + arg_f32(ctx, argc, argv, 6), + arg_f32(ctx, argc, argv, 7), + arg_f32(ctx, argc, argv, 8), + arg_f32(ctx, argc, argv, 9), + arg_f32(ctx, argc, argv, 10), + ); + JS_UNDEFINED +}); + +js_op!(js_sprite_pool, |ctx, argc, argv| JS_NewInt32( + ctx, + store().pool_create( + arg_i32(ctx, argc, argv, 0), + arg_f64(ctx, argc, argv, 1), + arg_i32(ctx, argc, argv, 2), + PoolKind::Sprite, + ), +)); +js_op!(js_beam_pool, |ctx, argc, argv| JS_NewInt32( + ctx, + store().pool_create( + arg_i32(ctx, argc, argv, 0), + arg_f64(ctx, argc, argv, 1), + arg_i32(ctx, argc, argv, 2), + PoolKind::Beam, + ), +)); +js_op!(js_write_sprites, |ctx, argc, argv| { + pool_write(ctx, argc, argv, PoolKind::Sprite) +}); +js_op!(js_write_beams, |ctx, argc, argv| { + pool_write(ctx, argc, argv, PoolKind::Beam) +}); +js_op!(js_pool_free, |ctx, argc, argv| { + store().pool_free(arg_i32(ctx, argc, argv, 0)); + JS_UNDEFINED +}); + +/// Shared body of writeSprites/writeBeams: (pool, f32 buf, u32 colors, count). +unsafe fn pool_write(ctx: *mut JSContext, argc: i32, argv: *mut JSValue, kind: PoolKind) -> JSValue { + let pool = arg_i32(ctx, argc, argv, 0); + let buf = arg_f32s(ctx, argc, argv, 1); + let colors = arg_u32s(ctx, argc, argv, 2); + let count = arg_f64(ctx, argc, argv, 3); + store().pool_write(pool, kind, &buf, &colors, count); + JS_UNDEFINED +} + +js_op!(js_bind_viewport, |ctx, argc, argv| { + store().bind_viewport(arg_i32(ctx, argc, argv, 0), arg_i32(ctx, argc, argv, 1)); + JS_UNDEFINED +}); + +// --------------------------------------------------------------------------- +// registration +// --------------------------------------------------------------------------- + +/// Install `globalThis.s3` (the full Scene3dOps surface). Call before the +/// bundle evals, alongside ffi::register's `ui`. +pub unsafe fn register(ctx: *mut JSContext, global: JSValue) { + let s3 = JS_NewObject(ctx); + + add_fn(ctx, s3, b"sceneCreate\0", js_scene_create, 0); + add_fn(ctx, s3, b"sceneDestroy\0", js_scene_destroy, 1); + add_fn(ctx, s3, b"nodeCreate\0", js_node_create, 2); + add_fn(ctx, s3, b"nodeDestroy\0", js_node_destroy, 1); + add_fn(ctx, s3, b"nodeSetParent\0", js_node_set_parent, 2); + add_fn(ctx, s3, b"nodeSetVisible\0", js_node_set_visible, 2); + add_fn(ctx, s3, b"nodeSetPose\0", js_node_set_pose, 8); + add_fn(ctx, s3, b"nodeSetScale\0", js_node_set_scale, 4); + add_fn(ctx, s3, b"writePoses\0", js_write_poses, 2); + add_fn(ctx, s3, b"geomBox\0", js_geom_box, 3); + add_fn(ctx, s3, b"geomSphere\0", js_geom_sphere, 2); + add_fn(ctx, s3, b"geomCylinder\0", js_geom_cylinder, 4); + add_fn(ctx, s3, b"geomCone\0", js_geom_cone, 3); + add_fn(ctx, s3, b"geomPlane\0", js_geom_plane, 2); + add_fn(ctx, s3, b"geomTorus\0", js_geom_torus, 4); + add_fn(ctx, s3, b"geomMesh\0", js_geom_mesh, 3); + add_fn(ctx, s3, b"geomHeightfield\0", js_geom_heightfield, 6); + add_fn(ctx, s3, b"geomFree\0", js_geom_free, 1); + add_fn(ctx, s3, b"material\0", js_material, 2); + add_fn(ctx, s3, b"materialSetColor\0", js_material_set_color, 2); + add_fn(ctx, s3, b"materialFree\0", js_material_free, 1); + add_fn(ctx, s3, b"meshSet\0", js_mesh_set, 3); + add_fn(ctx, s3, b"nodeSetTint\0", js_node_set_tint, 2); + add_fn(ctx, s3, b"sun\0", js_sun, 5); + add_fn(ctx, s3, b"ambient\0", js_ambient, 3); + add_fn(ctx, s3, b"fog\0", js_fog, 4); + add_fn(ctx, s3, b"sky\0", js_sky, 3); + add_fn(ctx, s3, b"camera\0", js_camera, 11); + add_fn(ctx, s3, b"spritePool\0", js_sprite_pool, 3); + add_fn(ctx, s3, b"writeSprites\0", js_write_sprites, 4); + add_fn(ctx, s3, b"beamPool\0", js_beam_pool, 3); + add_fn(ctx, s3, b"writeBeams\0", js_write_beams, 4); + add_fn(ctx, s3, b"poolFree\0", js_pool_free, 1); + add_fn(ctx, s3, b"bindViewport\0", js_bind_viewport, 2); + + // Honest host label (ops.ts __host; native hosts omit __serialize). + let host = JS_NewStringLen(ctx, b"psp".as_ptr(), 3); + JS_SetPropertyStr(ctx, s3, b"__host\0".as_ptr() as *const _, host); + + // JS_SetPropertyStr consumes ownership of s3. + JS_SetPropertyStr(ctx, global, b"s3\0".as_ptr() as *const _, s3); +} + +// libquickjs-sys omits JS_NewStringLen; the linked QuickJS C library provides +// it (same local-extern pattern as ffi.rs). +extern "C" { + fn JS_NewStringLen(ctx: *mut JSContext, str1: *const u8, len1: usize) -> JSValue; +} diff --git a/package.json b/package.json index f1f9cb7..4f4d8eb 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "serve": "bun host-web/serve.ts", "golden": "bun test/golden.ts", "e2e": "bun test/e2e-ppsspp.ts", - "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts", + "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts && bun scripts/build.ts rally-main >/dev/null && bun scripts/build.ts snake-main >/dev/null && bun scripts/build.ts dogfight-main >/dev/null && bun scripts/build.ts runner-main >/dev/null && bun test --conditions=browser playset/test/", "tape": "bun scripts/tape.ts", "tape:check": "bun scripts/tape.ts replay hero-main test/tapes/hero-main.tape.json --assert test/tapes/hero-main.hashes.json", "devtools": "bun scripts/devtools.ts", diff --git a/playset/ATTRIBUTION.md b/playset/ATTRIBUTION.md new file mode 100644 index 0000000..a3547c5 --- /dev/null +++ b/playset/ATTRIBUTION.md @@ -0,0 +1,37 @@ +# Attribution + +Substantial portions of `playset/modules/` are TypeScript ports of +**GameBlocks** (https://github.com/xt4d/GameBlocks), © 2026 Weihao Cheng +(xt4d), used under the MIT license reproduced below. Ported files carry a header identifying their +source module; behavioral changes beyond the Three.js→playset substitution +are called out in each header and in `summary.md`. + +`playset/math/` is an independent clean-room implementation of a subset of +the Three.js math API (method names and signatures mirror three@0.161 for +port compatibility; no Three.js source code was copied). + +## GameBlocks license (MIT) + +``` +MIT License + +Copyright (c) 2026 Weihao Cheng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/playset/README.md b/playset/README.md new file mode 100644 index 0000000..e60cfc6 --- /dev/null +++ b/playset/README.md @@ -0,0 +1,62 @@ +# @pocketjs/playset + +*Game-mechanism playsets for Pocket: the GameBlocks module taxonomy, ported to +TypeScript and extended, running on the `scene3d` surface.* + +A playset is not an engine and not an npm dependency — it is a library of +copy-in game mechanisms (motion controllers, camera rigs, AI behaviors, +gameplay referees, world builders, HUD components) that a game project vendors +and adapts. The taxonomy and most module semantics come from +[GameBlocks](https://github.com/xt4d/GameBlocks) (74 ported modules); playset +adds what a renderer-free, deterministic port needs: a collision core, blob +shadows, and ABGR color plumbing. See `ATTRIBUTION.md`. + +## Three layers + +1. **`scene3d/`** — the presentation surface (RUNTIMES.md §3): a closed, + write-only 3D vocabulary sized for fixed-function GPUs. Ops contract + (`ops.ts`), guest client (`Scene3D`, `SceneNode`, `Camera3D`, sprite/beam + pools), ``, and a renderless sim host (`sim.ts`) that replays + op streams headlessly. No reads: picking and collision are the guest's job. +2. **`modules/`** — the vendored module library, organized by GameBlocks' + categories (actor motion, behavior, camera, gameplay, math, physics, + user interface, world). `math/` supplies three-compatible value types + (`Vector3`, `Quaternion`, ...) so sim code ports as an import swap; + `loop.ts` supplies the fixed-step driver. +3. **`SKILL.md`** — the porting skill: the copy-into-project workflow, the + determinism rules, and the Three.js → playset mapping table for bringing + browser mini-games to Pocket. + +Where GameBlocks depends on Three.js and Rapier from a CDN, playset modules +have **no runtime dependencies**: Three.js math → `math/`, scene graph → +`scene3d/`, Rapier → `modules/physics/collision-world.ts`. HUD modules build +on `@pocketjs/framework/*` + `solid-js` like any Pocket UI. + +## Determinism + +- **Fixed-step loop** — `createGameLoop` steps the sim at exactly 1/60 s, + `60/hz` times per virtual frame, so the trajectory is identical at every + `simulationHz` and input tapes replay byte-exact. +- **Seeded PRNG** — `RandomGenerator` is bit-identical to GameBlocks' + Mulberry32: seeded alike, both engines draw the same stream, which makes + cross-engine golden traces possible. +- **Virtual clock** — the wall clock is never an input; `Clock` reads + `virtualNow` by default (DETERMINISM.md). +- **Host-sim goldens** — the scene3d sim host serializes canonical, + platform-stable state, so a frame's pixels are a pure function of the op + stream and suites in `test/` pin behavior without a GPU. + +## Status + +- TypeScript reference implementation, complete with the renderless scene3d + sim host and the test suites under `test/`. +- Native cores (wgpu desktop, sceGu PSP) are next — guest program first, + cores follow (RUNTIMES.md). CollisionWorld is deliberately v1 (planar + resolution, static shapes); the native Rust physics block is the upgrade + path. + +## See also + +- `SKILL.md` — workflow + Three.js → playset mapping. +- `summary.md` — the full module catalog with per-module deviation notes. +- `ATTRIBUTION.md` — GameBlocks provenance and license. diff --git a/playset/SKILL.md b/playset/SKILL.md new file mode 100644 index 0000000..1310448 --- /dev/null +++ b/playset/SKILL.md @@ -0,0 +1,82 @@ +--- +name: playset +description: Use when building 3D mini-games for Pocket with playset modules — coordinate systems, actor motion, camera rigs, collision-aware movement, gameplay state, world building — or when porting a Three.js/GameBlocks-style browser game to Pocket. +--- + +# Playset + +Use playset as source material before inventing 3D game systems from scratch, +and as the target vocabulary when porting Three.js mini-games to Pocket. + +## Workflow + +- Use `modules/math/world-basis.ts` as the single source of truth for + gameplay-space coordinates, forward/right/up axes, planar movement, + heading, and control-signal transforms. +- Review `summary.md` and select modules that can support the game + implementation. Prioritize existing modules whenever possible, especially + for motion controllers, camera rigs, and the collision world. +- Copy the `playset/` tree (`math/`, `scene3d/`, `modules/`, `loop.ts`) into + the game project, preserving relative directory structure so imports keep + working. Reuse modules as-is when they satisfy the design; adapt from the + existing implementation instead of rewriting when they don't. +- Structure the game as a deterministic fixed-step machine: + `createGameLoop({ step, render })` from `loop.ts` — ALL simulation in + `step` (1/60 s, hz-invariant), ALL scene writes in `render`, ending with + `scene.flush()`. Never read the wall clock; never call `Math.random` + (inject `RandomGenerator` seeds); express delays with the injected `Clock` + or `after()` from `@pocketjs/framework/clock`. +- Presentation goes through one `Scene3D` bound to a ``; the HUD + is ordinary PocketJS UI composed as children of the viewport. Picking and + collision are guest-side: `modules/physics/collision-world.ts`, never a + scene query. +- Create `playset_usage.md` documenting the selected modules, their purpose, + reuse/adaptation status, key changes, and game integration. + +## Porting a Three.js game + +1. Locate the sim: state + `step(state, input)` logic ports nearly verbatim + (swap `three` math imports for `math/index.ts`). If state lives on the + scene graph (`mesh.position` as the source of truth), extract it into + plain state first — the ops boundary requires it anyway. +2. Map presentation with the table below. Visual factories become + `Scene3D`-based builders returning `SceneNode` trees (see + `modules/world/object/factory/` for the idiom). +3. Replace `Raycaster` picking with `CollisionWorld.raycast` + + `Camera3D.rayFromNdc`; replace physics with `KinematicBatchResolver` / + the arcade car resolver; register environment colliders in the + `CollisionWorld`. +4. Make the original deterministic FIRST (seeded PRNG, fixed step) — a small + diff — then record input-tape goldens on both sides and compare state + traces (host-sim on the Pocket side). + +## Three.js → playset mapping + +| Three.js | playset | +|---|---| +| `Scene`, `Group`, `Object3D` | `Scene3D`, `scene.node()` (SceneNode) | +| `Mesh(geometry, material)` + `add` | `scene.mesh(geomId, matId, parent)` | +| `BoxGeometry(w,h,d)` | `scene.box(w/2, h/2, d/2)` — HALF extents | +| `Sphere/Cylinder/Cone/Plane/TorusGeometry` | `scene.sphere/cylinder/cone/plane/torus` | +| `BufferGeometry` (indexed) | `scene.meshGeom(positions, indices, colors)` | +| terrain grid mesh | `scene.heightfield(w, d, cols, rows, heights, colors)` | +| `MeshStandardMaterial{color}` | `scene.material(abgr, 0)` (vertex-lit; PBR params drop) | +| `MeshBasicMaterial` | `MAT.unlit` | +| `vertexColors: true` | `MAT.vertexColors` | +| `transparent`/`AdditiveBlending`/`DoubleSide` | `MAT.transparent` / `MAT.additive` / `MAT.doubleSided` | +| `DirectionalLight` + `HemisphereLight` | `scene.sun(dir, color)` + `scene.ambient(sky, ground)` | +| `Fog(color, near, far)` | `scene.fog(color, near, far)` | +| shadow maps (`castShadow` etc.) | `modules/world/blob-shadow.ts` decals | +| `Points`/particles | `scene.spritePool(capacity, mat)` + per-frame writes | +| `LineSegments` tracers / trails | `scene.beamPool(capacity, mat)` | +| `Sprite` billboards | camera-quaternion-billboarded node groups | +| `ShaderMaterial` | layered additive nodes (see `jet-flame.ts` for the pattern) | +| `Raycaster` | `CollisionWorld.raycast` + `Camera3D.rayFromNdc` | +| Rapier world / character controller | `CollisionWorld` + `KinematicBatchResolver` | +| Rapier raycast vehicle | `DynamicCarBatchResolver` (arcade approximation) | +| DOM / Canvas2D HUD | PocketJS `View`/`Text` components (`modules/user-interface/`) | +| `requestAnimationFrame` + dt clamp | `createGameLoop` (fixed 1/60, hz-invariant) | +| `Math.random()` / `Date.now()` | injected `RandomGenerator` / `Clock` (virtual time) | + +Colors are u32 ABGR on the surface; module files carry an `rgbToAbgr` helper +for 0xRRGGBB literals. diff --git a/playset/index.ts b/playset/index.ts new file mode 100644 index 0000000..b34502f --- /dev/null +++ b/playset/index.ts @@ -0,0 +1,12 @@ +// @pocketjs/playset — game-mechanism playsets for Pocket. +// +// Structure (see SKILL.md for the copy-into-project workflow): +// math/ three-compatible math value types (Vector3, Quaternion, ...) +// scene3d/ the scene3d presentation surface (ops contract, client, viewport) +// modules/ the ported GameBlocks module library (motion, camera, gameplay, +// behavior, world, user-interface) — vendored per game project +// loop.ts fixed-step game loop on the virtual clock + +export * from "./math/index.ts"; +export * from "./scene3d/index.ts"; +export { createGameLoop, FIXED_DT, type GameInput, type GameLoopOptions } from "./loop.ts"; diff --git a/playset/loop.ts b/playset/loop.ts new file mode 100644 index 0000000..cf6f373 --- /dev/null +++ b/playset/loop.ts @@ -0,0 +1,53 @@ +// playset/loop.ts — the fixed-step game loop, wired to the virtual clock. +// +// GameBlocks demos run `FIXED_STEP = 1/60` state machines driven by rAF with +// dt clamping. Pocket already HAS the stronger version of that contract +// (DETERMINISM.md): one `frame()` per virtual frame, `60/hz` core ticks each. +// This helper folds a playset game into it: the sim ALWAYS steps at 1/60 s — +// on a low-Hz guest it steps 60/hz times per virtual frame — so the +// trajectory is identical at every simulationHz (the subsampling theorem +// extends from pixels to game state), input tapes replay byte-exact, and +// host-sim golden traces work unchanged. +// +// Usage (inside a component, e.g. the app root or a owner): +// +// createGameLoop({ +// step: (dt, input) => { game.step(state, input, dt); }, // 1/60 fixed +// render: () => { syncVisuals(state); scene.flush(); }, // once/frame +// }); + +import { onFrame, analogX, analogY } from "@pocketjs/framework/lifecycle"; +import { ticksPerFrame } from "@pocketjs/framework/clock"; + +/** The fixed simulation timestep (matches spec FIXED_DT: one core tick). */ +export const FIXED_DT = 1 / 60; + +export interface GameInput { + /** spec BTN bitmask as delivered to frame(). */ + buttons: number; + /** Analog nub, deadzoned, -1..1 (0 on stickless hosts). */ + analogX: number; + analogY: number; +} + +export interface GameLoopOptions { + /** Called `60/hz` times per virtual frame with dt = FIXED_DT, always. */ + step: (dt: number, input: GameInput) => void; + /** Called once per virtual frame AFTER the catch-up steps — push poses to + * the scene and flush here (presentation runs at the guest rate; the sim + * never skips or stretches a step). */ + render?: (input: GameInput) => void; +} + +/** Register the loop (component-scoped — unregisters with its owner). */ +export function createGameLoop(opts: GameLoopOptions): void { + const input: GameInput = { buttons: 0, analogX: 0, analogY: 0 }; + onFrame((buttons) => { + input.buttons = buttons; + input.analogX = analogX(); + input.analogY = analogY(); + const n = ticksPerFrame(); + for (let i = 0; i < n; i++) opts.step(FIXED_DT, input); + opts.render?.(input); + }); +} diff --git a/playset/math/color.ts b/playset/math/color.ts new file mode 100644 index 0000000..423f39c --- /dev/null +++ b/playset/math/color.ts @@ -0,0 +1,338 @@ +// playset/math/color.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. +// +// Color management matches three@0.161 defaults: `ColorManagement.enabled` +// is true and the working color space is Linear-sRGB, so `new Color(hex)`, +// `set(hex)`, `setStyle(...)`, `getHex()` and `getHexString()` convert +// through the standard sRGB transfer function exactly like three, while +// `setRGB`/`setHSL`/`getHSL`/`offsetHSL`/`lerp` operate directly in the +// working space. Set `ColorManagement.enabled = false` for raw sRGB +// components (same escape hatch as three). +// +// Scope note: CSS color keywords ('rebeccapurple', ...) are not supported in +// setStyle — no GameBlocks module uses them (hex numbers only). Hex, rgb()/ +// rgba() and hsl()/hsla() strings are supported. + +import { clamp, euclideanModulo, lerp } from "./math-utils.ts"; + +export const SRGBColorSpace = "srgb"; +export const LinearSRGBColorSpace = "srgb-linear"; +export type ColorSpace = typeof SRGBColorSpace | typeof LinearSRGBColorSpace; + +export type ColorRepresentation = Color | number | string; + +export interface HSL { + h: number; + s: number; + l: number; +} + +export interface RGB { + r: number; + g: number; + b: number; +} + +/** Standard sRGB EOTF: gamma-encoded component → linear. */ +export function SRGBToLinear(c: number): number { + return c < 0.04045 ? c * 0.0773993808 : Math.pow(c * 0.9478672986 + 0.0521327014, 2.4); +} + +/** Standard sRGB OETF: linear component → gamma-encoded. */ +export function LinearToSRGB(c: number): number { + return c < 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 0.41666) - 0.055; +} + +export const ColorManagement = { + /** three@0.161 default: color management on. */ + enabled: true, + workingColorSpace: LinearSRGBColorSpace as ColorSpace, + + convert(color: RGB, sourceColorSpace: ColorSpace, targetColorSpace: ColorSpace): RGB { + if ( + this.enabled === false || + sourceColorSpace === targetColorSpace || + !sourceColorSpace || + !targetColorSpace + ) { + return color; + } + const fn = sourceColorSpace === SRGBColorSpace ? SRGBToLinear : LinearToSRGB; + color.r = fn(color.r); + color.g = fn(color.g); + color.b = fn(color.b); + return color; + }, + + toWorkingColorSpace(color: RGB, sourceColorSpace: ColorSpace): RGB { + return this.convert(color, sourceColorSpace, this.workingColorSpace); + }, + + fromWorkingColorSpace(color: RGB, targetColorSpace: ColorSpace): RGB { + return this.convert(color, this.workingColorSpace, targetColorSpace); + }, +}; + +/** Wikipedia HSL→RGB helper; p is the low value, q the high value. */ +function hue2rgb(p: number, q: number, t: number): number { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * 6 * (2 / 3 - t); + return p; +} + +/** + * An RGB color with components in the working color space (Linear-sRGB by + * default, matching three). Mutating methods return `this`. + */ +export class Color { + readonly isColor: true = true; + + r = 1; + g = 1; + b = 1; + + constructor(r?: ColorRepresentation, g?: number, b?: number) { + if (r === undefined) return; + if (g === undefined || b === undefined) { + this.set(r); + } else { + this.setRGB(r as number, g, b); + } + } + + set(color: ColorRepresentation): this; + set(r: number, g: number, b: number): this; + set(color: ColorRepresentation, g?: number, b?: number): this { + if (g !== undefined && b !== undefined) { + return this.setRGB(color as number, g, b); + } + if (typeof color === "object" && color.isColor) { + return this.copy(color); + } + if (typeof color === "number") { + return this.setHex(color); + } + if (typeof color === "string") { + return this.setStyle(color); + } + return this; + } + + setScalar(scalar: number): this { + this.r = scalar; + this.g = scalar; + this.b = scalar; + return this; + } + + setHex(hex: number, colorSpace: ColorSpace = SRGBColorSpace): this { + hex = Math.floor(hex); + this.r = ((hex >> 16) & 255) / 255; + this.g = ((hex >> 8) & 255) / 255; + this.b = (hex & 255) / 255; + ColorManagement.toWorkingColorSpace(this, colorSpace); + return this; + } + + setRGB(r: number, g: number, b: number, colorSpace: ColorSpace = ColorManagement.workingColorSpace): this { + this.r = r; + this.g = g; + this.b = b; + ColorManagement.toWorkingColorSpace(this, colorSpace); + return this; + } + + setHSL(h: number, s: number, l: number, colorSpace: ColorSpace = ColorManagement.workingColorSpace): this { + h = euclideanModulo(h, 1); + s = clamp(s, 0, 1); + l = clamp(l, 0, 1); + + if (s === 0) { + this.r = this.g = this.b = l; + } else { + const q = l <= 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + this.r = hue2rgb(p, q, h + 1 / 3); + this.g = hue2rgb(p, q, h); + this.b = hue2rgb(p, q, h - 1 / 3); + } + + ColorManagement.toWorkingColorSpace(this, colorSpace); + return this; + } + + /** + * Parses '#rgb'/'#rrggbb' hex strings and rgb()/rgba()/hsl()/hsla() + * functional notation. CSS color keywords are not supported (see header). + */ + setStyle(style: string, colorSpace: ColorSpace = SRGBColorSpace): this { + const fn = /^(\w+)\(([^)]*)\)$/.exec(style.trim()); + if (fn) { + const name = fn[1]!; + const components = fn[2]!; + let m: RegExpExecArray | null; + + switch (name) { + case "rgb": + case "rgba": + m = /^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?$/.exec(components); + if (m) { + // rgb(255, 0, 0) + return this.setRGB( + Math.min(255, parseInt(m[1]!, 10)) / 255, + Math.min(255, parseInt(m[2]!, 10)) / 255, + Math.min(255, parseInt(m[3]!, 10)) / 255, + colorSpace, + ); + } + m = /^\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*[\d.]+\s*)?$/.exec(components); + if (m) { + // rgb(100%, 0%, 0%) + return this.setRGB( + Math.min(100, parseInt(m[1]!, 10)) / 100, + Math.min(100, parseInt(m[2]!, 10)) / 100, + Math.min(100, parseInt(m[3]!, 10)) / 100, + colorSpace, + ); + } + break; + case "hsl": + case "hsla": + m = /^\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*[\d.]+\s*)?$/.exec(components); + if (m) { + // hsl(120, 50%, 50%) + return this.setHSL( + parseFloat(m[1]!) / 360, + parseFloat(m[2]!) / 100, + parseFloat(m[3]!) / 100, + colorSpace, + ); + } + break; + } + return this; + } + + const hex = /^#([A-Fa-f\d]+)$/.exec(style.trim()); + if (hex) { + const digits = hex[1]!; + if (digits.length === 3) { + // #ff0 shorthand + return this.setRGB( + parseInt(digits.charAt(0), 16) / 15, + parseInt(digits.charAt(1), 16) / 15, + parseInt(digits.charAt(2), 16) / 15, + colorSpace, + ); + } + if (digits.length === 6) { + return this.setHex(parseInt(digits, 16), colorSpace); + } + } + return this; + } + + clone(): Color { + return new Color().copy(this); + } + + copy(color: Color): this { + this.r = color.r; + this.g = color.g; + this.b = color.b; + return this; + } + + getHex(colorSpace: ColorSpace = SRGBColorSpace): number { + ColorManagement.fromWorkingColorSpace(_color.copy(this), colorSpace); + return ( + Math.round(clamp(_color.r * 255, 0, 255)) * 65536 + + Math.round(clamp(_color.g * 255, 0, 255)) * 256 + + Math.round(clamp(_color.b * 255, 0, 255)) + ); + } + + getHexString(colorSpace: ColorSpace = SRGBColorSpace): string { + return ("000000" + this.getHex(colorSpace).toString(16)).slice(-6); + } + + getHSL(target: HSL, colorSpace: ColorSpace = ColorManagement.workingColorSpace): HSL { + ColorManagement.fromWorkingColorSpace(_color.copy(this), colorSpace); + const r = _color.r, g = _color.g, b = _color.b; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let hue = 0; + let saturation = 0; + const lightness = (min + max) / 2.0; + + if (min !== max) { + const delta = max - min; + saturation = lightness <= 0.5 ? delta / (max + min) : delta / (2 - max - min); + switch (max) { + case r: + hue = (g - b) / delta + (g < b ? 6 : 0); + break; + case g: + hue = (b - r) / delta + 2; + break; + case b: + hue = (r - g) / delta + 4; + break; + } + hue /= 6; + } + + target.h = hue; + target.s = saturation; + target.l = lightness; + return target; + } + + getRGB(target: RGB, colorSpace: ColorSpace = ColorManagement.workingColorSpace): RGB { + ColorManagement.fromWorkingColorSpace(_color.copy(this), colorSpace); + target.r = _color.r; + target.g = _color.g; + target.b = _color.b; + return target; + } + + /** Adds the given h/s/l deltas to this color (hue wraps, s/l clamp). */ + offsetHSL(h: number, s: number, l: number): this { + this.getHSL(_hsl); + return this.setHSL(_hsl.h + h, _hsl.s + s, _hsl.l + l); + } + + lerp(color: Color, alpha: number): this { + this.r += (color.r - this.r) * alpha; + this.g += (color.g - this.g) * alpha; + this.b += (color.b - this.b) * alpha; + return this; + } + + lerpColors(color1: Color, color2: Color, alpha: number): this { + this.r = lerp(color1.r, color2.r, alpha); + this.g = lerp(color1.g, color2.g, alpha); + this.b = lerp(color1.b, color2.b, alpha); + return this; + } + + multiplyScalar(s: number): this { + this.r *= s; + this.g *= s; + this.b *= s; + return this; + } + + equals(c: Color): boolean { + return c.r === this.r && c.g === this.g && c.b === this.b; + } +} + +// Module-level scratch temporaries (three's own pattern). +const _color = /*@__PURE__*/ new Color(); +const _hsl: HSL = { h: 0, s: 0, l: 0 }; diff --git a/playset/math/euler.ts b/playset/math/euler.ts new file mode 100644 index 0000000..28b2bdc --- /dev/null +++ b/playset/math/euler.ts @@ -0,0 +1,153 @@ +// playset/math/euler.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { clamp } from "./math-utils.ts"; +import { Matrix4 } from "./matrix4.ts"; +import { Quaternion } from "./quaternion.ts"; + +export type EulerOrder = "XYZ" | "YXZ" | "ZXY" | "ZYX" | "YZX" | "XZY"; + +/** + * Intrinsic Tait-Bryan angles in radians, applied in `order` (three + * convention; default 'XYZ'). Mutating methods return `this`. + */ +export class Euler { + static DEFAULT_ORDER: EulerOrder = "XYZ"; + + readonly isEuler: true = true; + + x: number; + y: number; + z: number; + order: EulerOrder; + + constructor(x = 0, y = 0, z = 0, order: EulerOrder = Euler.DEFAULT_ORDER) { + this.x = x; + this.y = y; + this.z = z; + this.order = order; + } + + set(x: number, y: number, z: number, order: EulerOrder = this.order): this { + this.x = x; + this.y = y; + this.z = z; + this.order = order; + return this; + } + + clone(): Euler { + return new Euler(this.x, this.y, this.z, this.order); + } + + copy(euler: Euler): this { + this.x = euler.x; + this.y = euler.y; + this.z = euler.z; + this.order = euler.order; + return this; + } + + /** + * Extracts euler angles from `m`, whose upper-3x3 is assumed to be a pure + * (unscaled) rotation matrix. Standard per-order extraction with the same + * gimbal-lock fallbacks as three. + */ + setFromRotationMatrix(m: Matrix4, order: EulerOrder = this.order): this { + const te = m.elements; + const m11 = te[0]!, m12 = te[4]!, m13 = te[8]!; + const m21 = te[1]!, m22 = te[5]!, m23 = te[9]!; + const m31 = te[2]!, m32 = te[6]!, m33 = te[10]!; + + switch (order) { + case "XYZ": + this.y = Math.asin(clamp(m13, -1, 1)); + if (Math.abs(m13) < 0.9999999) { + this.x = Math.atan2(-m23, m33); + this.z = Math.atan2(-m12, m11); + } else { + this.x = Math.atan2(m32, m22); + this.z = 0; + } + break; + case "YXZ": + this.x = Math.asin(-clamp(m23, -1, 1)); + if (Math.abs(m23) < 0.9999999) { + this.y = Math.atan2(m13, m33); + this.z = Math.atan2(m21, m22); + } else { + this.y = Math.atan2(-m31, m11); + this.z = 0; + } + break; + case "ZXY": + this.x = Math.asin(clamp(m32, -1, 1)); + if (Math.abs(m32) < 0.9999999) { + this.y = Math.atan2(-m31, m33); + this.z = Math.atan2(-m12, m22); + } else { + this.y = 0; + this.z = Math.atan2(m21, m11); + } + break; + case "ZYX": + this.y = Math.asin(-clamp(m31, -1, 1)); + if (Math.abs(m31) < 0.9999999) { + this.x = Math.atan2(m32, m33); + this.z = Math.atan2(m21, m11); + } else { + this.x = 0; + this.z = Math.atan2(-m12, m22); + } + break; + case "YZX": + this.z = Math.asin(clamp(m21, -1, 1)); + if (Math.abs(m21) < 0.9999999) { + this.x = Math.atan2(-m23, m22); + this.y = Math.atan2(-m31, m11); + } else { + this.x = 0; + this.y = Math.atan2(m13, m33); + } + break; + case "XZY": + this.z = Math.asin(-clamp(m12, -1, 1)); + if (Math.abs(m12) < 0.9999999) { + this.x = Math.atan2(m32, m22); + this.y = Math.atan2(m13, m11); + } else { + this.x = Math.atan2(-m23, m33); + this.y = 0; + } + break; + } + + this.order = order; + return this; + } + + setFromQuaternion(q: Quaternion, order: EulerOrder = this.order): this { + _matrix.makeRotationFromQuaternion(q); + return this.setFromRotationMatrix(_matrix, order); + } + + /** Re-expresses the same rotation in a different order (may introduce gimbal lock). */ + reorder(newOrder: EulerOrder): this { + _quaternion.setFromEuler(this); + return this.setFromQuaternion(_quaternion, newOrder); + } + + equals(euler: Euler): boolean { + return ( + euler.x === this.x && + euler.y === this.y && + euler.z === this.z && + euler.order === this.order + ); + } +} + +// Module-level scratch temporaries (three's own pattern). +const _matrix = /*@__PURE__*/ new Matrix4(); +const _quaternion = /*@__PURE__*/ new Quaternion(); diff --git a/playset/math/index.ts b/playset/math/index.ts new file mode 100644 index 0000000..e48a95c --- /dev/null +++ b/playset/math/index.ts @@ -0,0 +1,22 @@ +// playset/math/index.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented, not copied. Porting a GameBlocks module is a mechanical +// import swap: `import { Vector3 } from 'three'` → `from '../math/index.ts'`. + +export { Vector3, type BufferAttributeLike } from "./vector3.ts"; +export { Quaternion } from "./quaternion.ts"; +export { Matrix4 } from "./matrix4.ts"; +export { Euler, type EulerOrder } from "./euler.ts"; +export { + Color, + ColorManagement, + LinearSRGBColorSpace, + LinearToSRGB, + SRGBColorSpace, + SRGBToLinear, + type ColorRepresentation, + type ColorSpace, + type HSL, + type RGB, +} from "./color.ts"; +export { DEG2RAD, MathUtils, RAD2DEG } from "./math-utils.ts"; diff --git a/playset/math/math-utils.ts b/playset/math/math-utils.ts new file mode 100644 index 0000000..9254758 --- /dev/null +++ b/playset/math/math-utils.ts @@ -0,0 +1,104 @@ +// playset/math/math-utils.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +export const DEG2RAD: number = Math.PI / 180; +export const RAD2DEG: number = 180 / Math.PI; + +/** Clamps `value` to the inclusive range [min, max]. */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** Linear interpolation from `x` to `y` by factor `t`. */ +export function lerp(x: number, y: number, t: number): number { + return (1 - t) * x + t * y; +} + +/** Inverse of lerp: the factor of `value` between `x` and `y` (0 when x === y). */ +export function inverseLerp(x: number, y: number, value: number): number { + if (x !== y) return (value - x) / (y - x); + return 0; +} + +/** Frame-rate independent exponential approach of `x` toward `y`. */ +export function damp(x: number, y: number, lambda: number, dt: number): number { + return lerp(x, y, 1 - Math.exp(-lambda * dt)); +} + +/** Maps `x` from range [a1, a2] into range [b1, b2]. */ +export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number { + return b1 + ((x - a1) * (b2 - b1)) / (a2 - a1); +} + +/** Euclidean (always-positive) modulo of n by m. */ +export function euclideanModulo(n: number, m: number): number { + return ((n % m) + m) % m; +} + +/** Triangle wave over [0, length] (default length 1). */ +export function pingpong(x: number, length = 1): number { + return length - Math.abs(euclideanModulo(x, length * 2) - length); +} + +/** Hermite smoothstep of `x` between `min` and `max`. */ +export function smoothstep(x: number, min: number, max: number): number { + if (x <= min) return 0; + if (x >= max) return 1; + x = (x - min) / (max - min); + return x * x * (3 - 2 * x); +} + +/** Perlin's smootherstep of `x` between `min` and `max`. */ +export function smootherstep(x: number, min: number, max: number): number { + if (x <= min) return 0; + if (x >= max) return 1; + x = (x - min) / (max - min); + return x * x * x * (x * (x * 6 - 15) + 10); +} + +export function degToRad(degrees: number): number { + return degrees * DEG2RAD; +} + +export function radToDeg(radians: number): number { + return radians * RAD2DEG; +} + +/** Random integer in the inclusive interval [low, high]. */ +export function randInt(low: number, high: number): number { + return low + Math.floor(Math.random() * (high - low + 1)); +} + +/** Random float in the interval [low, high). */ +export function randFloat(low: number, high: number): number { + return low + Math.random() * (high - low); +} + +/** Random float in the interval (-range/2, range/2). */ +export function randFloatSpread(range: number): number { + return range * (0.5 - Math.random()); +} + +/** + * Namespace object matching three's `MathUtils` export so ports can keep + * `MathUtils.lerp(...)` call sites unchanged. + */ +export const MathUtils = { + DEG2RAD, + RAD2DEG, + clamp, + lerp, + inverseLerp, + damp, + mapLinear, + euclideanModulo, + pingpong, + smoothstep, + smootherstep, + degToRad, + radToDeg, + randInt, + randFloat, + randFloatSpread, +} as const; diff --git a/playset/math/matrix4.ts b/playset/math/matrix4.ts new file mode 100644 index 0000000..b784035 --- /dev/null +++ b/playset/math/matrix4.ts @@ -0,0 +1,153 @@ +// playset/math/matrix4.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { Vector3 } from "./vector3.ts"; +import type { Quaternion } from "./quaternion.ts"; + +/** + * A 4x4 matrix stored column-major in `elements` (like three.js and OpenGL). + * `set()` takes arguments in row-major order for readability, exactly as + * three does. Mutating methods return `this` so calls chain. + * + * Deliberately lean: only the operations the GameBlocks portable core uses + * (basis construction consumed by `Quaternion#setFromRotationMatrix`) plus + * trivially cheap standard companions. No compose/decompose/invert — the + * inventory shows no usage. + */ +export class Matrix4 { + readonly isMatrix4: true = true; + + elements: number[]; + + constructor() { + // prettier-ignore + this.elements = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]; + } + + /** Arguments are row-major; storage is column-major (three convention). */ + set( + n11: number, n12: number, n13: number, n14: number, + n21: number, n22: number, n23: number, n24: number, + n31: number, n32: number, n33: number, n34: number, + n41: number, n42: number, n43: number, n44: number, + ): this { + const te = this.elements; + te[0] = n11; te[4] = n12; te[8] = n13; te[12] = n14; + te[1] = n21; te[5] = n22; te[9] = n23; te[13] = n24; + te[2] = n31; te[6] = n32; te[10] = n33; te[14] = n34; + te[3] = n41; te[7] = n42; te[11] = n43; te[15] = n44; + return this; + } + + identity(): this { + return this.set( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ); + } + + clone(): Matrix4 { + return new Matrix4().copy(this); + } + + copy(m: Matrix4): this { + const te = this.elements; + const me = m.elements; + for (let i = 0; i < 16; i++) te[i] = me[i]!; + return this; + } + + /** Sets the upper-3x3 columns to the given basis axes (rest = identity). */ + makeBasis(xAxis: Vector3, yAxis: Vector3, zAxis: Vector3): this { + return this.set( + xAxis.x, yAxis.x, zAxis.x, 0, + xAxis.y, yAxis.y, zAxis.y, 0, + xAxis.z, yAxis.z, zAxis.z, 0, + 0, 0, 0, 1, + ); + } + + /** Reads the upper-3x3 columns back out into the given vectors. */ + extractBasis(xAxis: Vector3, yAxis: Vector3, zAxis: Vector3): this { + xAxis.setFromMatrixColumn(this, 0); + yAxis.setFromMatrixColumn(this, 1); + zAxis.setFromMatrixColumn(this, 2); + return this; + } + + /** Pure rotation matrix from unit quaternion `q` (translation = 0). */ + makeRotationFromQuaternion(q: Quaternion): this { + const x = q.x, y = q.y, z = q.z, w = q.w; + + const x2 = x + x, y2 = y + y, z2 = z + z; + const xx = x * x2, xy = x * y2, xz = x * z2; + const yy = y * y2, yz = y * z2, zz = z * z2; + const wx = w * x2, wy = w * y2, wz = w * z2; + + return this.set( + 1 - (yy + zz), xy - wz, xz + wy, 0, + xy + wz, 1 - (xx + zz), yz - wx, 0, + xz - wy, yz + wx, 1 - (xx + yy), 0, + 0, 0, 0, 1, + ); + } + + /** + * Rotation-only look-at (three semantics): local -Z ends up pointing from + * `eye` toward `target`, with `up` as the vertical hint. Degenerate inputs + * (eye === target, up parallel to view) are nudged exactly like three. + */ + lookAt(eye: Vector3, target: Vector3, up: Vector3): this { + const te = this.elements; + + _z.subVectors(eye, target); + if (_z.lengthSq() === 0) { + // eye and target are in the same position + _z.z = 1; + } + _z.normalize(); + + _x.crossVectors(up, _z); + if (_x.lengthSq() === 0) { + // up and z are parallel + if (Math.abs(up.z) === 1) { + _z.x += 0.0001; + } else { + _z.z += 0.0001; + } + _z.normalize(); + _x.crossVectors(up, _z); + } + _x.normalize(); + + _y.crossVectors(_z, _x); + + te[0] = _x.x; te[4] = _y.x; te[8] = _z.x; + te[1] = _x.y; te[5] = _y.y; te[9] = _z.y; + te[2] = _x.z; te[6] = _y.z; te[10] = _z.z; + return this; + } + + equals(m: Matrix4): boolean { + const te = this.elements; + const me = m.elements; + for (let i = 0; i < 16; i++) { + if (te[i] !== me[i]) return false; + } + return true; + } +} + +// Module-level scratch temporaries (three's own pattern) — lookAt is +// allocation-free per call. +const _x = /*@__PURE__*/ new Vector3(); +const _y = /*@__PURE__*/ new Vector3(); +const _z = /*@__PURE__*/ new Vector3(); diff --git a/playset/math/quaternion.ts b/playset/math/quaternion.ts new file mode 100644 index 0000000..54f1f48 --- /dev/null +++ b/playset/math/quaternion.ts @@ -0,0 +1,325 @@ +// playset/math/quaternion.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { clamp } from "./math-utils.ts"; +import type { Euler } from "./euler.ts"; +import type { Matrix4 } from "./matrix4.ts"; +import type { Vector3 } from "./vector3.ts"; + +/** + * A quaternion (x, y, z, w) representing a rotation. Mutating methods return + * `this` so calls chain exactly like three.js. + */ +export class Quaternion { + readonly isQuaternion: true = true; + + x: number; + y: number; + z: number; + w: number; + + constructor(x = 0, y = 0, z = 0, w = 1) { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + set(x: number, y: number, z: number, w: number): this { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + return this; + } + + clone(): Quaternion { + return new Quaternion(this.x, this.y, this.z, this.w); + } + + copy(q: Quaternion): this { + this.x = q.x; + this.y = q.y; + this.z = q.z; + this.w = q.w; + return this; + } + + identity(): this { + return this.set(0, 0, 0, 1); + } + + /** Assumes this quaternion has unit length (rotation inverse = conjugate). */ + invert(): this { + return this.conjugate(); + } + + conjugate(): this { + this.x = -this.x; + this.y = -this.y; + this.z = -this.z; + return this; + } + + dot(q: Quaternion): number { + return this.x * q.x + this.y * q.y + this.z * q.z + this.w * q.w; + } + + lengthSq(): number { + return this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w; + } + + length(): number { + return Math.sqrt(this.lengthSq()); + } + + /** Normalizes to unit length; a zero quaternion becomes the identity. */ + normalize(): this { + let l = this.length(); + if (l === 0) { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 1; + } else { + l = 1 / l; + this.x *= l; + this.y *= l; + this.z *= l; + this.w *= l; + } + return this; + } + + /** Sets `this = this * q` (apply q's rotation first, then this). */ + multiply(q: Quaternion): this { + return this.multiplyQuaternions(this, q); + } + + /** Sets `this = q * this` (apply this rotation first, then q). */ + premultiply(q: Quaternion): this { + return this.multiplyQuaternions(q, this); + } + + /** Hamilton product `this = a * b`. */ + multiplyQuaternions(a: Quaternion, b: Quaternion): this { + const ax = a.x, ay = a.y, az = a.z, aw = a.w; + const bx = b.x, by = b.y, bz = b.z, bw = b.w; + + this.x = ax * bw + aw * bx + ay * bz - az * by; + this.y = ay * bw + aw * by + az * bx - ax * bz; + this.z = az * bw + aw * bz + ax * by - ay * bx; + this.w = aw * bw - ax * bx - ay * by - az * bz; + return this; + } + + /** Angle in radians between this rotation and `q`. */ + angleTo(q: Quaternion): number { + return 2 * Math.acos(Math.abs(clamp(this.dot(q), -1, 1))); + } + + /** + * Spherical linear interpolation toward `qb` by `t` (shortest arc). Falls + * back to normalized lerp when the quaternions are nearly parallel. + */ + slerp(qb: Quaternion, t: number): this { + if (t === 0) return this; + if (t === 1) return this.copy(qb); + + const x = this.x, y = this.y, z = this.z, w = this.w; + + let cosHalfTheta = w * qb.w + x * qb.x + y * qb.y + z * qb.z; + if (cosHalfTheta < 0) { + // Take the shortest arc: interpolate toward -qb (same rotation). + this.set(-qb.x, -qb.y, -qb.z, -qb.w); + cosHalfTheta = -cosHalfTheta; + } else { + this.copy(qb); + } + + if (cosHalfTheta >= 1.0) { + // Identical rotations — restore the start value untouched. + return this.set(x, y, z, w); + } + + const sqrSinHalfTheta = 1.0 - cosHalfTheta * cosHalfTheta; + if (sqrSinHalfTheta <= Number.EPSILON) { + // Nearly parallel: normalized linear interpolation is stable here. + const s = 1 - t; + this.x = s * x + t * this.x; + this.y = s * y + t * this.y; + this.z = s * z + t * this.z; + this.w = s * w + t * this.w; + return this.normalize(); + } + + const sinHalfTheta = Math.sqrt(sqrSinHalfTheta); + const halfTheta = Math.atan2(sinHalfTheta, cosHalfTheta); + const ratioA = Math.sin((1 - t) * halfTheta) / sinHalfTheta; + const ratioB = Math.sin(t * halfTheta) / sinHalfTheta; + + this.x = x * ratioA + this.x * ratioB; + this.y = y * ratioA + this.y * ratioB; + this.z = z * ratioA + this.z * ratioB; + this.w = w * ratioA + this.w * ratioB; + return this; + } + + slerpQuaternions(qa: Quaternion, qb: Quaternion, t: number): this { + return this.copy(qa).slerp(qb, t); + } + + /** `axis` is assumed to be normalized; `angle` is in radians. */ + setFromAxisAngle(axis: Vector3, angle: number): this { + const halfAngle = angle / 2; + const s = Math.sin(halfAngle); + + this.x = axis.x * s; + this.y = axis.y * s; + this.z = axis.z * s; + this.w = Math.cos(halfAngle); + return this; + } + + /** Standard intrinsic Tait-Bryan euler → quaternion for all six orders. */ + setFromEuler(euler: Euler): this { + const c1 = Math.cos(euler.x / 2); + const c2 = Math.cos(euler.y / 2); + const c3 = Math.cos(euler.z / 2); + const s1 = Math.sin(euler.x / 2); + const s2 = Math.sin(euler.y / 2); + const s3 = Math.sin(euler.z / 2); + + switch (euler.order) { + case "XYZ": + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + break; + case "YXZ": + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + break; + case "ZXY": + this.x = s1 * c2 * c3 - c1 * s2 * s3; + this.y = c1 * s2 * c3 + s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + break; + case "ZYX": + this.x = s1 * c2 * c3 - c1 * s2 * s3; + this.y = c1 * s2 * c3 + s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + break; + case "YZX": + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 + s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + break; + case "XZY": + this.x = s1 * c2 * c3 - c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + break; + } + return this; + } + + /** + * Extracts the rotation from `m`, whose upper-3x3 is assumed to be a pure + * (unscaled) rotation matrix. Trace-based Shepperd method. + */ + setFromRotationMatrix(m: Matrix4): this { + const te = m.elements; + const m11 = te[0]!, m12 = te[4]!, m13 = te[8]!; + const m21 = te[1]!, m22 = te[5]!, m23 = te[9]!; + const m31 = te[2]!, m32 = te[6]!, m33 = te[10]!; + const trace = m11 + m22 + m33; + + if (trace > 0) { + const s = 0.5 / Math.sqrt(trace + 1.0); + this.w = 0.25 / s; + this.x = (m32 - m23) * s; + this.y = (m13 - m31) * s; + this.z = (m21 - m12) * s; + } else if (m11 > m22 && m11 > m33) { + const s = 2.0 * Math.sqrt(1.0 + m11 - m22 - m33); + this.w = (m32 - m23) / s; + this.x = 0.25 * s; + this.y = (m12 + m21) / s; + this.z = (m13 + m31) / s; + } else if (m22 > m33) { + const s = 2.0 * Math.sqrt(1.0 + m22 - m11 - m33); + this.w = (m13 - m31) / s; + this.x = (m12 + m21) / s; + this.y = 0.25 * s; + this.z = (m23 + m32) / s; + } else { + const s = 2.0 * Math.sqrt(1.0 + m33 - m11 - m22); + this.w = (m21 - m12) / s; + this.x = (m13 + m31) / s; + this.y = (m23 + m32) / s; + this.z = 0.25 * s; + } + return this; + } + + /** + * Sets this quaternion to the rotation carrying unit vector `vFrom` onto + * unit vector `vTo`. Antiparallel inputs produce a 180° rotation about an + * axis perpendicular to `vFrom`. + */ + setFromUnitVectors(vFrom: Vector3, vTo: Vector3): this { + let r = vFrom.dot(vTo) + 1; + + if (r < Number.EPSILON) { + // vFrom and vTo point in opposite directions. + r = 0; + if (Math.abs(vFrom.x) > Math.abs(vFrom.z)) { + this.x = -vFrom.y; + this.y = vFrom.x; + this.z = 0; + this.w = r; + } else { + this.x = 0; + this.y = -vFrom.z; + this.z = vFrom.y; + this.w = r; + } + } else { + // cross(vFrom, vTo) with w = 1 + dot, then normalize. + this.x = vFrom.y * vTo.z - vFrom.z * vTo.y; + this.y = vFrom.z * vTo.x - vFrom.x * vTo.z; + this.z = vFrom.x * vTo.y - vFrom.y * vTo.x; + this.w = r; + } + return this.normalize(); + } + + equals(q: Quaternion): boolean { + return q.x === this.x && q.y === this.y && q.z === this.z && q.w === this.w; + } + + fromArray(array: ArrayLike, offset = 0): this { + this.x = array[offset]!; + this.y = array[offset + 1]!; + this.z = array[offset + 2]!; + this.w = array[offset + 3]!; + return this; + } + + toArray(array: number[] = [], offset = 0): number[] { + array[offset] = this.x; + array[offset + 1] = this.y; + array[offset + 2] = this.z; + array[offset + 3] = this.w; + return array; + } +} diff --git a/playset/math/vector3.ts b/playset/math/vector3.ts new file mode 100644 index 0000000..7bdc8d1 --- /dev/null +++ b/playset/math/vector3.ts @@ -0,0 +1,314 @@ +// playset/math/vector3.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { clamp } from "./math-utils.ts"; +import { Quaternion } from "./quaternion.ts"; +import type { Euler } from "./euler.ts"; +import type { Matrix4 } from "./matrix4.ts"; + +/** + * Structural stand-in for three's BufferAttribute in + * `Vector3#fromBufferAttribute` — anything exposing per-index component + * getters works (including a real three BufferAttribute). + */ +export interface BufferAttributeLike { + getX(index: number): number; + getY(index: number): number; + getZ(index: number): number; +} + +/** + * A 3-component vector with plain `x`/`y`/`z` fields (GameBlocks indexes them + * dynamically as `v[axis]`). Mutating methods return `this` so calls chain + * exactly like three.js. + */ +export class Vector3 { + readonly isVector3: true = true; + + x: number; + y: number; + z: number; + + constructor(x = 0, y = 0, z = 0) { + this.x = x; + this.y = y; + this.z = z; + } + + set(x: number, y: number, z: number): this { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + setScalar(scalar: number): this { + this.x = scalar; + this.y = scalar; + this.z = scalar; + return this; + } + + setX(x: number): this { + this.x = x; + return this; + } + + setY(y: number): this { + this.y = y; + return this; + } + + setZ(z: number): this { + this.z = z; + return this; + } + + clone(): Vector3 { + return new Vector3(this.x, this.y, this.z); + } + + copy(v: Vector3): this { + this.x = v.x; + this.y = v.y; + this.z = v.z; + return this; + } + + add(v: Vector3): this { + this.x += v.x; + this.y += v.y; + this.z += v.z; + return this; + } + + addVectors(a: Vector3, b: Vector3): this { + this.x = a.x + b.x; + this.y = a.y + b.y; + this.z = a.z + b.z; + return this; + } + + addScaledVector(v: Vector3, s: number): this { + this.x += v.x * s; + this.y += v.y * s; + this.z += v.z * s; + return this; + } + + sub(v: Vector3): this { + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + return this; + } + + subVectors(a: Vector3, b: Vector3): this { + this.x = a.x - b.x; + this.y = a.y - b.y; + this.z = a.z - b.z; + return this; + } + + multiply(v: Vector3): this { + this.x *= v.x; + this.y *= v.y; + this.z *= v.z; + return this; + } + + multiplyScalar(scalar: number): this { + this.x *= scalar; + this.y *= scalar; + this.z *= scalar; + return this; + } + + divide(v: Vector3): this { + this.x /= v.x; + this.y /= v.y; + this.z /= v.z; + return this; + } + + divideScalar(scalar: number): this { + return this.multiplyScalar(1 / scalar); + } + + negate(): this { + this.x = -this.x; + this.y = -this.y; + this.z = -this.z; + return this; + } + + dot(v: Vector3): number { + return this.x * v.x + this.y * v.y + this.z * v.z; + } + + lengthSq(): number { + return this.x * this.x + this.y * this.y + this.z * this.z; + } + + length(): number { + return Math.sqrt(this.lengthSq()); + } + + /** Normalizes in place; a zero vector stays (0, 0, 0), as in three. */ + normalize(): this { + return this.divideScalar(this.length() || 1); + } + + /** Sets this vector's magnitude to `length` (zero vector stays zero). */ + setLength(length: number): this { + return this.normalize().multiplyScalar(length); + } + + lerp(v: Vector3, alpha: number): this { + this.x += (v.x - this.x) * alpha; + this.y += (v.y - this.y) * alpha; + this.z += (v.z - this.z) * alpha; + return this; + } + + lerpVectors(v1: Vector3, v2: Vector3, alpha: number): this { + this.x = v1.x + (v2.x - v1.x) * alpha; + this.y = v1.y + (v2.y - v1.y) * alpha; + this.z = v1.z + (v2.z - v1.z) * alpha; + return this; + } + + cross(v: Vector3): this { + return this.crossVectors(this, v); + } + + crossVectors(a: Vector3, b: Vector3): this { + const ax = a.x, ay = a.y, az = a.z; + const bx = b.x, by = b.y, bz = b.z; + + this.x = ay * bz - az * by; + this.y = az * bx - ax * bz; + this.z = ax * by - ay * bx; + return this; + } + + /** Projects this vector onto `v` (becomes zero if `v` is zero-length). */ + projectOnVector(v: Vector3): this { + const denominator = v.lengthSq(); + if (denominator === 0) return this.set(0, 0, 0); + const scalar = v.dot(this) / denominator; + return this.copy(v).multiplyScalar(scalar); + } + + /** + * Removes the component of this vector along `planeNormal`, projecting it + * onto the plane through the origin with that normal. A zero-length normal + * leaves the vector unchanged (three's behavior). + */ + projectOnPlane(planeNormal: Vector3): this { + _vector.copy(this).projectOnVector(planeNormal); + return this.sub(_vector); + } + + /** Applies rotation `q` (assumed unit length): v' = q · v · q*. */ + applyQuaternion(q: Quaternion): this { + const vx = this.x, vy = this.y, vz = this.z; + const qx = q.x, qy = q.y, qz = q.z, qw = q.w; + + // t = 2 · cross(q.xyz, v) + const tx = 2 * (qy * vz - qz * vy); + const ty = 2 * (qz * vx - qx * vz); + const tz = 2 * (qx * vy - qy * vx); + + // v' = v + w·t + cross(q.xyz, t) + this.x = vx + qw * tx + qy * tz - qz * ty; + this.y = vy + qw * ty + qz * tx - qx * tz; + this.z = vz + qw * tz + qx * ty - qy * tx; + return this; + } + + /** Rotates about `axis` (assumed normalized) by `angle` radians. */ + applyAxisAngle(axis: Vector3, angle: number): this { + return this.applyQuaternion(_quaternion.setFromAxisAngle(axis, angle)); + } + + applyEuler(euler: Euler): this { + return this.applyQuaternion(_quaternion.setFromEuler(euler)); + } + + /** Applies `m` as a full 4x4 transform, including the perspective divide. */ + applyMatrix4(m: Matrix4): this { + const x = this.x, y = this.y, z = this.z; + const e = m.elements; + + const w = 1 / (e[3]! * x + e[7]! * y + e[11]! * z + e[15]!); + this.x = (e[0]! * x + e[4]! * y + e[8]! * z + e[12]!) * w; + this.y = (e[1]! * x + e[5]! * y + e[9]! * z + e[13]!) * w; + this.z = (e[2]! * x + e[6]! * y + e[10]! * z + e[14]!) * w; + return this; + } + + distanceTo(v: Vector3): number { + return Math.sqrt(this.distanceToSquared(v)); + } + + distanceToSquared(v: Vector3): number { + const dx = this.x - v.x; + const dy = this.y - v.y; + const dz = this.z - v.z; + return dx * dx + dy * dy + dz * dz; + } + + /** Angle to `v` in radians; PI/2 if either vector is zero-length. */ + angleTo(v: Vector3): number { + const denominator = Math.sqrt(this.lengthSq() * v.lengthSq()); + if (denominator === 0) return Math.PI / 2; + const theta = this.dot(v) / denominator; + return Math.acos(clamp(theta, -1, 1)); + } + + /** Copies matrix column `index` (0-3) of `m` into this vector. */ + setFromMatrixColumn(m: Matrix4, index: number): this { + return this.fromArray(m.elements, index * 4); + } + + /** Copies the translation part of `m` into this vector. */ + setFromMatrixPosition(m: Matrix4): this { + const e = m.elements; + this.x = e[12]!; + this.y = e[13]!; + this.z = e[14]!; + return this; + } + + fromBufferAttribute(attribute: BufferAttributeLike, index: number): this { + this.x = attribute.getX(index); + this.y = attribute.getY(index); + this.z = attribute.getZ(index); + return this; + } + + equals(v: Vector3): boolean { + return v.x === this.x && v.y === this.y && v.z === this.z; + } + + fromArray(array: ArrayLike, offset = 0): this { + this.x = array[offset]!; + this.y = array[offset + 1]!; + this.z = array[offset + 2]!; + return this; + } + + toArray(array: number[] = [], offset = 0): number[] { + array[offset] = this.x; + array[offset + 1] = this.y; + array[offset + 2] = this.z; + return array; + } +} + +// Module-level scratch temporaries — mirrors three's own approach so hot +// methods never allocate. +const _vector = /*@__PURE__*/ new Vector3(); +const _quaternion = /*@__PURE__*/ new Quaternion(); diff --git a/playset/modules/actor-motion/aircraft/airplane-model-controller.ts b/playset/modules/actor-motion/aircraft/airplane-model-controller.ts new file mode 100644 index 0000000..7e7c62f --- /dev/null +++ b/playset/modules/actor-motion/aircraft/airplane-model-controller.ts @@ -0,0 +1,89 @@ +// playset/modules/actor-motion/aircraft/airplane-model-controller.ts — writes +// airplane motion state (position + yaw/pitch/roll frame) onto a scene model +// and drives jet-flame effects. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/aircraft/AirplaneModelController.js. +// Verbatim semantics; the model is typed structurally (position + quaternion) +// instead of a three.js Object3D. + +import { Matrix4, type Quaternion, type Vector3 } from "../../../math/index.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; + +export interface PlaneModelLike { + position: Vector3; + quaternion: Quaternion; +} + +export interface JetFlameLike { + step(frame: { + throttle: number; + isBoosting: boolean; + timeSeconds: number; + deltaSeconds: number; + }): void; +} + +export interface AirplaneModelStepInput { + position: VecLike; + yaw: number; + pitch: number; + roll: number; + throttle: number; + isBoosting: boolean; + elapsedTimeSeconds: number; + deltaSeconds?: number; +} + +export class AirplaneModelController { + planeModel: PlaneModelLike | null; + jetFlames: JetFlameLike[]; + basis: WorldBasis; + modelMatrix: Matrix4; + + constructor( + planeModel: PlaneModelLike | null = null, + jetFlames: JetFlameLike[] = [], + basis: WorldBasis = DEFAULT_WORLD_BASIS, + ) { + this.planeModel = planeModel; + this.jetFlames = jetFlames; + this.basis = basis; + + this.modelMatrix = new Matrix4(); + } + + reset(): void { + if (!this.planeModel) return; + this.planeModel.position.set(0, 0, 0); + this.planeModel.quaternion.identity(); + } + + step({ + position, + yaw, + pitch, + roll, + throttle, + isBoosting, + elapsedTimeSeconds, + deltaSeconds = 1 / 60, + }: AirplaneModelStepInput): void { + if (!this.planeModel) return; + + this.planeModel.position.set(position.x ?? 0, position.y ?? 0, position.z ?? 0); + + const frame = this.basis.yawPitchRollFrame(yaw, pitch, roll); + this.modelMatrix.makeBasis(frame.right, frame.up, frame.back ?? frame.forward.clone().negate()); + this.planeModel.quaternion.setFromRotationMatrix(this.modelMatrix); + + for (const flame of this.jetFlames) { + flame.step({ + throttle, + isBoosting, + timeSeconds: elapsedTimeSeconds, + deltaSeconds, + }); + } + } +} diff --git a/playset/modules/actor-motion/aircraft/airplane-motion-controller.ts b/playset/modules/actor-motion/aircraft/airplane-motion-controller.ts new file mode 100644 index 0000000..3215875 --- /dev/null +++ b/playset/modules/actor-motion/aircraft/airplane-motion-controller.ts @@ -0,0 +1,314 @@ +// playset/modules/actor-motion/aircraft/airplane-motion-controller.ts — +// arcade airplane flight model: throttle→speed lag, pitch steering, bank-roll +// turning, timed boost. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/aircraft/AirplaneMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../../math/index.ts"; +import { clamp, smoothToward } from "../../math/scalar-utils.ts"; +import { toVec3 } from "../../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; + +export interface AirplaneMotionControllerOptions { + throttleRate?: number; + minSpeed?: number; + maxSpeed?: number; + speedLag?: number; + boostSpeedLag?: number; + pitchRate?: number; + maxBankRoll?: number; + bankRollLag?: number; + bankTurnRate?: number; + bankTurnRollReference?: number; + boostDuration?: number; + boostMultiplier?: number; + basis?: WorldBasis; +} + +export interface AirplanePlanMovementInput { + left?: number; + right?: number; + up?: number; + down?: number; + throttle?: number; + boost?: boolean; + deltaSeconds?: number; + commit?: boolean; +} + +export interface AirplaneIntent { + position: Vector3; + startPosition: Vector3; + desiredDelta: Vector3; + deltaSeconds: number; + speed: number; + pitch: number; + roll: number; + yaw: number; +} + +export interface AirplaneCommitResult { + position: Vector3; + yaw: number; + pitch: number; + roll: number; + bodyFrame: { + forward: Vector3; + right: Vector3; + up: Vector3; + }; +} + +export class AirplaneMotionController { + cfg: { + throttleRate: number; + minSpeed: number; + maxSpeed: number; + speedLag: number; + boostSpeedLag: number; + pitchRate: number; + maxBankRoll: number; + bankRollLag: number; + bankTurnRate: number; + bankTurnRollReference: number; + boostDuration: number; + boostMultiplier: number; + }; + basis: WorldBasis; + + speed: number; + pitch: number; + roll: number; + yaw: number; + position: Vector3; + + throttle: number; + isBoosting: boolean; + boostRemainingSeconds: number; + boostPressed: boolean; + + constructor({ + throttleRate = 0.42, + minSpeed = 82, + maxSpeed = 246, + speedLag = 0.56, + boostSpeedLag = 0.26, + pitchRate = 1.18, + maxBankRoll = 1.1868, // 68 deg + bankRollLag = 0.21, + bankTurnRate = 0.42, + bankTurnRollReference = 0.9774, // 56 deg + boostDuration = 1.7, + boostMultiplier = 1.28, + basis = DEFAULT_WORLD_BASIS, + }: AirplaneMotionControllerOptions) { + this.cfg = { + throttleRate, + minSpeed, + maxSpeed, + speedLag, + boostSpeedLag, + pitchRate, + maxBankRoll, + bankRollLag, + bankTurnRate, + bankTurnRollReference, + boostDuration, + boostMultiplier, + }; + this.basis = basis; + + this.speed = this.cfg.minSpeed; + this.pitch = 0; + this.roll = 0; + this.yaw = 0; + this.position = new Vector3(); + + this.throttle = 0; + this.isBoosting = false; + this.boostRemainingSeconds = 0; + this.boostPressed = false; + } + + setState( + speed?: number | null, + throttle?: number | null, + pitch?: number | null, + roll?: number | null, + yaw?: number | null, + position: VecLike | null = null, + isBoosting: boolean | null = null, + boostRemainingSeconds: number | null = null, + boostDuration: number | null = null, + ): void { + if (position) { + this.position.copy(toVec3(position, this.position)); + } + if (typeof speed === "number") this.speed = speed; + if (typeof throttle === "number") { + this.throttle = clamp(throttle, 0, 1); + } + if (typeof pitch === "number") this.pitch = pitch; + if (typeof roll === "number") this.roll = roll; + if (typeof yaw === "number") this.yaw = yaw; + if (typeof isBoosting === "boolean") this.isBoosting = isBoosting; + if (typeof boostRemainingSeconds === "number") this.boostRemainingSeconds = boostRemainingSeconds; + if (typeof boostDuration === "number") this.cfg.boostDuration = boostDuration; + } + + // left/right: 0..1 steers toward the local left/right directions. + // up/down: 0..1 steers toward the local up/down directions. + // throttle: -1..1 adjusts cruise throttle. + // boost: true triggers boost. + planMovement({ + left = 0, + right = 0, + up = 0, + down = 0, + throttle = 0, + boost = false, + deltaSeconds = 1 / 60, + commit = false, + }: AirplanePlanMovementInput): AirplaneIntent | AirplaneCommitResult { + const startPosition = this.position.clone(); + const leftRight = + this.basis.controlSignal("counterClockWise", left) + this.basis.controlSignal("clockWise", right); + const upDown = + this.basis.controlSignal("counterClockWise", up) + this.basis.controlSignal("clockWise", down); + + if (throttle > 0) { + this.throttle = Math.min(1, this.throttle + this.cfg.throttleRate * deltaSeconds); + } else if (throttle < 0) { + this.throttle = Math.max(0, this.throttle - this.cfg.throttleRate * deltaSeconds); + } + + const boostHeld = Boolean(boost); + + this._stepBoost(boostHeld, deltaSeconds); + const nextSpeed = this.predictSpeed(deltaSeconds); + const nextAttitude = this.predictAttitude(leftRight, upDown, nextSpeed, deltaSeconds); + + const nextPosition = this.predictPosition( + this.position, + nextSpeed * deltaSeconds, + nextAttitude.yaw, + nextAttitude.pitch, + ); + + const intent: AirplaneIntent = { + position: nextPosition.clone(), + startPosition, + desiredDelta: nextPosition.clone().sub(startPosition), + deltaSeconds: deltaSeconds, + speed: nextSpeed, + pitch: nextAttitude.pitch, + roll: nextAttitude.roll, + yaw: nextAttitude.yaw, + }; + + if (commit) return this.commitMovement(intent); + return intent; + } + + commitMovement(intent: AirplaneIntent, resolved: { position: VecLike } | null = null): AirplaneCommitResult { + const position = toVec3(resolved ? resolved.position : intent.position); + this.position.copy(position); + this.speed = intent.speed; + this.pitch = intent.pitch; + this.roll = intent.roll; + this.yaw = intent.yaw; + + const frame = this.basis.yawPitchRollFrame(this.yaw, this.pitch, this.roll); + return { + position: this.position.clone(), + yaw: this.yaw, + pitch: this.pitch, + roll: this.roll, + bodyFrame: { + forward: frame.forward.clone(), + right: frame.right.clone(), + up: frame.up.clone(), + }, + }; + } + + _stepBoost(boostHeld: boolean, deltaSeconds: number): void { + if (this.boostRemainingSeconds > 0) { + this.boostRemainingSeconds -= deltaSeconds; + if (this.boostRemainingSeconds <= 0) { + this.boostRemainingSeconds = 0; + this.isBoosting = false; + } + } + + if (boostHeld) { + if (!this.boostPressed && !this.isBoosting) { + this.isBoosting = true; + this.boostRemainingSeconds = this.cfg.boostDuration; + } + this.boostPressed = true; + } else { + this.boostPressed = false; + } + } + + predictSpeed(deltaSeconds: number): number { + const cruiseSpeed = this.cfg.minSpeed + this.throttle * (this.cfg.maxSpeed - this.cfg.minSpeed); + const targetSpeed = this.isBoosting ? this.cfg.maxSpeed * this.cfg.boostMultiplier : cruiseSpeed; + return smoothToward( + this.speed, + targetSpeed, + this.isBoosting ? this.cfg.boostSpeedLag : this.cfg.speedLag, + deltaSeconds, + ); + } + + predictAttitude( + leftRight: number, + upDown: number, + speed: number, + deltaSeconds: number, + ): { pitch: number; roll: number; yaw: number } { + const controlEffectiveness = speed > this.cfg.minSpeed ? 1 : speed / this.cfg.minSpeed; + const localPitch = upDown * this.cfg.pitchRate * deltaSeconds * controlEffectiveness; + const maxBankRoll = Math.abs(this.cfg.maxBankRoll); + // the turn direction and roll-bank direction have opposite signs. + const targetRoll = -leftRight * maxBankRoll; + const currentRoll = clamp(this.roll, -maxBankRoll, maxBankRoll); + const pitch = this.pitch + localPitch; + const roll = smoothToward(currentRoll, targetRoll, this.cfg.bankRollLag, deltaSeconds); + + const bankTurnReference = Math.max(1e-6, Math.abs(this.cfg.bankTurnRollReference)); + // Convert the roll-bank direction back to turn direction. + const bankTurnAxis = clamp(-roll / bankTurnReference, -1, 1); + const bankTurnYaw = bankTurnAxis * this.cfg.bankTurnRate * deltaSeconds * controlEffectiveness; + const yaw = this.yaw + bankTurnYaw; + + return { pitch, roll, yaw }; + } + + predictPosition( + position: VecLike = this.position, + distance = 0, + yaw: number = this.yaw, + pitch: number = this.pitch, + ): Vector3 { + const startPosition = toVec3(position, this.position); + const forward = this.basis.yawPitchRollFrame(yaw, pitch).forward; + return startPosition.addScaledVector(forward, distance); + } + + reset(position: VecLike = { x: 0, y: 0, z: 0 }): void { + this.speed = this.cfg.minSpeed; + this.throttle = 0; + this.pitch = 0; + this.roll = 0; + this.yaw = 0; + this.position.set(position.x ?? 0, position.y ?? 0, position.z ?? 0); + this.isBoosting = false; + this.boostRemainingSeconds = 0; + this.boostPressed = false; + } +} diff --git a/playset/modules/actor-motion/character/base-character-motion-controller.ts b/playset/modules/actor-motion/character/base-character-motion-controller.ts new file mode 100644 index 0000000..6de6421 --- /dev/null +++ b/playset/modules/actor-motion/character/base-character-motion-controller.ts @@ -0,0 +1,299 @@ +// playset/modules/actor-motion/character/base-character-motion-controller.ts — +// shared character locomotion core: lag-smoothed planar velocity, jump and +// gravity, yaw/pitch state, and the plan → resolve → commit handshake. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/character/BaseCharacterMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../../math/index.ts"; +import { clamp, smoothingAlpha, smoothToward } from "../../math/scalar-utils.ts"; +import { toVec3, VECTOR_EPS } from "../../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../../math/world-basis.ts"; + +const EPS_SQ = VECTOR_EPS * VECTOR_EPS; + +export interface BaseCharacterMotionControllerOptions { + walkSpeed?: number; + sprintSpeed?: number; + crouchSpeed?: number; + accelerationLag?: number; + decelerationLag?: number; + airAccelerationLag?: number; + turnLag?: number; + gravity?: number; + jumpVelocity?: number; + maxFallSpeed?: number; + pitchMin?: number; + pitchMax?: number; + basis?: WorldBasis; +} + +export interface CharacterMotionConfig { + walkSpeed: number; + sprintSpeed: number; + crouchSpeed: number; + accelerationLag: number; + decelerationLag: number; + airAccelerationLag: number; + turnLag: number; + gravity: number; + jumpVelocity: number; + maxFallSpeed: number; + pitchMin: number; + pitchMax: number; +} + +export interface CharacterStateOptions { + position?: VecLike | null; + velocity?: VecLike | null; + grounded?: boolean; + forward?: VecLike | null; + yaw?: number; + pitch?: number; +} + +/** Frame-local movement request produced by planMovement / _prepareLocomotion. */ +export interface CharacterMovementIntent { + position: Vector3; + startPosition: Vector3; + desiredDelta: Vector3; + deltaSeconds: number; + velocity: Vector3; + grounded: boolean; + yaw: number; + pitch: number; +} + +/** What a batch resolver hands back for one intent (KinematicBatchResolver-shaped — + * typed structurally so any resolver or test stub with these fields works). */ +export interface ResolvedCharacterMovement { + position: VecLike; + velocity: VecLike; + correctedDelta: VecLike; + grounded: boolean; +} + +export interface DirectionFrame { + forward: Vector3; + right: Vector3; + up: Vector3; +} + +export interface CharacterMotionState { + position: Vector3; + velocity: Vector3; + grounded: boolean; + yaw: number; + pitch: number; + viewFrame: DirectionFrame; + planarMoveFrame: DirectionFrame; +} + +export type CharacterMotionPlanResult = CharacterMovementIntent | CharacterMotionState; + +export interface LocomotionInput { + moveDirection: Vector3; + facingDirection?: Vector3 | null; + sprint: boolean; + crouch: boolean; + jump: boolean; + yaw?: number; + pitch?: number; + deltaSeconds: number; +} + +export class BaseCharacterMotionController { + cfg: CharacterMotionConfig; + basis: WorldBasis; + position: Vector3; + velocity: Vector3; + yaw: number; + pitch: number; + grounded: boolean; + + constructor({ + walkSpeed = 6, + sprintSpeed = 9, + crouchSpeed = 3.2, + accelerationLag = 0.04, + decelerationLag = 0.05, + airAccelerationLag = 0.11, + turnLag = 0, + gravity = 9.81, + jumpVelocity = 8.5, + maxFallSpeed = 55, + pitchMin = -1.45, + pitchMax = 1.45, + basis = DEFAULT_WORLD_BASIS, + }: BaseCharacterMotionControllerOptions) { + this.cfg = { + walkSpeed, + sprintSpeed, + crouchSpeed, + accelerationLag, + decelerationLag, + airAccelerationLag, + turnLag, + gravity, + jumpVelocity, + maxFallSpeed, + pitchMin, + pitchMax, + }; + + this.basis = basis; + this.position = new Vector3(); + this.velocity = new Vector3(); + this.yaw = 0; + this.pitch = 0; + this.grounded = true; + } + + setState(options: CharacterStateOptions = {}): void { + if (options.position) this.position.copy(toVec3(options.position)); + if (options.velocity) this.velocity.copy(toVec3(options.velocity)); + if (options.grounded !== undefined) this.grounded = options.grounded; + if (options.forward) { + const forward = this._planarUnit(toVec3(options.forward)); + this.yaw = this.basis.forwardToYaw(forward); + } + if (options.yaw !== undefined) this.yaw = options.yaw; + if (options.pitch !== undefined) this.pitch = clamp(options.pitch, this.cfg.pitchMin, this.cfg.pitchMax); + } + + snapshot(): CharacterMotionState { + return this._stateOutput(); + } + + commitMovement( + intent: CharacterMovementIntent, + resolved: ResolvedCharacterMovement | null = null, + ): CharacterMotionState { + const position = toVec3(resolved ? resolved.position : intent.position); + const velocity = toVec3(resolved ? resolved.velocity : intent.velocity); + const correctedDelta = toVec3(resolved ? resolved.correctedDelta : intent.desiredDelta); + let grounded = resolved ? resolved.grounded : intent.grounded; + + // Cancel upward velocity when collision clipped the requested upward move. + if ( + this.basis.upComponent(intent.desiredDelta) > this.basis.upComponent(correctedDelta) + 1e-5 + && this.basis.upComponent(velocity) > 0 + ) { + this.basis.flatten(velocity); + } + // Keep the character airborne while jump velocity is still rising. + if (intent.grounded === false && this.basis.upComponent(intent.velocity) > 0) grounded = false; + // Remove downward velocity after landing so the character does not sink. + if (grounded && this.basis.upComponent(velocity) < 0) this.basis.flatten(velocity); + + this.position.copy(position); + this.velocity.copy(velocity); + this.grounded = grounded; + this.yaw = intent.yaw; + this.pitch = clamp(intent.pitch, this.cfg.pitchMin, this.cfg.pitchMax); + + return this._stateOutput(); + } + + _prepareLocomotion({ + moveDirection, + facingDirection = null, + sprint, + crouch, + jump, + yaw = this.yaw, + pitch = this.pitch, + deltaSeconds, + }: LocomotionInput): CharacterMovementIntent { + const startPosition = this.position.clone(); + const moveDir = this._planarUnit(moveDirection); + const targetSpeed = crouch ? this.cfg.crouchSpeed : sprint ? this.cfg.sprintSpeed : this.cfg.walkSpeed; + const targetVelocity = moveDir.clone().multiplyScalar(targetSpeed); + const hasMoveInput = moveDir.lengthSq() > EPS_SQ; + const nextVelocity = this.velocity.clone(); + let nextGrounded = this.grounded; + + const accelLag = hasMoveInput + ? (nextGrounded ? this.cfg.accelerationLag : this.cfg.airAccelerationLag) + : this.cfg.decelerationLag; + + for (const axis of [this.basis.rightAxis.axis, this.basis.forwardAxis.axis]) { + nextVelocity[axis] = smoothToward(nextVelocity[axis], targetVelocity[axis], accelLag, deltaSeconds); + } + + if (nextGrounded) { + this.basis.flatten(nextVelocity); + if (jump && !crouch) { + this.basis.setHeight(nextVelocity, this.cfg.jumpVelocity); + nextGrounded = false; + } + } + + if (!nextGrounded) { + this.basis.setHeight( + nextVelocity, + Math.max( + this.basis.upComponent(nextVelocity) - this.cfg.gravity * deltaSeconds, + -this.cfg.maxFallSpeed, + ), + ); + } + + let nextYaw = yaw; + const facingDir = facingDirection ? this._planarUnit(facingDirection) : null; + if (facingDir && facingDir.lengthSq() > EPS_SQ) { + const targetYaw = this.basis.forwardToYaw(facingDir); + const yawDelta = Math.atan2(Math.sin(targetYaw - nextYaw), Math.cos(targetYaw - nextYaw)); + nextYaw += yawDelta * smoothingAlpha(this.cfg.turnLag, deltaSeconds); + } + + const nextPitch = clamp(pitch, this.cfg.pitchMin, this.cfg.pitchMax); + const desiredDelta = nextVelocity.clone().multiplyScalar(deltaSeconds); + const position = startPosition.clone().add(desiredDelta); + + return { + position, + startPosition, + desiredDelta, + deltaSeconds, + velocity: nextVelocity.clone(), + grounded: nextGrounded, + yaw: nextYaw, + pitch: nextPitch, + }; + } + + _planarUnit(value: Vector3): Vector3 { + const vector = value.clone(); + this.basis.flatten(vector); + const lengthSq = vector.lengthSq(); + return lengthSq > EPS_SQ ? vector.multiplyScalar(1 / Math.sqrt(lengthSq)) : new Vector3(); + } + + _directionTo(target: Vector3, from: Vector3 = this.position): Vector3 { + return this._planarUnit(target.clone().sub(from)); + } + + _stateOutput(): CharacterMotionState { + const viewFrame = this.basis.yawPitchRollFrame(this.yaw, this.pitch); + const planarMoveFrame = this.basis.yawPitchRollFrame(this.yaw); + return { + position: this.position.clone(), + velocity: this.velocity.clone(), + grounded: this.grounded, + yaw: this.yaw, + pitch: this.pitch, + viewFrame: { + forward: viewFrame.forward, + right: viewFrame.right, + up: viewFrame.up, + }, + planarMoveFrame: { + forward: planarMoveFrame.forward, + right: planarMoveFrame.right, + up: this.basis.upVector(), + }, + }; + } +} diff --git a/playset/modules/actor-motion/character/heading-relative-character-motion-controller.ts b/playset/modules/actor-motion/character/heading-relative-character-motion-controller.ts new file mode 100644 index 0000000..6787b82 --- /dev/null +++ b/playset/modules/actor-motion/character/heading-relative-character-motion-controller.ts @@ -0,0 +1,88 @@ +// playset/modules/actor-motion/character/heading-relative-character-motion-controller.ts — +// character controller driven relative to the current heading (classic +// forward/strafe/turn locomotion). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/character/HeadingRelativeCharacterMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../../math/index.ts"; +import { + BaseCharacterMotionController, + type BaseCharacterMotionControllerOptions, + type CharacterMotionConfig, + type CharacterMotionPlanResult, +} from "./base-character-motion-controller.ts"; + +export interface HeadingRelativeCharacterMotionControllerOptions + extends BaseCharacterMotionControllerOptions { + turnRate?: number; +} + +export interface HeadingRelativePlanOptions { + forward?: number; + backward?: number; + strafeLeft?: number; + strafeRight?: number; + turnLeft?: number; + turnRight?: number; + sprint?: boolean; + crouch?: boolean; + jump?: boolean; + deltaSeconds?: number; + commit?: boolean; +} + +export class HeadingRelativeCharacterMotionController extends BaseCharacterMotionController { + declare cfg: CharacterMotionConfig & { turnRate: number }; + + constructor({ + turnRate = 2.8, + ...config + }: HeadingRelativeCharacterMotionControllerOptions) { + super(config); + this.cfg.turnRate = turnRate; + } + + // forward/backward: 0..1 moves along the local forward/backward directions. + // strafeLeft/strafeRight: 0..1 moves along the local left/right directions. + // turnLeft/turnRight: 0..1 rotates toward the local left/right directions. + planMovement({ + forward = 0, + backward = 0, + strafeLeft = 0, + strafeRight = 0, + turnLeft = 0, + turnRight = 0, + sprint = false, + crouch = false, + jump = false, + deltaSeconds = 1 / 60, + commit = false, + }: HeadingRelativePlanOptions): CharacterMotionPlanResult { + const moveRight = this.basis.controlSignal("left", strafeLeft) + this.basis.controlSignal("right", strafeRight); + const moveForward = this.basis.controlSignal("backward", backward) + this.basis.controlSignal("forward", forward); + const turnInput = this.basis.controlSignal("counterClockWise", turnLeft) + this.basis.controlSignal("clockWise", turnRight); + const nextYaw = this.yaw + turnInput * this.cfg.turnRate * deltaSeconds; + const inputLength = Math.hypot(moveRight, moveForward); + const inputScale = inputLength > 1 ? 1 / inputLength : 1; + const moveFrame = this.basis.yawPitchRollFrame(nextYaw); + const moveDirection = inputLength > 0 + ? moveFrame.right + .multiplyScalar(moveRight * inputScale) + .addScaledVector(moveFrame.forward, moveForward * inputScale) + : new Vector3(); + + const intent = this._prepareLocomotion({ + moveDirection, + sprint, + crouch, + jump, + yaw: nextYaw, + deltaSeconds, + }); + + if (commit) return this.commitMovement(intent); + return intent; + } +} diff --git a/playset/modules/actor-motion/character/mouse-look-character-motion-controller.ts b/playset/modules/actor-motion/character/mouse-look-character-motion-controller.ts new file mode 100644 index 0000000..77a4172 --- /dev/null +++ b/playset/modules/actor-motion/character/mouse-look-character-motion-controller.ts @@ -0,0 +1,90 @@ +// playset/modules/actor-motion/character/mouse-look-character-motion-controller.ts — +// first/third-person character controller: mouse deltas steer yaw/pitch, +// WASD-style axes move relative to the view heading. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/character/MouseLookCharacterMotionController.js. +// Verbatim semantics. + +import { + BaseCharacterMotionController, + type BaseCharacterMotionControllerOptions, + type CharacterMotionConfig, + type CharacterMotionPlanResult, +} from "./base-character-motion-controller.ts"; + +export interface MouseLookCharacterMotionControllerOptions + extends BaseCharacterMotionControllerOptions { + lookSensitivityX?: number; + lookSensitivityY?: number; +} + +export interface MouseLookPlanOptions { + forward?: number; + backward?: number; + strafeLeft?: number; + strafeRight?: number; + sprint?: boolean; + crouch?: boolean; + jump?: boolean; + mouseDeltaX?: number; + mouseDeltaY?: number; + deltaSeconds?: number; + commit?: boolean; +} + +export class MouseLookCharacterMotionController extends BaseCharacterMotionController { + declare cfg: CharacterMotionConfig & { lookSensitivityX: number; lookSensitivityY: number }; + + constructor({ + lookSensitivityX = 0.0022, + lookSensitivityY = 0.0018, + ...config + }: MouseLookCharacterMotionControllerOptions) { + super(config); + this.cfg.lookSensitivityX = lookSensitivityX; + this.cfg.lookSensitivityY = lookSensitivityY; + } + + // forward/backward: 0..1 moves along the local forward/backward directions. + // strafeLeft/strafeRight: 0..1 moves along the local left/right directions. + // mouseDeltaX/mouseDeltaY: rotate view yaw/pitch. + planMovement({ + forward = 0, + backward = 0, + strafeLeft = 0, + strafeRight = 0, + sprint = false, + crouch = false, + jump = false, + mouseDeltaX = 0, + mouseDeltaY = 0, + deltaSeconds = 1 / 60, + commit = false, + }: MouseLookPlanOptions): CharacterMotionPlanResult { + const rightAxis = this.basis.controlSignal("left", strafeLeft) + this.basis.controlSignal("right", strafeRight); + const forwardAxis = this.basis.controlSignal("backward", backward) + this.basis.controlSignal("forward", forward); + const clockWiseSign = this.basis.controlSignal("clockWise", true); + const nextYaw = this.yaw + clockWiseSign * mouseDeltaX * this.cfg.lookSensitivityX; + const nextPitch = this.pitch + clockWiseSign * mouseDeltaY * this.cfg.lookSensitivityY; + const inputLength = Math.hypot(rightAxis, forwardAxis); + const inputScale = inputLength > 1 ? 1 / inputLength : 1; + const moveFrame = this.basis.yawPitchRollFrame(nextYaw); + const moveDirection = moveFrame.right + .multiplyScalar(rightAxis * inputScale) + .addScaledVector(moveFrame.forward, forwardAxis * inputScale); + + const intent = this._prepareLocomotion({ + moveDirection, + sprint, + crouch, + jump, + yaw: nextYaw, + pitch: nextPitch, + deltaSeconds, + }); + + if (commit) return this.commitMovement(intent); + return intent; + } +} diff --git a/playset/modules/actor-motion/character/world-cardinal-character-motion-controller.ts b/playset/modules/actor-motion/character/world-cardinal-character-motion-controller.ts new file mode 100644 index 0000000..2a3db07 --- /dev/null +++ b/playset/modules/actor-motion/character/world-cardinal-character-motion-controller.ts @@ -0,0 +1,84 @@ +// playset/modules/actor-motion/character/world-cardinal-character-motion-controller.ts — +// character controller driven by world-cardinal move axes (tank/top-down style: +// input moves along world axes regardless of facing). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/character/WorldCardinalCharacterMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../../math/index.ts"; +import { + BaseCharacterMotionController, + type BaseCharacterMotionControllerOptions, + type CharacterMotionConfig, + type CharacterMotionPlanResult, +} from "./base-character-motion-controller.ts"; + +export interface WorldCardinalCharacterMotionControllerOptions + extends BaseCharacterMotionControllerOptions { + turnRate?: number; +} + +export interface WorldCardinalPlanOptions { + forward?: number; + backward?: number; + left?: number; + right?: number; + rotateCCW?: number; + rotateCW?: number; + sprint?: boolean; + crouch?: boolean; + jump?: boolean; + deltaSeconds?: number; + commit?: boolean; +} + +export class WorldCardinalCharacterMotionController extends BaseCharacterMotionController { + declare cfg: CharacterMotionConfig & { turnRate: number }; + + constructor({ + turnRate = 2.8, + ...config + }: WorldCardinalCharacterMotionControllerOptions) { + super(config); + this.cfg.turnRate = turnRate; + } + + // forward/backward: 0..1 moves along the basis forward/backward directions. + // left/right: 0..1 moves along the basis left/right directions. + // rotateCCW/rotateCW: 0..1 rotates toward the basis counter-clockwise/clockwise directions. + planMovement({ + forward = 0, + backward = 0, + left = 0, + right = 0, + rotateCCW = 0, + rotateCW = 0, + sprint = false, + crouch = false, + jump = false, + deltaSeconds = 1 / 60, + commit = false, + }: WorldCardinalPlanOptions): CharacterMotionPlanResult { + const moveRight = this.basis.controlSignal("left", left) + this.basis.controlSignal("right", right); + const moveForward = this.basis.controlSignal("backward", backward) + this.basis.controlSignal("forward", forward); + const turnAxis = this.basis.controlSignal("counterClockWise", rotateCCW) + this.basis.controlSignal("clockWise", rotateCW); + const nextYaw = this.yaw + turnAxis * this.cfg.turnRate * deltaSeconds; + const moveDirection = Math.hypot(moveRight, moveForward) > 0 + ? this._planarUnit(this.basis.fromBasisComponents(moveRight, 0, moveForward)) + : new Vector3(); + + const intent = this._prepareLocomotion({ + moveDirection, + facingDirection: turnAxis === 0 && moveDirection.lengthSq() > 0 ? moveDirection : null, + sprint, + crouch, + jump, + yaw: nextYaw, + deltaSeconds, + }); + + if (commit) return this.commitMovement(intent); + return intent; + } +} diff --git a/playset/modules/actor-motion/character/world-target-character-motion-controller.ts b/playset/modules/actor-motion/character/world-target-character-motion-controller.ts new file mode 100644 index 0000000..20141db --- /dev/null +++ b/playset/modules/actor-motion/character/world-target-character-motion-controller.ts @@ -0,0 +1,83 @@ +// playset/modules/actor-motion/character/world-target-character-motion-controller.ts — +// character controller that walks toward / faces world-space target points +// (click-to-move, AI destinations). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/character/WorldTargetCharacterMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../../math/index.ts"; +import type { MutableVec } from "../../math/world-basis.ts"; +import { + BaseCharacterMotionController, + type BaseCharacterMotionControllerOptions, + type CharacterMotionConfig, + type CharacterMotionPlanResult, +} from "./base-character-motion-controller.ts"; + +export interface WorldTargetCharacterMotionControllerOptions + extends BaseCharacterMotionControllerOptions { + stopRadius?: number; +} + +export interface WorldTargetPlanOptions { + moveTarget?: MutableVec | null; + faceTarget?: MutableVec | null; + sprint?: boolean; + crouch?: boolean; + jump?: boolean; + deltaSeconds?: number; + commit?: boolean; +} + +export class WorldTargetCharacterMotionController extends BaseCharacterMotionController { + declare cfg: CharacterMotionConfig & { stopRadius: number }; + + constructor({ + stopRadius = 0.35, + ...config + }: WorldTargetCharacterMotionControllerOptions) { + super(config); + this.cfg.stopRadius = stopRadius; + } + + // moveTarget: move toward a world position. + // faceTarget: face toward a world position when no move target is active. + planMovement({ + moveTarget = null, + faceTarget = null, + sprint = false, + crouch = false, + jump = false, + deltaSeconds = 1 / 60, + commit = false, + }: WorldTargetPlanOptions): CharacterMotionPlanResult { + const activeMoveTarget = moveTarget ? new Vector3(moveTarget.x, moveTarget.y, moveTarget.z) : null; + const activeFaceTarget = faceTarget ? new Vector3(faceTarget.x, faceTarget.y, faceTarget.z) : null; + const targetDistance = activeMoveTarget + ? Math.sqrt(this.basis.distanceSqPlanar(activeMoveTarget, this.position)) + : Infinity; + const targetReached = targetDistance <= this.cfg.stopRadius; + + const moveDirection = activeMoveTarget && !targetReached + ? this._directionTo(activeMoveTarget) + : new Vector3(); + const facingDirection = activeMoveTarget && !targetReached + ? moveDirection + : activeFaceTarget + ? this._directionTo(activeFaceTarget) + : null; + + const intent = this._prepareLocomotion({ + moveDirection, + facingDirection, + sprint, + crouch, + jump, + deltaSeconds, + }); + + if (commit) return this.commitMovement(intent); + return intent; + } +} diff --git a/playset/modules/actor-motion/general-object-model-controller.ts b/playset/modules/actor-motion/general-object-model-controller.ts new file mode 100644 index 0000000..d63c7cf --- /dev/null +++ b/playset/modules/actor-motion/general-object-model-controller.ts @@ -0,0 +1,110 @@ +// playset/modules/actor-motion/general-object-model-controller.ts — pushes a +// motion controller's position + direction frame onto a scene object's +// position/quaternion (with optional basis-up levelling). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/GeneralObjectModelController.js. Verbatim +// semantics; `model` is typed structurally (position + quaternion) so a +// scene3d SceneNode or any test double fits without importing scene3d. + +import { Matrix4, Vector3 } from "../../math/index.ts"; +import type { Quaternion } from "../../math/index.ts"; +import { DEFAULT_WORLD_BASIS, type MutableVec, type WorldBasis } from "../math/world-basis.ts"; + +/** Minimal writable pose — satisfied by scene3d SceneNode and test doubles. */ +export interface ObjectModelLike { + position: Vector3; + quaternion: Quaternion; +} + +export interface ObjectFrameInput { + forward: MutableVec; + right?: MutableVec | null; + up?: MutableVec | null; +} + +export interface GeneralObjectModelControllerOptions { + model?: ObjectModelLike | null; + localForward?: "+z" | "-z"; + basis?: WorldBasis; + keepBasisUp?: boolean; +} + +export class GeneralObjectModelController { + model: ObjectModelLike | null; + basis: WorldBasis; + localForwardSign: 1 | -1; + keepBasisUp: boolean; + modelMatrix: Matrix4; + xAxis: Vector3; + yAxis: Vector3; + zAxis: Vector3; + right: Vector3; + up: Vector3; + forward: Vector3; + + constructor({ + model = null, + localForward = "-z", + basis = DEFAULT_WORLD_BASIS, + keepBasisUp = false, + }: GeneralObjectModelControllerOptions) { + this.model = model; + this.basis = basis; + this.localForwardSign = localForward === "+z" ? 1 : -1; + this.keepBasisUp = keepBasisUp; + + this.modelMatrix = new Matrix4(); + this.xAxis = new Vector3(); + this.yAxis = new Vector3(); + this.zAxis = new Vector3(); + this.right = this.xAxis; + this.up = this.yAxis; + this.forward = this.zAxis; + } + + reset(position: Vector3 | null = null): ObjectModelLike | null { + if (!this.model) return this.model; + + if (position) this.model.position.copy(position); + this.model.quaternion.identity(); + return this.model; + } + + step(position: Vector3 | null, objectFrame: ObjectFrameInput | null = null): ObjectModelLike | null { + if (!this.model) return this.model; + + if (position) this.model.position.copy(position); + if (objectFrame) this.updateObjectFrame(objectFrame); + + return this.model; + } + + updateObjectFrame(objectFrame: ObjectFrameInput): void { + this.zAxis + .set(objectFrame.forward.x, objectFrame.forward.y, objectFrame.forward.z); + + if (this.keepBasisUp) { + this.basis.flatten(this.zAxis); + } + + this.zAxis.normalize().multiplyScalar(this.localForwardSign); + + if (!this.keepBasisUp && objectFrame.right && objectFrame.up) { + this.xAxis + .set(objectFrame.right.x, objectFrame.right.y, objectFrame.right.z) + .normalize() + .multiplyScalar(-this.localForwardSign); + this.yAxis + .set(objectFrame.up.x, objectFrame.up.y, objectFrame.up.z) + .normalize(); + } else { + this.basis.upVector(this.yAxis); + this.xAxis.crossVectors(this.yAxis, this.zAxis).normalize(); + } + + this.modelMatrix.makeBasis(this.xAxis, this.yAxis, this.zAxis); + // Matches the original: calling this directly with a null model throws. + this.model!.quaternion.setFromRotationMatrix(this.modelMatrix); + } +} diff --git a/playset/modules/actor-motion/general-vehicle-motion-controller.ts b/playset/modules/actor-motion/general-vehicle-motion-controller.ts new file mode 100644 index 0000000..3b8ea85 --- /dev/null +++ b/playset/modules/actor-motion/general-vehicle-motion-controller.ts @@ -0,0 +1,328 @@ +// playset/modules/actor-motion/general-vehicle-motion-controller.ts — +// six-axis vehicle motion (hover/space/drone style): local-frame thrust with +// damping and speed cap, path yaw/pitch steering, body yaw, and banking. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/GeneralVehicleMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../math/index.ts"; +import { clamp, smoothToward } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../math/world-basis.ts"; + +export interface GeneralVehicleMotionControllerOptions { + acceleration?: number; + maxSpeed?: number; + damping?: number; + brakeDamping?: number; + steerYawRate?: number; + steerPitchRate?: number; + rotateYawRate?: number; + instantMotion?: boolean; + maxForwardBackwardBank?: number; + maxLeftRightBank?: number; + bankLag?: number; + shiftBankWeight?: number; + steerBankWeight?: number; + basis?: WorldBasis; +} + +export interface VehicleMotionConfig { + acceleration: number; + maxSpeed: number; + damping: number; + brakeDamping: number; + steerYawRate: number; + steerPitchRate: number; + rotateYawRate: number; + instantMotion: boolean; + maxForwardBackwardBank: number; + maxLeftRightBank: number; + bankLag: number; + shiftBankWeight: number; + steerBankWeight: number; +} + +export interface VehiclePlanOptions { + forward?: number; + backward?: number; + left?: number; + right?: number; + up?: number; + down?: number; + steerLeft?: number; + steerRight?: number; + steerUp?: number; + steerDown?: number; + rotateLeft?: number; + rotateRight?: number; + brake?: boolean; + deltaSeconds?: number; + commit?: boolean; +} + +export interface VehicleMovementIntent { + startPosition: Vector3; + desiredDelta: Vector3; + deltaSeconds: number; + position: Vector3; + velocity: Vector3; + pathYaw: number; + pathPitch: number; + relativeBodyYaw: number; + forwardBackwardBank: number; + leftRightBank: number; +} + +/** Resolver output for a vehicle intent — structural, so any resolver or test + * stub with corrected position/velocity works. */ +export interface ResolvedVehicleMovement { + position: Vector3; + velocity: Vector3; +} + +export interface VehicleBodyFrame { + forward: Vector3; + right: Vector3; + up: Vector3; +} + +export interface VehicleMotionState { + position: Vector3; + velocity: Vector3; + bodyFrame: VehicleBodyFrame; +} + +export type VehicleMotionPlanResult = VehicleMovementIntent | VehicleMotionState; + +export interface VehicleResetOptions { + position: Vector3; + velocity: Vector3; + pathYaw?: number; + pathPitch?: number; + relativeBodyYaw?: number; +} + +export class GeneralVehicleMotionController { + basis: WorldBasis; + cfg: VehicleMotionConfig; + position: Vector3; + velocity: Vector3; + pathYaw: number; + pathPitch: number; + relativeBodyYaw: number; + forwardBackwardBank: number; + leftRightBank: number; + + constructor({ + acceleration = 24, + maxSpeed = 40, + damping = 0.8, + brakeDamping = 8, + steerYawRate = 2.8, + steerPitchRate = 2.0, + rotateYawRate = 2.8, + instantMotion = false, + maxForwardBackwardBank = 0, + maxLeftRightBank = 0, + bankLag = 0.12, + shiftBankWeight = 1, + steerBankWeight = 1, + basis = DEFAULT_WORLD_BASIS, + }: GeneralVehicleMotionControllerOptions) { + this.basis = basis; + this.cfg = { + acceleration, + maxSpeed, + damping, + brakeDamping, + steerYawRate, + steerPitchRate, + rotateYawRate, + instantMotion, + maxForwardBackwardBank: Math.max(0, maxForwardBackwardBank), + maxLeftRightBank: Math.max(0, maxLeftRightBank), + bankLag, + shiftBankWeight, + steerBankWeight, + }; + + this.position = new Vector3(); + this.velocity = new Vector3(); + this.pathYaw = 0; + this.pathPitch = 0; + this.relativeBodyYaw = 0; + this.forwardBackwardBank = 0; + this.leftRightBank = 0; + } + + get bodyYaw(): number { + return this.pathYaw + this.relativeBodyYaw; + } + + get bodyPitch(): number { + return this.pathPitch + this.forwardBackwardBank; + } + + get bodyRoll(): number { + return this.leftRightBank; + } + + // forward/backward: 0..1 translates along the local forward/backward directions. + // left/right: 0..1 translates along the local left/right directions. + // up/down: 0..1 translates along the local up/down directions. + // steerLeft/steerRight: 0..1 steers toward the local left/right directions. + // steerUp/steerDown: 0..1 steers toward the local up/down directions. + // rotateLeft/rotateRight: 0..1 rotates the body toward the local left/right directions. + planMovement({ + forward = 0, + backward = 0, + left = 0, + right = 0, + up = 0, + down = 0, + steerLeft = 0, + steerRight = 0, + steerUp = 0, + steerDown = 0, + rotateLeft = 0, + rotateRight = 0, + brake = false, + deltaSeconds = 1 / 60, + commit = false, + }: VehiclePlanOptions): VehicleMotionPlanResult { + const startPosition = this.position.clone(); + const speed = this.velocity.length(); + const steerScale = this.cfg.maxSpeed > 0 ? clamp(speed / this.cfg.maxSpeed, 0, 1) : 0; + + const steerLeftRight = this.basis.controlSignal("counterClockWise", steerLeft) + this.basis.controlSignal("clockWise", steerRight); + const steerUpDown = this.basis.controlSignal("counterClockWise", steerUp) + this.basis.controlSignal("clockWise", steerDown); + + const pathYaw = this.pathYaw + steerLeftRight * this.cfg.steerYawRate * steerScale * deltaSeconds; + const pathPitch = this.pathPitch + steerUpDown * this.cfg.steerPitchRate * steerScale * deltaSeconds; + const relativeBodyYaw = this.relativeBodyYaw + + (rotateLeft - rotateRight) * this.cfg.rotateYawRate * deltaSeconds; + + const shiftRight = this.basis.controlSignal("left", left) + this.basis.controlSignal("right", right); + const shiftUp = this.basis.controlSignal("up", up) + this.basis.controlSignal("down", down); + const shiftForward = this.basis.controlSignal("forward", forward) + this.basis.controlSignal("backward", backward); + + const shiftLength = Math.hypot(shiftRight, shiftUp, shiftForward); + const shiftScale = shiftLength > 1 ? 1 / shiftLength : 1; + const frame = this.basis.yawPitchRollFrame(pathYaw + relativeBodyYaw, pathPitch); + const velocity = this.cfg.instantMotion ? new Vector3() : this.velocity.clone(); + const forwardBackwardBank = this._predictForwardBackwardBank(shiftForward, steerUpDown, deltaSeconds); + const leftRightBank = this._predictLeftRightBank(shiftRight, steerLeftRight, deltaSeconds); + + if (shiftLength > 0) { + const frameMotion = (this.cfg.instantMotion ? this.cfg.maxSpeed : this.cfg.acceleration * deltaSeconds) * shiftScale; + velocity + .addScaledVector(frame.right, shiftRight * frameMotion) + .addScaledVector(frame.up, shiftUp * frameMotion) + .addScaledVector(frame.forward, shiftForward * frameMotion); + } + + if (!this.cfg.instantMotion) { + velocity.multiplyScalar(Math.exp(-(brake ? this.cfg.brakeDamping : this.cfg.damping) * deltaSeconds)); + + const nextSpeed = velocity.length(); + if (nextSpeed > this.cfg.maxSpeed) velocity.multiplyScalar(this.cfg.maxSpeed / nextSpeed); + } + + const desiredDelta = velocity.clone().multiplyScalar(deltaSeconds); + const position = startPosition.clone().add(desiredDelta); + + const intent: VehicleMovementIntent = { + startPosition, + desiredDelta, + deltaSeconds, + position, + velocity, + pathYaw, + pathPitch, + relativeBodyYaw, + forwardBackwardBank, + leftRightBank, + }; + + if (commit) return this.commitMovement(intent); + return intent; + } + + commitMovement( + intent: VehicleMovementIntent, + resolved: ResolvedVehicleMovement | null = null, + ): VehicleMotionState { + this.position.copy(resolved ? resolved.position : intent.position); + this.velocity.copy(resolved ? resolved.velocity : intent.velocity); + this.pathYaw = intent.pathYaw; + this.pathPitch = intent.pathPitch; + this.relativeBodyYaw = intent.relativeBodyYaw; + this.forwardBackwardBank = intent.forwardBackwardBank; + this.leftRightBank = intent.leftRightBank; + const frame = this.basis.yawPitchRollFrame(this.bodyYaw, this.bodyPitch, this.bodyRoll); + return { + position: this.position.clone(), + velocity: this.velocity.clone(), + bodyFrame: { + forward: frame.forward.clone(), + right: frame.right.clone(), + up: frame.up.clone(), + }, + }; + } + + reset({ + position, + velocity, + pathYaw = 0, + pathPitch = 0, + relativeBodyYaw = 0, + }: VehicleResetOptions): VehicleMotionState { + this.position.copy(position); + this.velocity.copy(velocity); + this.pathYaw = pathYaw; + this.pathPitch = pathPitch; + this.relativeBodyYaw = relativeBodyYaw; + this.forwardBackwardBank = 0; + this.leftRightBank = 0; + const frame = this.basis.yawPitchRollFrame(this.bodyYaw, this.bodyPitch, this.bodyRoll); + return { + position: this.position.clone(), + velocity: this.velocity.clone(), + bodyFrame: { + forward: frame.forward.clone(), + right: frame.right.clone(), + up: frame.up.clone(), + }, + }; + } + + _predictForwardBackwardBank(shiftForward: number, steerUpDown: number, deltaSeconds: number): number { + const source = clamp( + -shiftForward * this.cfg.shiftBankWeight + steerUpDown * this.cfg.steerBankWeight, + -1, + 1, + ); + return smoothToward( + this.forwardBackwardBank, + source * this.cfg.maxForwardBackwardBank, + this.cfg.bankLag, + deltaSeconds, + ); + } + + _predictLeftRightBank(shiftRight: number, steerLeftRight: number, deltaSeconds: number): number { + const source = clamp( + shiftRight * this.cfg.shiftBankWeight - steerLeftRight * this.cfg.steerBankWeight, + -1, + 1, + ); + return smoothToward( + this.leftRightBank, + source * this.cfg.maxLeftRightBank, + this.cfg.bankLag, + deltaSeconds, + ); + } +} diff --git a/playset/modules/actor-motion/ground-vehicle/arcade-car-motion-controller.ts b/playset/modules/actor-motion/ground-vehicle/arcade-car-motion-controller.ts new file mode 100644 index 0000000..19930a0 --- /dev/null +++ b/playset/modules/actor-motion/ground-vehicle/arcade-car-motion-controller.ts @@ -0,0 +1,293 @@ +// playset/modules/actor-motion/ground-vehicle/arcade-car-motion-controller.ts — +// kinematic arcade car: bicycle-model steering, throttle/drag speed curve, +// terrain-following ride height and surface-aligned body frame. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/ground-vehicle/ArcadeCarMotionController.js. +// Verbatim semantics. + +import { Vector3 } from "../../../math/index.ts"; +import { clamp, smoothToward } from "../../math/scalar-utils.ts"; +import { toVec3 } from "../../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; + +export interface TerrainSamplerLike { + sample(right: number, forward: number): { height: number; normal: Vector3 }; +} + +export interface ArcadeCarBodyFrame { + right: Vector3; + up: Vector3; + forward: Vector3; +} + +export interface ArcadeCarMotionControllerOptions { + maxForwardSpeed?: number; + maxReverseSpeed?: number; + throttleAccel?: number; + reverseAccel?: number; + engineBrake?: number; + steerLag?: number; + steerAngleMax?: number; + wheelBase?: number; + rideHeight?: number; + boostMultiplier?: number; + basis?: WorldBasis; +} + +export interface ArcadeCarPlanMovementInput { + left?: number; + right?: number; + throttle?: number; + reverse?: number; + boost?: boolean; + deltaSeconds?: number; + terrain?: TerrainSamplerLike | null; + commit?: boolean; +} + +export interface ArcadeCarIntent { + position: Vector3; + startPosition: Vector3; + desiredDelta: Vector3; + velocity: Vector3; + deltaSeconds: number; + yaw: number; + steeringAngle: number; +} + +export interface ArcadeCarCommitResult { + position: Vector3; + velocity: Vector3; + speed: number; + yaw: number; + steering: number; + steeringAngle: number; + surfaceNormal: Vector3; + bodyFrame: ArcadeCarBodyFrame; + collisions: number; +} + +function buildOrientationBasis( + yaw: number, + surfaceNormal: Vector3, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): ArcadeCarBodyFrame { + const worldBasis = basis; + const up = surfaceNormal.clone().normalize(); + + const frame = worldBasis.yawPitchRollFrame(yaw); + const forward = frame.forward.projectOnPlane(up).normalize(); + + const right = new Vector3().crossVectors(forward, up); + right.normalize(); + + forward.crossVectors(up, right).normalize(); + return { right, up, forward }; +} + +function resolveTerrainSample( + terrain: TerrainSamplerLike | null, + worldPosition: VecLike, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): { height: number; normal: Vector3 } { + if (terrain === null) { + return { + height: 0, + normal: basis.upVector(), + }; + } + + const worldBasis = basis; + const planar = worldBasis.toPlanar(worldPosition); + return terrain.sample(planar.right, planar.forward); +} + +function tangentForwardSpeed(velocity: Vector3, basis: ArcadeCarBodyFrame): number { + return velocity.clone().projectOnPlane(basis.up).dot(basis.forward); +} + +export class ArcadeCarMotionController { + cfg: { + maxForwardSpeed: number; + maxReverseSpeed: number; + throttleAccel: number; + reverseAccel: number; + engineBrake: number; + steerLag: number; + steerAngleMax: number; + wheelBase: number; + rideHeight: number; + boostMultiplier: number; + }; + steer: number; + basis: WorldBasis; + position: Vector3; + velocity: Vector3; + surfaceNormal: Vector3; + bodyFrame: ArcadeCarBodyFrame; + yaw: number; + steeringAngle: number; + + constructor({ + maxForwardSpeed = 54, + maxReverseSpeed = 18, + throttleAccel = 40, + reverseAccel = 16, + engineBrake = 1.0, + steerLag = 0.09, + steerAngleMax = 0.56, + wheelBase = 5.6, + rideHeight = 0.38, + boostMultiplier = 1.35, + basis = DEFAULT_WORLD_BASIS, + }: ArcadeCarMotionControllerOptions) { + // Top speed happens when speed stops increasing: + // 0 = throttleAccel - engineBrake * speed, + // speed = throttleAccel / engineBrake. + this.cfg = { + maxForwardSpeed, + maxReverseSpeed, + throttleAccel, + reverseAccel, + engineBrake, + steerLag, + steerAngleMax, + wheelBase, + rideHeight, + boostMultiplier, + }; + + this.steer = 0; + this.basis = basis; + + this.position = new Vector3(); + this.velocity = new Vector3(); + this.surfaceNormal = this.basis.upVector(); + this.bodyFrame = buildOrientationBasis(0, this.surfaceNormal, this.basis); + + this.yaw = 0; + this.steeringAngle = 0; + } + + reset(position: VecLike = { x: 0, y: 0, z: 0 }, yaw = 0): void { + this.position.copy(toVec3(position)); + this.velocity.set(0, 0, 0); + this.surfaceNormal.copy(this.basis.upVector()); + + this.yaw = yaw; + this.steer = 0; + this.steeringAngle = 0; + + const basis = buildOrientationBasis(this.yaw, this.surfaceNormal, this.basis); + this.bodyFrame.right.copy(basis.right); + this.bodyFrame.up.copy(basis.up); + this.bodyFrame.forward.copy(basis.forward); + } + + // left/right: 0..1 steers toward the local left/right directions. + // throttle/reverse: 0..1 accelerates along the local forward/backward directions. + // boost: true scales throttle acceleration. + // terrain: supply height and surface normal. + planMovement({ + left = 0, + right = 0, + throttle = 0, + reverse = 0, + boost = false, + deltaSeconds = 1 / 60, + terrain = null, + commit = false, + }: ArcadeCarPlanMovementInput): ArcadeCarIntent | ArcadeCarCommitResult { + const startPosition = this.position.clone(); + const input = { + steer: + this.basis.controlSignal("counterClockWise", left) + this.basis.controlSignal("clockWise", right), + throttle: clamp(throttle, 0, 1), + reverse: clamp(reverse, 0, 1), + boost: Boolean(boost), + }; + + const terrainNow = resolveTerrainSample(terrain, startPosition, this.basis); + this.steer = + input.steer != 0 ? smoothToward(this.steer, input.steer, this.cfg.steerLag, deltaSeconds) : input.steer; + + const startBasis = buildOrientationBasis(this.yaw, terrainNow.normal, this.basis); + const currentForwardSpeed = tangentForwardSpeed(this.velocity, startBasis); + const steerAngle = this.steer * this.cfg.steerAngleMax; + const yawRate = (currentForwardSpeed * Math.tan(steerAngle)) / this.cfg.wheelBase; + const nextYaw = this.yaw + yawRate * deltaSeconds; + const motionBasis = buildOrientationBasis(nextYaw, terrainNow.normal, this.basis); + + const boostScale = input.boost ? this.cfg.boostMultiplier : 1; + const driveAccel = + input.throttle * this.cfg.throttleAccel * boostScale - input.reverse * this.cfg.reverseAccel; + const dragAccel = -this.cfg.engineBrake * currentForwardSpeed; + const nextForwardSpeed = clamp( + currentForwardSpeed + (driveAccel + dragAccel) * deltaSeconds, + -this.cfg.maxReverseSpeed, + this.cfg.maxForwardSpeed, + ); + + const desiredVelocity = new Vector3().addScaledVector(motionBasis.forward, nextForwardSpeed); + const targetPosition = startPosition.clone().addScaledVector(desiredVelocity, deltaSeconds); + const terrainAfter = resolveTerrainSample(terrain, targetPosition, this.basis); + this.basis.setHeight(targetPosition, terrainAfter.height + this.cfg.rideHeight); + + const desiredDelta = targetPosition.clone().sub(startPosition); + + const intent: ArcadeCarIntent = { + position: targetPosition.clone(), + startPosition, + desiredDelta, + velocity: desiredVelocity.clone(), + deltaSeconds, + yaw: nextYaw, + steeringAngle: steerAngle, + }; + + if (commit) { + return this.commitMovement(intent, null, terrain); + } + return intent; + } + + commitMovement( + intent: ArcadeCarIntent, + resolved: { position: VecLike; velocity: VecLike; collisions?: number } | null = null, + terrain: TerrainSamplerLike | null = null, + ): ArcadeCarCommitResult { + const position = toVec3(resolved ? resolved.position : intent.position); + const velocity = toVec3(resolved ? resolved.velocity : intent.velocity); + const surfaceNormal = resolveTerrainSample(terrain, position, this.basis).normal; + + this.position.copy(position); + this.velocity.copy(velocity); + this.yaw = intent.yaw; + this.steeringAngle = intent.steeringAngle; + this.surfaceNormal.copy(surfaceNormal); + + const basis = buildOrientationBasis(this.yaw, this.surfaceNormal, this.basis); + this.bodyFrame.right.copy(basis.right); + this.bodyFrame.up.copy(basis.up); + this.bodyFrame.forward.copy(basis.forward); + + const tangentSpeed = this.velocity.clone().projectOnPlane(this.bodyFrame.up).length(); + + return { + position: this.position.clone(), + velocity: this.velocity.clone(), + speed: tangentSpeed, + yaw: this.yaw, + steering: this.steer, + steeringAngle: this.steeringAngle, + surfaceNormal: this.surfaceNormal.clone(), + bodyFrame: { + forward: this.bodyFrame.forward.clone(), + right: this.bodyFrame.right.clone(), + up: this.bodyFrame.up.clone(), + }, + collisions: resolved ? (resolved.collisions ?? 0) : 0, + }; + } +} diff --git a/playset/modules/actor-motion/ground-vehicle/car-model-controller.ts b/playset/modules/actor-motion/ground-vehicle/car-model-controller.ts new file mode 100644 index 0000000..7fedcd1 --- /dev/null +++ b/playset/modules/actor-motion/ground-vehicle/car-model-controller.ts @@ -0,0 +1,157 @@ +// playset/modules/actor-motion/ground-vehicle/car-model-controller.ts — maps +// resolved car state onto scene models: chassis pose, wheel spin from forward +// speed, steering yaw on the steered wheels/pivots. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/ground-vehicle/CarModelController.js. +// Verbatim semantics; models are typed structurally instead of three.js +// Object3D (position/quaternion for the chassis, mutable Euler-like rotation +// for wheels and pivots). + +import { Matrix4, Vector3, type Quaternion } from "../../../math/index.ts"; + +const EPS = 1e-6; + +export interface CarModelLike { + position: Vector3; + quaternion: Quaternion; +} + +export interface WheelNodeLike { + rotation: { x: number; y: number }; +} + +export interface CarBodyFrameLike { + right: Vector3; + up: Vector3; + forward: Vector3; +} + +export interface CarModelControllerOptions { + vehicleModel?: CarModelLike | null; + wheels?: WheelNodeLike[]; + wheelPivots?: (WheelNodeLike | null | undefined)[]; + wheelRadius?: number; + steerWheelIndices?: number[]; +} + +export interface CarModelStepInput { + position: Vector3; + bodyFrame?: CarBodyFrameLike | null; + velocity?: Vector3 | null; + /** Rapier3D local yaw angle around up (+Y). */ + steeringAngle: number; + deltaSeconds?: number; +} + +export class CarModelController { + vehicleModel: CarModelLike | null; + wheels: WheelNodeLike[]; + wheelPivots: (WheelNodeLike | null | undefined)[]; + wheelRadius: number; + steerWheelIndices: Set; + wheelSpin: number; + modelMatrix: Matrix4; + forwardVelocity: Vector3; + modelBack: Vector3; + + constructor({ + vehicleModel = null, + wheels = [], + wheelPivots = [], + wheelRadius = 0.35, + steerWheelIndices = [0, 1], + }: CarModelControllerOptions) { + this.vehicleModel = vehicleModel; + this.wheels = wheels; + this.wheelPivots = wheelPivots; + + this.wheelRadius = wheelRadius; + this.steerWheelIndices = new Set(steerWheelIndices); + + this.wheelSpin = 0; + this.modelMatrix = new Matrix4(); + this.forwardVelocity = new Vector3(); + this.modelBack = new Vector3(); + } + + reset(position: Vector3): CarModelLike | null { + this.wheelSpin = 0; + + if (this.vehicleModel) { + this.vehicleModel.position.copy(position); + this.vehicleModel.quaternion.identity(); + } + + for (let i = 0; i < this.wheels.length; i += 1) { + const wheel = this.wheels[i]; + const pivot = this.wheelPivots[i]; + + if (wheel) { + wheel.rotation.x = 0; + wheel.rotation.y = 0; + } + if (pivot) pivot.rotation.y = 0; + } + + return this.vehicleModel; + } + + step({ + position, + bodyFrame, + velocity, + steeringAngle, + deltaSeconds = 1 / 60, + }: CarModelStepInput): CarModelLike | null { + this.updateChassis(position, bodyFrame ?? null); + this.updateWheels(bodyFrame ?? null, velocity ?? null, steeringAngle, deltaSeconds); + + return this.vehicleModel; + } + + updateChassis(position: Vector3, bodyFrame: CarBodyFrameLike | null): void { + if (!this.vehicleModel) return; + + this.vehicleModel.position.copy(position); + + if (bodyFrame?.right && bodyFrame?.up && bodyFrame?.forward) { + // Matrix4.makeBasis asks where local +Z points; since vehicle meshes face + // local -Z, local +Z points to the backward direction. + this.modelBack.copy(bodyFrame.forward).multiplyScalar(-1); + this.modelMatrix.makeBasis(bodyFrame.right, bodyFrame.up, this.modelBack); + this.vehicleModel.quaternion.setFromRotationMatrix(this.modelMatrix); + } + } + + updateWheels( + bodyFrame: CarBodyFrameLike | null, + velocity: Vector3 | null, + steeringAngle: number, // Rapier3D local yaw angle around up (+Y) + deltaSeconds: number, + ): void { + const radius = Math.max(EPS, Math.abs(this.wheelRadius)); + this.wheelSpin += (this.getForwardSpeed(velocity, bodyFrame) * deltaSeconds) / radius; + const localYaw = steeringAngle; + + for (let i = 0; i < this.wheels.length; i += 1) { + const wheel = this.wheels[i]; + const pivot = this.wheelPivots[i]; + const wheelYaw = this.steerWheelIndices.has(i) ? localYaw : 0; + + wheel.rotation.x = this.wheelSpin; + + if (pivot) { + pivot.rotation.y = wheelYaw; + wheel.rotation.y = 0; + } else { + wheel.rotation.y = wheelYaw; + } + } + } + + getForwardSpeed(velocity: Vector3 | null = null, bodyFrame: CarBodyFrameLike | null = null): number { + if (!velocity || !bodyFrame?.forward) return 0; + return this.forwardVelocity.copy(velocity).dot(bodyFrame.forward); + } +} diff --git a/playset/modules/actor-motion/ground-vehicle/drifting-plugin.ts b/playset/modules/actor-motion/ground-vehicle/drifting-plugin.ts new file mode 100644 index 0000000..f210960 --- /dev/null +++ b/playset/modules/actor-motion/ground-vehicle/drifting-plugin.ts @@ -0,0 +1,391 @@ +// playset/modules/actor-motion/ground-vehicle/drifting-plugin.ts — drift +// state machine for the dynamic car: slip/steer/handbrake demand blend, rear +// wheel friction fade, steering assist and yaw-rate assist. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/ground-vehicle/DriftingPlugin.js. Verbatim +// semantics; typed structurally so it plugs into both the motion controller +// (planMovement/commitMovement) and the kinematic batch resolver's rigid-body +// shim (applyDynamicCarControls/yaw assist). + +import type { Vector3 } from "../../../math/index.ts"; +import { clamp, lerp, smoothToward } from "../../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; + +const EPS = 1e-6; +const DRIFT_EFFECT_KEY = "vehicleDrifting"; + +export interface DriftBodyFrameLike { + forward: Vector3; + right: Vector3; + up: Vector3; +} + +export interface DriftControllerLike { + velocity: Vector3; + bodyFrame: DriftBodyFrameLike; + steer: number; + inputSteer: number; + handbrake: boolean; +} + +export interface DriftWheelLike { + name?: string; + steerable?: boolean; + drive?: boolean; + handbrake?: boolean; + frictionSlip: number; + sideFrictionStiffness: number; +} + +export interface DriftWheelControlLike { + index: number; + wheel: DriftWheelLike; + steering: number; + engineForce: number; + brakeForce: number; + handbrakeForce: number; + brake: number; + frictionSlip: number; + sideFrictionStiffness: number; +} + +export interface DriftRigidBodyLike { + angvel(): { x: number; y: number; z: number }; + setAngvel(value: { x: number; y: number; z: number }, wakeUp?: boolean): void; +} + +export interface DriftActorLike { + rigidBody: DriftRigidBodyLike; +} + +export interface DriftDescriptor { + amount: number; + side: number; + yawAssist: number; + maxYawRate: number; + steeringAssist: number; + rearSideFrictionScale: number; + frontSideFrictionScale: number; + rearFrictionSlipScale: number; + frontFrictionSlipScale: number; + handbrakeBrakeScale: number; + rearWheelIndices?: number[]; + rearWheelNames?: string[]; +} + +export interface DriftEffectState { + driftAmount: number; + driftSide: number; + drift: DriftDescriptor | null; +} + +export interface DriftingPluginOptions { + speedForFullDrift?: number; + steerForFullDrift?: number; + slipAngleFull?: number; + steeringDrift?: number; + handbrakeDrift?: number; + enterLag?: number; + exitLag?: number; + steeringAssist?: number; + yawAssist?: number; + maxYawRate?: number; + speedForFullYaw?: number; + rearSideFrictionScale?: number; + frontSideFrictionScale?: number; + rearFrictionSlipScale?: number; + frontFrictionSlipScale?: number; + handbrakeBrakeScale?: number; +} + +function localVelocity( + velocity: Vector3, + bodyFrame: DriftBodyFrameLike, +): { forward: number; right: number; speed: number } { + const tangent = velocity.clone().projectOnPlane(bodyFrame.up); + + return { + forward: tangent.dot(bodyFrame.forward), + right: tangent.dot(bodyFrame.right), + speed: tangent.length(), + }; +} + +function driftWheelMatch(wheel: DriftWheelLike, drift: DriftDescriptor, index: number): boolean { + if (Array.isArray(drift.rearWheelIndices)) return drift.rearWheelIndices.includes(index); + if (Array.isArray(drift.rearWheelNames)) return drift.rearWheelNames.includes(wheel.name ?? ""); + return Boolean(wheel.handbrake || (wheel.drive && !wheel.steerable) || /rear/i.test(wheel.name ?? "")); +} + +function stateFromDrift(drift: DriftDescriptor | null = null): Record { + const amount = drift ? clamp(drift.amount, 0, 1) : 0; + const side = drift ? Math.sign(drift.side) : 0; + return { + [DRIFT_EFFECT_KEY]: { + driftAmount: amount, + driftSide: side, + drift, + }, + }; +} + +export class DriftingPlugin { + id: string; + cfg: { + speedForFullDrift: number; + steerForFullDrift: number; + slipAngleFull: number; + steeringDrift: number; + handbrakeDrift: number; + enterLag: number; + exitLag: number; + steeringAssist: number; + yawAssist: number; + maxYawRate: number; + speedForFullYaw: number; + wheels: { + rearSideFrictionScale: number; + frontSideFrictionScale: number; + rearFrictionSlipScale: number; + frontFrictionSlipScale: number; + handbrakeBrakeScale: number; + }; + }; + driftAmount!: number; + driftSide!: number; + forwardSpeed!: number; + rightSpeed!: number; + + constructor({ + speedForFullDrift = 8, + steerForFullDrift = 0.18, + slipAngleFull = 0.58, + steeringDrift = 0.35, + handbrakeDrift = 1, + enterLag = 0.08, + exitLag = 0.2, + steeringAssist = 0.18, + yawAssist = 5.2, + maxYawRate = 2.8, + speedForFullYaw = 24, + rearSideFrictionScale = 0.3, + frontSideFrictionScale = 1.0, + rearFrictionSlipScale = 1.0, + frontFrictionSlipScale = 1.0, + handbrakeBrakeScale = 0.0, + }: DriftingPluginOptions) { + this.id = "vehicle-drifting"; + this.cfg = { + speedForFullDrift, + steerForFullDrift, + slipAngleFull, + steeringDrift, + handbrakeDrift, + enterLag, + exitLag, + steeringAssist, + yawAssist, + maxYawRate, + speedForFullYaw, + wheels: { + rearSideFrictionScale, + frontSideFrictionScale, + rearFrictionSlipScale, + frontFrictionSlipScale, + handbrakeBrakeScale, + }, + }; + this.reset(); + } + + reset(): { state: DriftEffectState } { + this.driftAmount = 0; + this.driftSide = 0; + this.forwardSpeed = 0; + this.rightSpeed = 0; + return { + state: { + driftAmount: this.driftAmount, + driftSide: this.driftSide, + drift: null, + }, + }; + } + + planMovement({ controller, deltaSeconds }: { controller: DriftControllerLike; deltaSeconds: number }): { + intent: { effects: Record }; + state: DriftEffectState; + } { + const velocity = localVelocity(controller.velocity, controller.bodyFrame); + const speedAbs = velocity.speed; + const steerAbs = Math.abs(controller.steer); + const slipAngle = Math.atan2(Math.abs(velocity.right), Math.max(1, Math.abs(velocity.forward))); + const slipDrift = clamp(slipAngle / Math.max(EPS, this.cfg.slipAngleFull), 0, 1); + const speedDrift = clamp(speedAbs / Math.max(EPS, this.cfg.speedForFullDrift), 0, 1); + const steerDrift = clamp(steerAbs / Math.max(EPS, this.cfg.steerForFullDrift), 0, 1); + const steeringDemand = speedDrift * steerDrift * this.cfg.steeringDrift; + const handbrakeDemand = controller.handbrake ? speedDrift * this.cfg.handbrakeDrift : 0; + const driftTarget = clamp(Math.max(slipDrift, steeringDemand, handbrakeDemand), 0, 1); + const driftAmount = smoothToward( + this.driftAmount, + driftTarget, + driftTarget > this.driftAmount ? this.cfg.enterLag : this.cfg.exitLag, + deltaSeconds, + ); + const sideSource = + Math.abs(controller.inputSteer) > EPS + ? controller.inputSteer + : Math.abs(controller.steer) > EPS + ? controller.steer + : velocity.right; + const driftSide = driftAmount > EPS ? Math.sign(sideSource || this.driftSide) : 0; + const speedScale = clamp(speedAbs / Math.max(EPS, this.cfg.speedForFullYaw), 0, 1.35); + const drift: DriftDescriptor = { + amount: driftAmount, + side: driftSide, + yawAssist: driftSide * this.cfg.yawAssist * speedScale, + maxYawRate: this.cfg.maxYawRate, + steeringAssist: this.cfg.steeringAssist, + ...this.cfg.wheels, + }; + + this.driftAmount = driftAmount; + this.driftSide = driftSide; + this.forwardSpeed = velocity.forward; + this.rightSpeed = velocity.right; + + return { + intent: { + effects: { + [DRIFT_EFFECT_KEY]: drift, + }, + }, + state: { + driftAmount, + driftSide, + drift, + }, + }; + } + + commitMovement({ resolved }: { resolved: { extensionState: Record } }): { + state: DriftEffectState; + } { + const state = resolved.extensionState[DRIFT_EFFECT_KEY] as DriftEffectState; + const drift = state.drift; + this.driftAmount = state.driftAmount; + this.driftSide = state.driftSide; + return { + state: { + driftAmount: this.driftAmount, + driftSide: this.driftSide, + drift, + }, + }; + } + + applyDynamicCarControls({ + actor, + controls, + wheelControls, + deltaSeconds, + state, + basis = DEFAULT_WORLD_BASIS, + }: { + resolver?: unknown; + actor: DriftActorLike; + controls: { effects?: Record }; + wheelControls: DriftWheelControlLike[]; + deltaSeconds: number; + state: Record; + basis?: WorldBasis; + }): void { + const drift = controls.effects?.[DRIFT_EFFECT_KEY] as DriftDescriptor | undefined; + if (!drift) { + const driftState = stateFromDrift(null); + Object.assign(state, driftState); + return; + } + + const amount = drift.amount; + + for (const control of wheelControls) { + const { wheel, index } = control; + const isRear = driftWheelMatch(wheel, drift, index); + const sideScale = isRear ? drift.rearSideFrictionScale : drift.frontSideFrictionScale; + const slipScale = isRear ? drift.rearFrictionSlipScale : drift.frontFrictionSlipScale; + const sideTarget = wheel.sideFrictionStiffness * sideScale; + const slipTarget = wheel.frictionSlip * slipScale; + + control.sideFrictionStiffness = lerp(wheel.sideFrictionStiffness, sideTarget, amount); + control.frictionSlip = lerp(wheel.frictionSlip, slipTarget, amount); + control.steering *= 1 + amount * drift.steeringAssist; + + if (isRear) { + control.handbrakeForce *= lerp(1, drift.handbrakeBrakeScale, amount); + control.brake = control.brakeForce + control.handbrakeForce; + } + } + + this.applyYawAssist(actor, drift, deltaSeconds, basis); + + const driftState = stateFromDrift(drift); + Object.assign(state, driftState); + } + + applyYawAssist( + actor: DriftActorLike, + drift: DriftDescriptor, + deltaSeconds: number, + basis: WorldBasis = DEFAULT_WORLD_BASIS, + ): void { + const amount = clamp(drift.amount, 0, 1); + const yawAccel = drift.yawAssist; + if (deltaSeconds <= 0 || amount <= EPS || Math.abs(yawAccel) <= EPS) return; + + const av = actor.rigidBody.angvel(); + const up = basis.upVector(); + const currentYaw = av.x * up.x + av.y * up.y + av.z * up.z; + const maxYawRate = Math.max(0, drift.maxYawRate); + const requestedYawDelta = yawAccel * amount * deltaSeconds; + let yawDelta = requestedYawDelta; + if (maxYawRate > 0) { + const currentAbs = Math.abs(currentYaw); + const deltaSign = Math.sign(requestedYawDelta); + if (currentAbs >= maxYawRate && Math.sign(currentYaw) === deltaSign) return; + const targetYaw = currentYaw + requestedYawDelta; + if (Math.abs(targetYaw) > maxYawRate && Math.sign(targetYaw) === deltaSign) { + yawDelta = deltaSign * Math.max(0, maxYawRate - currentAbs); + } + } + if (Math.abs(yawDelta) <= EPS) return; + + actor.rigidBody.setAngvel( + { + x: av.x + up.x * yawDelta, + y: av.y + up.y * yawDelta, + z: av.z + up.z * yawDelta, + }, + true, + ); + } + + snapshot(): { + id: string; + driftAmount: number; + driftSide: number; + forwardSpeed: number; + rightSpeed: number; + } { + return { + id: this.id, + driftAmount: this.driftAmount, + driftSide: this.driftSide, + forwardSpeed: this.forwardSpeed, + rightSpeed: this.rightSpeed, + }; + } +} + +export { DRIFT_EFFECT_KEY as VEHICLE_DRIFTING_EFFECT_KEY }; diff --git a/playset/modules/actor-motion/ground-vehicle/dynamic-car-batch-resolver.ts b/playset/modules/actor-motion/ground-vehicle/dynamic-car-batch-resolver.ts new file mode 100644 index 0000000..e0f8675 --- /dev/null +++ b/playset/modules/actor-motion/ground-vehicle/dynamic-car-batch-resolver.ts @@ -0,0 +1,685 @@ +// playset/modules/actor-motion/ground-vehicle/dynamic-car-batch-resolver.ts — +// frame-batched dynamic car resolution: actors carry a full wheel/chassis +// config, queue control intents, and get position/rotation/velocity/wheel +// states back each frame. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/ground-vehicle/DynamicCarBatchResolver.js. +// REENGINEERED: this is a kinematic approximation of the Rapier +// raycast-vehicle; the native Rust physics block is the planned upgrade path. +// Instead of suspension rays + wheel friction solved by a rigid-body engine, +// the car integrates deterministically on the CollisionWorld: +// - longitudinal accel/brake curves derived from the config module's +// engine/brake forces, chassis mass and linear damping (wheel brake +// values act as per-reference-step friction impulses, the +// Bullet/Rapier raycast-vehicle convention); +// - speed-dependent steering via a grip-clamped bicycle model +// (frictionSlip x sideFrictionStiffness bounds lateral acceleration); +// - lateral slip state so DriftingPlugin's slip detection and yaw-rate +// assist (through the actor's rigid-body shim) stay functional; +// - terrain following via world.groundHeightAt with a suspension-lag +// settle, ballistic airborne phase, and body pitch/roll from +// finite-difference terrain normal sampling; +// - planar wall push-out via world.resolveCapsule (circle footprint = +// the chassis' largest planar half extent). +// The public API and per-actor result shape match the original exactly (the +// `rapier` constructor option is gone; `applyGravityToWorld` only reports the +// configured gravity since the collision core has no gravity field). + +import { Matrix4, Quaternion, Vector3 } from "../../../math/index.ts"; +import { clamp, smoothToward, smoothingAlpha } from "../../math/scalar-utils.ts"; +import { VECTOR_EPS } from "../../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type AxisName, type VecLike } from "../../math/world-basis.ts"; +import type { CollisionWorld } from "../../physics/collision-world.ts"; +import { + createDynamicCarConfigForBasis, + type DynamicCarConfig, + type DynamicCarConfigOptions, + type DynamicCarWheel, +} from "./dynamic-car-config.ts"; +import type { + DynamicCarResolvedState, + DynamicCarWheelState, + DynamicCarBodyFrame, +} from "./dynamic-car-motion-controller.ts"; + +/** Wheel brake values are impulses per reference step (Bullet convention). */ +const BRAKE_IMPULSE_HZ = 60; +/** Lateral slip decay rate per unit of sideFrictionStiffness. */ +const LATERAL_GRIP_RATE = 8; +/** Suspension settle lag = SUSPENSION_LAG_SCALE / suspensionStiffness. */ +const SUSPENSION_LAG_SCALE = 3; +/** Airborne bodies relax their surface normal back to world up with this lag. */ +const AIRBORNE_NORMAL_LAG = 0.25; + +export interface DynamicCarControlsIntent { + deltaSeconds?: number; + steeringAngle?: number; + throttle?: number; + reverse?: number; + brake?: number; + handbrake?: boolean; + boost?: boolean; + effects?: Record; + [key: string]: unknown; +} + +export interface DynamicCarWheelControl { + index: number; + wheel: DynamicCarWheel; + steering: number; + engineForce: number; + brakeForce: number; + handbrakeForce: number; + brake: number; + frictionSlip: number; + sideFrictionStiffness: number; +} + +export interface DynamicCarEffect { + applyDynamicCarControls(frame: { + resolver: DynamicCarBatchResolver; + actor: DynamicCarActor; + controls: DynamicCarControlsIntent; + wheelControls: DynamicCarWheelControl[]; + deltaSeconds: number; + state: Record; + basis: WorldBasis; + }): void; +} + +interface DynamicCarSim { + planarRight: number; + planarForward: number; + up: number; + yaw: number; + forwardSpeed: number; + lateralSpeed: number; + verticalSpeed: number; + /** Last step's steering-derived yaw rate (splits plugin assist off angvel). */ + steerYawRate: number; + angularVelocity: Vector3; + grounded: boolean; + normal: Vector3; + rotation: Quaternion; + wheelRotations: number[]; + wheelSteerings: number[]; +} + +/** Rapier-compatible rigid-body surface over the kinematic sim state. */ +export class DynamicCarRigidBody { + private readonly sim: DynamicCarSim; + private readonly basis: WorldBasis; + + constructor(sim: DynamicCarSim, basis: WorldBasis) { + this.sim = sim; + this.basis = basis; + } + + translation(): { x: number; y: number; z: number } { + const v = this.basis.fromBasisComponents(this.sim.planarRight, this.sim.up, this.sim.planarForward); + return { x: v.x, y: v.y, z: v.z }; + } + + rotation(): { x: number; y: number; z: number; w: number } { + const q = this.sim.rotation; + return { x: q.x, y: q.y, z: q.z, w: q.w }; + } + + linvel(): { x: number; y: number; z: number } { + const v = worldVelocity(this.sim, this.basis); + return { x: v.x, y: v.y, z: v.z }; + } + + angvel(): { x: number; y: number; z: number } { + const v = this.sim.angularVelocity; + return { x: v.x, y: v.y, z: v.z }; + } + + setTranslation(value: VecLike, _wakeUp?: boolean): void { + this.sim.planarRight = this.basis.rightComponent(value); + this.sim.up = this.basis.upComponent(value); + this.sim.planarForward = this.basis.forwardComponent(value); + } + + setRotation(value: { x: number; y: number; z: number; w: number }, _wakeUp?: boolean): void { + this.sim.rotation.set(value.x, value.y, value.z, value.w); + this.sim.yaw = this.basis.forwardToYaw( + this.basis.forwardVector().applyQuaternion(this.sim.rotation), + ); + } + + setLinvel(value: VecLike, _wakeUp?: boolean): void { + const sim = this.sim; + const right = this.basis.rightComponent(value); + const forward = this.basis.forwardComponent(value); + const sinYaw = Math.sin(sim.yaw); + const cosYaw = Math.cos(sim.yaw); + sim.forwardSpeed = -sinYaw * right + cosYaw * forward; + sim.lateralSpeed = cosYaw * right + sinYaw * forward; + sim.verticalSpeed = this.basis.upComponent(value); + } + + setAngvel(value: VecLike, _wakeUp?: boolean): void { + this.sim.angularVelocity.set(value.x ?? 0, value.y ?? 0, value.z ?? 0); + } +} + +export interface DynamicCarActor { + config: DynamicCarConfig; + wheels: DynamicCarWheel[]; + rigidBody: DynamicCarRigidBody; + effects: DynamicCarEffect[]; + extensionState: Record; + wheelControls: DynamicCarWheelControl[]; + sim: DynamicCarSim; + capsuleRadius: number; + capsuleHalfHeight: number; + rideHeight: number; + wheelBase: number; + track: number; + climb: number; + suspensionLag: number; + maxSuspensionTravel: number; +} + +export interface DynamicCarActorOptions { + vehicleConfigOptions?: DynamicCarConfigOptions; + position?: VecLike; + yaw?: number; + velocity?: VecLike; + angularVelocity?: VecLike; + effects?: DynamicCarEffect[]; +} + +function gravityObject(magnitude: number, basis: WorldBasis = DEFAULT_WORLD_BASIS): { x: number; y: number; z: number } { + const gravity = basis.downVector().multiplyScalar(magnitude); + return { x: gravity.x, y: gravity.y, z: gravity.z }; +} + +function worldVelocity(sim: DynamicCarSim, basis: WorldBasis, target = new Vector3()): Vector3 { + const sinYaw = Math.sin(sim.yaw); + const cosYaw = Math.cos(sim.yaw); + const right = -sinYaw * sim.forwardSpeed + cosYaw * sim.lateralSpeed; + const forward = cosYaw * sim.forwardSpeed + sinYaw * sim.lateralSpeed; + return basis.fromBasisComponents(right, sim.verticalSpeed, forward, target); +} + +function orientationFrame(yaw: number, surfaceNormal: Vector3, basis: WorldBasis): DynamicCarBodyFrame { + const up = surfaceNormal.clone().normalize(); + const forward = basis.yawPitchRollFrame(yaw).forward.projectOnPlane(up).normalize(); + const right = new Vector3().crossVectors(forward, up).normalize(); + forward.crossVectors(up, right).normalize(); + return { right, up, forward }; +} + +function quaternionFromFrame(frame: DynamicCarBodyFrame, basis: WorldBasis, target: Quaternion): Quaternion { + // Column for world axis a is the body image of that axis: ±frame vector, + // matching the basis' right/up/forward assignment. + const columns: Record = { + x: new Vector3(), + y: new Vector3(), + z: new Vector3(), + }; + columns[basis.rightAxis.axis].copy(frame.right).multiplyScalar(basis.rightAxis.sign); + columns[basis.upAxis.axis].copy(frame.up).multiplyScalar(basis.upAxis.sign); + columns[basis.forwardAxis.axis].copy(frame.forward).multiplyScalar(basis.forwardAxis.sign); + return target.setFromRotationMatrix(new Matrix4().makeBasis(columns.x, columns.y, columns.z)); +} + +export class DynamicCarBatchResolver { + world: CollisionWorld; + basis: WorldBasis; + gravityMagnitude: number; + worldConfig: { + basis: WorldBasis; + minDeltaSeconds: number; + gravity: { x: number; y: number; z: number }; + }; + actors: Set; + queuedMoves: Map; + results: Map; + effects: DynamicCarEffect[]; + + constructor({ + world, + minDeltaSeconds = 1 / 240, + gravityMagnitude = 9.81, + effects = [], + basis = DEFAULT_WORLD_BASIS, + }: { + world: CollisionWorld; + minDeltaSeconds?: number; + gravityMagnitude?: number; + effects?: DynamicCarEffect[]; + basis?: WorldBasis; + }) { + if (!world) { + throw new Error("DynamicCarBatchResolver: world is required"); + } + + this.world = world; + this.basis = basis; + this.gravityMagnitude = gravityMagnitude; + this.worldConfig = { + basis: this.basis, + minDeltaSeconds, + gravity: gravityObject(gravityMagnitude, this.basis), + }; + + this.actors = new Set(); + this.queuedMoves = new Map(); + this.results = new Map(); + this.effects = []; + for (const effect of effects) this.useEffect(effect); + } + + /** The collision core has no gravity field; reports the configured vector. */ + applyGravityToWorld(): { x: number; y: number; z: number } { + return this.worldConfig.gravity; + } + + useEffect(effect: DynamicCarEffect): this { + this.effects.push(effect); + for (const actor of this.actors) actor.effects.push(effect); + return this; + } + + createActor({ + vehicleConfigOptions = {}, + position = { x: 0, y: 0, z: 0 }, + yaw = 0, + velocity = { x: 0, y: 0, z: 0 }, + angularVelocity = { x: 0, y: 0, z: 0 }, + effects = [], + }: DynamicCarActorOptions = {}): DynamicCarActor { + const config = createDynamicCarConfigForBasis(vehicleConfigOptions, this.basis); + const { chassis, wheels } = config; + const b = this.basis; + + const hRight = Math.abs(b.rightComponent(chassis.halfExtents)); + const hUp = Math.abs(b.upComponent(chassis.halfExtents)); + const hForward = Math.abs(b.forwardComponent(chassis.halfExtents)); + + let rideHeightSum = 0; + let stiffnessSum = 0; + let travelSum = 0; + let climb = 0; + let minForward = Infinity; + let maxForward = -Infinity; + let minRight = Infinity; + let maxRight = -Infinity; + for (const wheel of wheels) { + rideHeightSum += wheel.radius + wheel.suspensionRestLength - b.upComponent(wheel.connection); + stiffnessSum += wheel.suspensionStiffness; + travelSum += wheel.maxSuspensionTravel; + climb = Math.max(climb, wheel.radius); + const wf = b.forwardComponent(wheel.connection); + const wr = b.rightComponent(wheel.connection); + minForward = Math.min(minForward, wf); + maxForward = Math.max(maxForward, wf); + minRight = Math.min(minRight, wr); + maxRight = Math.max(maxRight, wr); + } + const wheelCount = Math.max(1, wheels.length); + + const sim: DynamicCarSim = { + planarRight: b.rightComponent(position), + planarForward: b.forwardComponent(position), + up: b.upComponent(position), + yaw, + forwardSpeed: 0, + lateralSpeed: 0, + verticalSpeed: 0, + steerYawRate: 0, + angularVelocity: new Vector3(), + grounded: false, + normal: b.upVector(), + rotation: new Quaternion().setFromAxisAngle(b.upVector(), yaw), + wheelRotations: wheels.map(() => 0), + wheelSteerings: wheels.map(() => 0), + }; + + const rigidBody = new DynamicCarRigidBody(sim, b); + + const actor: DynamicCarActor = { + config, + wheels, + rigidBody, + effects: [...this.effects, ...effects], + extensionState: {}, + wheelControls: [], + sim, + capsuleRadius: Math.max(0.05, hRight, hForward), + capsuleHalfHeight: Math.max(0.05, hUp), + rideHeight: rideHeightSum / wheelCount, + wheelBase: Math.max(0.1, maxForward - minForward), + track: Math.max(0.1, maxRight - minRight), + climb, + suspensionLag: SUSPENSION_LAG_SCALE / Math.max(VECTOR_EPS, stiffnessSum / wheelCount), + maxSuspensionTravel: travelSum / wheelCount, + }; + + rigidBody.setLinvel(velocity); + rigidBody.setAngvel(angularVelocity); + + this._applyControls(actor, {}); + this.actors.add(actor); + return actor; + } + + beginFrame(): void { + this.queuedMoves.clear(); + this.results.clear(); + } + + queueMove(actor: DynamicCarActor, movement: DynamicCarControlsIntent | null = null): void { + this._requireActor(actor); + this.queuedMoves.set(actor, movement); + } + + resetState( + actor: DynamicCarActor, + position: VecLike = { x: 0, y: 0, z: 0 }, + yaw = 0, + ): DynamicCarResolvedState { + this._requireActor(actor); + actor.rigidBody.setTranslation(position, true); + actor.rigidBody.setRotation( + new Quaternion().setFromAxisAngle(this.basis.upVector(), yaw), + true, + ); + actor.rigidBody.setLinvel({ x: 0, y: 0, z: 0 }, true); + actor.rigidBody.setAngvel({ x: 0, y: 0, z: 0 }, true); + actor.sim.steerYawRate = 0; + actor.sim.grounded = false; + actor.sim.normal.copy(this.basis.upVector()); + actor.extensionState = {}; + this._applyControls(actor, {}); + const resolved = this.getResult(actor) as DynamicCarResolvedState; + this.results.set(actor, resolved); + return resolved; + } + + resolveQueuedMoves(deltaSeconds = 1 / 60): Map { + const minDeltaSeconds = this.worldConfig.minDeltaSeconds; + + for (const actor of this.actors) { + const intent = this.queuedMoves.get(actor) ?? {}; + const stepDt = Math.max(minDeltaSeconds, intent.deltaSeconds ?? deltaSeconds); + this._applyControls(actor, intent, stepDt); + if (deltaSeconds > 0) this._integrateActor(actor, stepDt); + } + + this.results.clear(); + for (const actor of this.actors) { + this.results.set(actor, this.getResult(actor) as DynamicCarResolvedState); + } + return this.results; + } + + getResult(actor: DynamicCarActor): DynamicCarResolvedState | null { + if (!this.actors.has(actor)) return null; + + const sim = actor.sim; + const b = this.basis; + const position = b.fromBasisComponents(sim.planarRight, sim.up, sim.planarForward); + const rotation = sim.rotation.clone(); + const velocity = worldVelocity(sim, b); + const angularVelocity = sim.angularVelocity.clone(); + const bodyFrame: DynamicCarBodyFrame = { + forward: b.forwardVector().applyQuaternion(rotation).normalize(), + right: b.rightVector().applyQuaternion(rotation).normalize(), + up: b.upVector().applyQuaternion(rotation).normalize(), + }; + const wheels: DynamicCarWheelState[] = actor.wheels.map((wheel, index) => ({ + index, + name: wheel.name, + steering: sim.wheelSteerings[index] ?? 0, + rotation: sim.wheelRotations[index] ?? 0, + suspensionLength: wheel.suspensionRestLength, + inContact: sim.grounded, + })); + + return { + position, + rotation, + velocity, + angularVelocity, + speed: velocity.length(), + horizontalSpeed: b.planarLength(velocity), + vehicleSpeed: sim.forwardSpeed, + grounded: wheels.some((wheel) => wheel.inContact), + bodyFrame, + wheels, + extensionState: { ...actor.extensionState }, + }; + } + + disposeActor(actor: DynamicCarActor): boolean { + if (!this.actors.has(actor)) return false; + this.actors.delete(actor); + this.queuedMoves.delete(actor); + this.results.delete(actor); + return true; + } + + dispose(): void { + for (const actor of Array.from(this.actors)) this.disposeActor(actor); + } + + _applyControls(actor: DynamicCarActor, controls: DynamicCarControlsIntent = {}, deltaSeconds = 0): void { + const { drive, axes } = actor.config; + const steeringAngle = controls.steeringAngle ?? 0; + const throttle = clamp(controls.throttle ?? 0, 0, 1); + const reverse = clamp(controls.reverse ?? 0, 0, 1); + const brake = clamp(controls.brake ?? 0, 0, 1); + const handbrake = Boolean(controls.handbrake); + const boost = Boolean(controls.boost); + const engineForce = + (throttle * drive.maxEngineForce * (boost ? drive.boostMultiplier : 1) - + reverse * drive.maxReverseForce) * + axes.forwardSign; + const brakeForce = brake * drive.maxBrakeForce; + const handbrakeForce = handbrake ? drive.maxHandbrakeForce : 0; + + const wheelControls: DynamicCarWheelControl[] = actor.wheels.map((wheel, index) => { + const wheelBrakeForce = wheel.brake ? brakeForce * wheel.brakeScale : 0; + const wheelHandbrakeForce = wheel.handbrake ? handbrakeForce * wheel.handbrakeScale : 0; + return { + index, + wheel, + steering: wheel.steerable ? steeringAngle * wheel.steeringScale : 0, + engineForce: wheel.drive ? engineForce * wheel.engineScale : 0, + brakeForce: wheelBrakeForce, + handbrakeForce: wheelHandbrakeForce, + brake: wheelBrakeForce + wheelHandbrakeForce, + frictionSlip: wheel.frictionSlip, + sideFrictionStiffness: wheel.sideFrictionStiffness, + }; + }); + + for (const effect of actor.effects) { + effect.applyDynamicCarControls({ + resolver: this, + actor, + controls, + wheelControls, + deltaSeconds: Math.max(0, deltaSeconds), + state: actor.extensionState, + basis: this.basis, + }); + } + + for (const control of wheelControls) { + control.frictionSlip = Math.max(0, control.frictionSlip); + control.sideFrictionStiffness = Math.max(0, control.sideFrictionStiffness); + actor.sim.wheelSteerings[control.index] = control.steering; + } + actor.wheelControls = wheelControls; + } + + _integrateActor(actor: DynamicCarActor, dt: number): void { + const sim = actor.sim; + const b = this.basis; + const g = this.gravityMagnitude; + const { chassis, damping } = actor.config; + const controls = actor.wheelControls; + const wheelCount = Math.max(1, controls.length); + + let engineForce = 0; + let brakeForce = 0; + let steerSum = 0; + let steerCount = 0; + let gripSum = 0; + let sideSum = 0; + for (const control of controls) { + engineForce += control.engineForce; + brakeForce += control.brake; + if (control.wheel.steerable) { + steerSum += control.steering; + steerCount += 1; + } + gripSum += control.frictionSlip * control.sideFrictionStiffness; + sideSum += control.sideFrictionStiffness; + } + const steering = steerCount > 0 ? steerSum / steerCount : 0; + + // Longitudinal: engine force -> accel, brake as per-step friction impulse. + let forwardSpeed = sim.forwardSpeed; + if (sim.grounded) { + forwardSpeed += ((engineForce * actor.config.axes.forwardSign) / chassis.mass) * dt; + const brakeDv = (brakeForce / chassis.mass) * BRAKE_IMPULSE_HZ * dt; + if (Math.abs(forwardSpeed) <= brakeDv) forwardSpeed = 0; + else forwardSpeed -= Math.sign(forwardSpeed) * brakeDv; + } + forwardSpeed *= Math.max(0, 1 - damping.linear * dt); + + // Yaw: grip-clamped bicycle model; drift assist rides on the angvel state. + let steerYawRate = 0; + if (sim.grounded) { + steerYawRate = (forwardSpeed * Math.tan(steering)) / actor.wheelBase; + if (Math.abs(forwardSpeed) > VECTOR_EPS) { + const maxLateralAccel = (gripSum / wheelCount) * g; + const maxYawRate = maxLateralAccel / Math.abs(forwardSpeed); + steerYawRate = clamp(steerYawRate, -maxYawRate, maxYawRate); + } + } + let assist = b.upComponent(sim.angularVelocity) - sim.steerYawRate; + assist *= Math.exp(-damping.angular * dt); + const yawRate = steerYawRate + assist; + const dYaw = yawRate * dt; + sim.yaw += dYaw; + sim.steerYawRate = steerYawRate; + + // Lateral slip: the heading rotates under the velocity; grip pulls it back. + let lateralSpeed = sim.lateralSpeed + forwardSpeed * Math.sin(dYaw); + const gripRate = sim.grounded ? LATERAL_GRIP_RATE * (sideSum / wheelCount) : 0; + if (gripRate > 0) lateralSpeed *= Math.exp(-gripRate * dt); + + // Planar integrate + wall push-out through the collision core. + const sinYaw = Math.sin(sim.yaw); + const cosYaw = Math.cos(sim.yaw); + const forwardDirRight = -sinYaw; + const forwardDirForward = cosYaw; + const rightDirRight = cosYaw; + const rightDirForward = sinYaw; + const velRight = forwardDirRight * forwardSpeed + rightDirRight * lateralSpeed; + const velForward = forwardDirForward * forwardSpeed + rightDirForward * lateralSpeed; + const current = b.fromBasisComponents(sim.planarRight, sim.up, sim.planarForward); + const desired = b.fromBasisComponents( + sim.planarRight + velRight * dt, + sim.up, + sim.planarForward + velForward * dt, + ); + const resolved = this.world.resolveCapsule(current, desired, { + radius: actor.capsuleRadius, + halfHeight: actor.capsuleHalfHeight, + climb: actor.climb, + snap: 0, + }); + const nextRight = b.rightComponent(resolved.position); + const nextForward = b.forwardComponent(resolved.position); + if (resolved.hitWall && dt > 0) { + const achievedRight = (nextRight - sim.planarRight) / dt; + const achievedForward = (nextForward - sim.planarForward) / dt; + forwardSpeed = achievedRight * forwardDirRight + achievedForward * forwardDirForward; + lateralSpeed = achievedRight * rightDirRight + achievedForward * rightDirForward; + } + sim.planarRight = nextRight; + sim.planarForward = nextForward; + + // Vertical: suspension settle when grounded, ballistic otherwise. + const ground = this.world.groundHeightAt(sim.planarRight, sim.planarForward); + const target = ground + actor.rideHeight; + let verticalSpeed = sim.verticalSpeed; + if (sim.grounded) { + if (sim.up - target > actor.maxSuspensionTravel) { + sim.grounded = false; + verticalSpeed -= g * dt; + sim.up += verticalSpeed * dt; + } else { + const previousUp = sim.up; + sim.up = smoothToward(sim.up, target, actor.suspensionLag, dt); + verticalSpeed = dt > 0 ? (sim.up - previousUp) / dt : 0; + } + } else { + verticalSpeed -= g * dt; + sim.up += verticalSpeed * dt; + if (sim.up <= target) { + sim.up = target; + verticalSpeed = 0; + sim.grounded = true; + } + } + sim.verticalSpeed = verticalSpeed; + + // Surface normal: finite differences along the body axes when grounded, + // relaxing back to world up in the air. + if (sim.grounded) { + const df = actor.wheelBase / 2; + const dr = actor.track / 2; + const hFront = this.world.groundHeightAt( + sim.planarRight + forwardDirRight * df, + sim.planarForward + forwardDirForward * df, + ); + const hBack = this.world.groundHeightAt( + sim.planarRight - forwardDirRight * df, + sim.planarForward - forwardDirForward * df, + ); + const hRight = this.world.groundHeightAt( + sim.planarRight + rightDirRight * dr, + sim.planarForward + rightDirForward * dr, + ); + const hLeft = this.world.groundHeightAt( + sim.planarRight - rightDirRight * dr, + sim.planarForward - rightDirForward * dr, + ); + const slopeForward = (hFront - hBack) / (2 * df); + const slopeRight = (hRight - hLeft) / (2 * dr); + const gradRight = slopeForward * forwardDirRight + slopeRight * rightDirRight; + const gradForward = slopeForward * forwardDirForward + slopeRight * rightDirForward; + b.surfaceNormalFromSlopes(gradRight, gradForward, sim.normal); + } else { + sim.normal.lerp(b.upVector(), smoothingAlpha(AIRBORNE_NORMAL_LAG, dt)).normalize(); + } + + quaternionFromFrame(orientationFrame(sim.yaw, sim.normal, b), b, sim.rotation); + b.fromBasisComponents(0, yawRate, 0, sim.angularVelocity); + + for (let i = 0; i < actor.wheels.length; i += 1) { + const wheel = actor.wheels[i]!; + sim.wheelRotations[i] = + (sim.wheelRotations[i] ?? 0) + (forwardSpeed * dt) / Math.max(VECTOR_EPS, wheel.radius); + } + + sim.forwardSpeed = forwardSpeed; + sim.lateralSpeed = lateralSpeed; + } + + _requireActor(actor: DynamicCarActor): void { + if (!this.actors.has(actor)) { + throw new Error("DynamicCarBatchResolver: unknown actor handle"); + } + } +} diff --git a/playset/modules/actor-motion/ground-vehicle/dynamic-car-config.ts b/playset/modules/actor-motion/ground-vehicle/dynamic-car-config.ts new file mode 100644 index 0000000..95ef58d --- /dev/null +++ b/playset/modules/actor-motion/ground-vehicle/dynamic-car-config.ts @@ -0,0 +1,322 @@ +// playset/modules/actor-motion/ground-vehicle/dynamic-car-config.ts — the +// dynamic car's tuning table: chassis mass/extents, damping, drive forces and +// per-wheel suspension/friction specs, expressed in basis space and resolved +// to world-axis vectors. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/ground-vehicle/DynamicCarRapierConfig.js. +// Verbatim semantics and numbers; only the FILE name drops "rapier" (the +// exported function keeps its original name so call sites port unchanged) — +// the config now feeds the kinematic dynamic-car-batch-resolver. + +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; + +const AXIS_INDEX = Object.freeze({ x: 0, y: 1, z: 2 }); + +export interface BasisComponentsLike { + right?: number; + up?: number; + forward?: number; +} + +export interface XYZ { + x: number; + y: number; + z: number; +} + +interface WheelScalars { + radius: number; + suspensionRestLength: number; + maxSuspensionTravel: number; + suspensionStiffness: number; + suspensionCompression: number; + suspensionRelaxation: number; + maxSuspensionForce: number; + frictionSlip: number; + sideFrictionStiffness: number; + steerable: boolean; + drive: boolean; + brake: boolean; + handbrake: boolean; + steeringScale: number; + engineScale: number; + brakeScale: number; + handbrakeScale: number; +} + +export interface DynamicCarWheelSpec extends Partial { + name?: string; + offset?: BasisComponentsLike; + connection?: XYZ; + position?: XYZ; +} + +export interface DynamicCarWheel extends WheelScalars { + name?: string; + direction: XYZ; + axle: XYZ; + connection: XYZ; + position?: XYZ; +} + +export interface DynamicCarChassisConfig { + mass: number; + friction: number; + restitution: number; + halfExtents: XYZ; + colliderOffset: XYZ; + centerOfMass: XYZ; +} + +export interface DynamicCarDampingConfig { + linear: number; + angular: number; +} + +export interface DynamicCarSolverConfig { + additionalSolverIterations: number; + ccdEnabled: boolean; + canSleep: boolean; +} + +export interface DynamicCarDriveConfig { + maxEngineForce: number; + maxReverseForce: number; + maxBrakeForce: number; + maxHandbrakeForce: number; + boostMultiplier: number; +} + +export interface DynamicCarAxesConfig { + forwardSign: number; + forwardAxis: number; + upAxis: number; +} + +export interface DynamicCarConfig { + chassis: DynamicCarChassisConfig; + damping: DynamicCarDampingConfig; + solver: DynamicCarSolverConfig; + drive: DynamicCarDriveConfig; + axes: DynamicCarAxesConfig; + wheelDefaults: WheelScalars & { direction: XYZ; axle: XYZ }; + wheels: DynamicCarWheel[]; +} + +export interface DynamicCarConfigOptions { + chassis?: Partial> & { + halfExtents?: BasisComponentsLike; + colliderOffset?: BasisComponentsLike; + centerOfMass?: BasisComponentsLike; + }; + wheelDefaults?: Partial & { direction?: XYZ; axle?: XYZ }; + wheelLayout?: { halfRight?: number; up?: number; halfForward?: number }; + wheels?: DynamicCarWheelSpec[]; + engineForwardSign?: number; + wheelDirection?: BasisComponentsLike; + wheelAxle?: BasisComponentsLike; + damping?: Partial; + solver?: Partial; + drive?: Partial; + axes?: Partial; +} + +const DEFAULT_DYNAMIC_VEHICLE_LAYOUT = Object.freeze({ + engineForwardSign: -1, + chassis: Object.freeze({ + halfExtents: Object.freeze({ right: 0.85, up: 0.35, forward: 1.5 }), + colliderOffset: Object.freeze({ right: 0, up: 0.42, forward: 0 }), + centerOfMass: Object.freeze({ right: 0, up: 0.18, forward: 0 }), + }), + wheelLayout: Object.freeze({ + halfRight: 0.84, + up: 0.42, + halfForward: 1.07, + }), + wheelDirection: Object.freeze({ right: 0, up: -1, forward: 0 }), + wheelAxle: Object.freeze({ right: -1, up: 0, forward: 0 }), +}); + +const DEFAULT_DYNAMIC_VEHICLE_SCALARS = Object.freeze({ + chassis: Object.freeze({ + mass: 1250, + friction: 0.7, + restitution: 0.08, + }), + damping: Object.freeze({ + linear: 0.08, + angular: 0.7, + }), + solver: Object.freeze({ + additionalSolverIterations: 8, + ccdEnabled: true, + canSleep: false, + }), + drive: Object.freeze({ + maxEngineForce: 4400, + maxReverseForce: 2200, + maxBrakeForce: 95, + maxHandbrakeForce: 140, + boostMultiplier: 1.35, + }), + wheelDefaults: Object.freeze({ + radius: 0.35, + suspensionRestLength: 0.36, + maxSuspensionTravel: 0.42, + suspensionStiffness: 30, + suspensionCompression: 4.4, + suspensionRelaxation: 5.2, + maxSuspensionForce: 7000, + frictionSlip: 4.2, + sideFrictionStiffness: 1, + steerable: false, + drive: false, + brake: false, + handbrake: false, + steeringScale: 1, + engineScale: 1, + brakeScale: 1, + handbrakeScale: 1, + }), +}); + +function mergeBasisComponents( + base: Required, + override: BasisComponentsLike = {}, +): Required { + return { + right: override.right ?? base.right, + up: override.up ?? base.up, + forward: override.forward ?? base.forward, + }; +} + +function basisObject(components: Required, basis: WorldBasis = DEFAULT_WORLD_BASIS): XYZ { + const worldBasis = basis; + const vector = worldBasis.fromBasisComponents(components.right, components.up, components.forward); + return { x: vector.x, y: vector.y, z: vector.z }; +} + +function basisHalfExtents(components: Required, basis: WorldBasis = DEFAULT_WORLD_BASIS): XYZ { + const vector = basisObject(components, basis); + return { + x: Math.abs(vector.x), + y: Math.abs(vector.y), + z: Math.abs(vector.z), + }; +} + +function defaultWheelSpecs(layout: { halfRight: number; up: number; halfForward: number }): DynamicCarWheelSpec[] { + return [ + { + name: "frontLeft", + offset: { right: -layout.halfRight, up: layout.up, forward: layout.halfForward }, + steerable: true, + drive: false, + brake: true, + handbrake: false, + }, + { + name: "frontRight", + offset: { right: layout.halfRight, up: layout.up, forward: layout.halfForward }, + steerable: true, + drive: false, + brake: true, + handbrake: false, + }, + { + name: "rearLeft", + offset: { right: -layout.halfRight, up: layout.up, forward: -layout.halfForward }, + steerable: false, + drive: true, + brake: true, + handbrake: true, + }, + { + name: "rearRight", + offset: { right: layout.halfRight, up: layout.up, forward: -layout.halfForward }, + steerable: false, + drive: true, + brake: true, + handbrake: true, + }, + ]; +} + +function basisWheelSpec(wheel: DynamicCarWheelSpec, basis: WorldBasis = DEFAULT_WORLD_BASIS): DynamicCarWheelSpec { + const { offset, connection, ...wheelConfig } = wheel; + if (!offset && !connection) { + throw new Error("createDynamicCarConfigForBasis: wheel offset or connection is required"); + } + return { + ...wheelConfig, + connection: + connection ?? basisObject(mergeBasisComponents({ right: 0, up: 0, forward: 0 }, offset), basis), + }; +} + +function makeWheel(spec: DynamicCarWheelSpec, defaults: WheelScalars & { direction: XYZ; axle: XYZ }): DynamicCarWheel { + const wheel = { ...defaults, ...spec }; + return { + ...wheel, + connection: (wheel.connection ?? wheel.position) as XYZ, + }; +} + +export function createDynamicCarConfigForBasis( + options: DynamicCarConfigOptions = {}, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): DynamicCarConfig { + const worldBasis = basis; + const chassisOptions = options.chassis ?? {}; + const chassisLayout = DEFAULT_DYNAMIC_VEHICLE_LAYOUT.chassis; + const { + halfExtents = chassisLayout.halfExtents, + colliderOffset = chassisLayout.colliderOffset, + centerOfMass = chassisLayout.centerOfMass, + ...chassisScalars + } = chassisOptions; + const wheelDefaultOptions = options.wheelDefaults ?? {}; + const { direction, axle, ...wheelDefaultScalars } = wheelDefaultOptions; + const wheelLayout = { + ...DEFAULT_DYNAMIC_VEHICLE_LAYOUT.wheelLayout, + ...options.wheelLayout, + }; + const wheels = options.wheels ?? defaultWheelSpecs(wheelLayout); + const engineForwardSign = + options.engineForwardSign ?? options.axes?.forwardSign ?? DEFAULT_DYNAMIC_VEHICLE_LAYOUT.engineForwardSign; + const wheelDefaults = { + ...DEFAULT_DYNAMIC_VEHICLE_SCALARS.wheelDefaults, + ...wheelDefaultScalars, + direction: + direction ?? + basisObject( + mergeBasisComponents(DEFAULT_DYNAMIC_VEHICLE_LAYOUT.wheelDirection, options.wheelDirection), + worldBasis, + ), + axle: + axle ?? + basisObject(mergeBasisComponents(DEFAULT_DYNAMIC_VEHICLE_LAYOUT.wheelAxle, options.wheelAxle), worldBasis), + }; + + return { + chassis: { + ...DEFAULT_DYNAMIC_VEHICLE_SCALARS.chassis, + ...chassisScalars, + halfExtents: basisHalfExtents(mergeBasisComponents(chassisLayout.halfExtents, halfExtents), worldBasis), + colliderOffset: basisObject(mergeBasisComponents(chassisLayout.colliderOffset, colliderOffset), worldBasis), + centerOfMass: basisObject(mergeBasisComponents(chassisLayout.centerOfMass, centerOfMass), worldBasis), + }, + damping: { ...DEFAULT_DYNAMIC_VEHICLE_SCALARS.damping, ...options.damping }, + solver: { ...DEFAULT_DYNAMIC_VEHICLE_SCALARS.solver, ...options.solver }, + drive: { ...DEFAULT_DYNAMIC_VEHICLE_SCALARS.drive, ...options.drive }, + axes: { + forwardSign: engineForwardSign, + forwardAxis: options.axes?.forwardAxis ?? AXIS_INDEX[worldBasis.forwardAxis.axis], + upAxis: options.axes?.upAxis ?? AXIS_INDEX[worldBasis.upAxis.axis], + }, + wheelDefaults, + wheels: wheels.map((wheel) => makeWheel(basisWheelSpec(wheel, worldBasis), wheelDefaults)), + }; +} diff --git a/playset/modules/actor-motion/ground-vehicle/dynamic-car-motion-controller.ts b/playset/modules/actor-motion/ground-vehicle/dynamic-car-motion-controller.ts new file mode 100644 index 0000000..471db55 --- /dev/null +++ b/playset/modules/actor-motion/ground-vehicle/dynamic-car-motion-controller.ts @@ -0,0 +1,352 @@ +// playset/modules/actor-motion/ground-vehicle/dynamic-car-motion-controller.ts — +// input shaper for the dynamic car: smooths steer/throttle/reverse/brake into +// a per-frame intent, runs plugins (drifting etc.), and mirrors resolved +// physics state back onto itself. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/ground-vehicle/DynamicCarMotionController.js. +// Verbatim semantics. + +import { Quaternion, Vector3 } from "../../../math/index.ts"; +import { clamp, smoothToward } from "../../math/scalar-utils.ts"; +import { toVec3 } from "../../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; + +export interface DynamicCarBodyFrame { + forward: Vector3; + right: Vector3; + up: Vector3; +} + +export interface DynamicCarWheelState { + index: number; + name?: string; + steering: number; + rotation: number; + suspensionLength: number; + inContact: boolean; +} + +export interface DynamicCarResolvedState { + position: Vector3; + rotation: Quaternion; + velocity: Vector3; + angularVelocity: Vector3; + speed: number; + horizontalSpeed: number; + vehicleSpeed: number; + grounded: boolean; + bodyFrame: DynamicCarBodyFrame; + wheels: DynamicCarWheelState[]; + extensionState: Record; +} + +export interface DynamicCarIntent { + deltaSeconds: number; + steeringAngle: number; + throttle: number; + reverse: number; + brake: number; + handbrake: boolean; + boost: boolean; + effects?: Record; + [key: string]: unknown; +} + +export interface DynamicCarPluginResult { + intent?: { effects?: Record; [key: string]: unknown }; + state?: object; +} + +export interface DynamicCarPlugin { + reset?(frame: { + controller: DynamicCarMotionController; + position: VecLike; + yaw: number; + basis: WorldBasis; + }): DynamicCarPluginResult | null | void; + planMovement?(frame: { + controller: DynamicCarMotionController; + intent: DynamicCarIntent; + deltaSeconds: number; + basis: WorldBasis; + }): DynamicCarPluginResult | null | void; + commitMovement?(frame: { + controller: DynamicCarMotionController; + resolved: DynamicCarResolvedState; + basis: WorldBasis; + }): DynamicCarPluginResult | null | void; +} + +export interface DynamicCarMotionControllerOptions { + steerLag?: number; + throttleLag?: number; + reverseLag?: number; + brakeLag?: number; + releaseLag?: number; + maxSteeringAngle?: number; + plugins?: DynamicCarPlugin[]; + basis?: WorldBasis; +} + +export interface DynamicCarPlanMovementInput { + left?: number; + right?: number; + throttle?: number; + reverse?: number; + brake?: number; + handbrake?: boolean; + boost?: boolean; + deltaSeconds?: number; +} + +function quatFromYaw(yaw = 0, basis: WorldBasis = DEFAULT_WORLD_BASIS): Quaternion { + return new Quaternion().setFromAxisAngle(basis.upVector(), yaw); +} + +function basisFromRotation(rotation: Quaternion, basis: WorldBasis = DEFAULT_WORLD_BASIS): DynamicCarBodyFrame { + const worldBasis = basis; + return { + forward: worldBasis.forwardVector().applyQuaternion(rotation).normalize(), + right: worldBasis.rightVector().applyQuaternion(rotation).normalize(), + up: worldBasis.upVector().applyQuaternion(rotation).normalize(), + }; +} + +function mergePluginResult( + controller: DynamicCarMotionController, + intent: DynamicCarIntent | null, + result: DynamicCarPluginResult | null | void, +): void { + if (!result || typeof result !== "object") return; + + const pluginIntent = result.intent; + if (intent && pluginIntent) { + if (pluginIntent.effects) { + intent.effects = Object.assign(intent.effects ?? {}, pluginIntent.effects); + } + + for (const key of Object.keys(pluginIntent)) { + if (key !== "effects") intent[key] = pluginIntent[key]; + } + } + + if (result.state) Object.assign(controller as unknown as Record, result.state); +} + +function callPlugin( + plugin: DynamicCarPlugin, + hook: keyof DynamicCarPlugin, + frame: unknown, +): DynamicCarPluginResult | null | void { + const fn = plugin[hook]; + if (typeof fn === "function") { + return fn.call(plugin, frame as never); + } + return null; +} + +export class DynamicCarMotionController { + cfg: { + steerLag: number; + throttleLag: number; + reverseLag: number; + brakeLag: number; + releaseLag: number; + maxSteeringAngle: number; + }; + basis: WorldBasis; + plugins: DynamicCarPlugin[]; + + position!: Vector3; + rotation!: Quaternion; + velocity!: Vector3; + angularVelocity!: Vector3; + speed!: number; + horizontalSpeed!: number; + vehicleSpeed!: number; + grounded!: boolean; + bodyFrame!: DynamicCarBodyFrame; + wheels!: DynamicCarWheelState[]; + + inputSteer!: number; + steer!: number; + steeringAngle!: number; + throttle!: number; + reverse!: number; + brake!: number; + handbrake!: boolean; + boost!: boolean; + + constructor({ + steerLag = 0.09, + throttleLag = 0.06, + reverseLag = 0.06, + brakeLag = 0.04, + releaseLag = 0.04, + maxSteeringAngle = 0.56, + plugins = [], + basis = DEFAULT_WORLD_BASIS, + }: DynamicCarMotionControllerOptions) { + this.cfg = { + steerLag, + throttleLag, + reverseLag, + brakeLag, + releaseLag, + maxSteeringAngle: maxSteeringAngle, + }; + + this.basis = basis; + this.plugins = []; + + for (const plugin of plugins) this.use(plugin); + + this.initControls(); + this.initMotion(new Vector3(), 0); + this.runPluginHook("reset", { + controller: this, + position: this.position, + yaw: 0, + basis: this.basis, + }); + } + + use(plugin: DynamicCarPlugin): this { + this.plugins.push(plugin); + return this; + } + + reset(position: VecLike = { x: 0, y: 0, z: 0 }, yaw = 0): void { + this.initControls(); + this.initMotion(position, yaw); + this.runPluginHook("reset", { + controller: this, + position, + yaw, + basis: this.basis, + }); + } + + // left/right: 0..1 steers toward the local left/right directions. + // throttle/reverse: 0..1 applies forward/reverse drive pressure. + // brake: 0..1 applies brake pressure. + // handbrake/boost: true triggers discrete action flags. + planMovement({ + left = 0, + right = 0, + throttle = 0, + reverse = 0, + brake = 0, + handbrake = false, + boost = false, + deltaSeconds = 1 / 60, + }: DynamicCarPlanMovementInput): DynamicCarIntent { + const input = { + steer: + this.basis.controlSignal("counterClockWise", left) + this.basis.controlSignal("clockWise", right), + throttle: clamp(throttle, 0, 1), + reverse: clamp(reverse, 0, 1), + brake: clamp(brake, 0, 1), + handbrake: Boolean(handbrake), + boost: Boolean(boost), + }; + + this.inputSteer = input.steer; + this.steer = smoothToward(this.steer, input.steer, this.cfg.steerLag, deltaSeconds); + this.steeringAngle = this.steer * this.cfg.maxSteeringAngle; + this.throttle = smoothToward( + this.throttle, + input.throttle, + input.throttle > this.throttle ? this.cfg.throttleLag : this.cfg.releaseLag, + deltaSeconds, + ); + this.reverse = smoothToward( + this.reverse, + input.reverse, + input.reverse > this.reverse ? this.cfg.reverseLag : this.cfg.releaseLag, + deltaSeconds, + ); + this.brake = smoothToward( + this.brake, + input.brake, + input.brake > this.brake ? this.cfg.brakeLag : this.cfg.releaseLag, + deltaSeconds, + ); + this.handbrake = input.handbrake; + this.boost = input.boost; + + const intent: DynamicCarIntent = { + deltaSeconds, + steeringAngle: this.steeringAngle, + throttle: this.throttle, + reverse: this.reverse, + brake: this.brake, + handbrake: this.handbrake, + boost: this.boost, + }; + + this.runPluginHook( + "planMovement", + { + controller: this, + intent, + deltaSeconds, + basis: this.basis, + }, + intent, + ); + + return intent; + } + + commitMovement(resolved: DynamicCarResolvedState | null = null): void { + if (resolved) { + this.position = resolved.position; + this.rotation = resolved.rotation; + this.velocity = resolved.velocity; + this.angularVelocity = resolved.angularVelocity; + this.speed = resolved.speed; + this.horizontalSpeed = resolved.horizontalSpeed; + this.vehicleSpeed = resolved.vehicleSpeed; + this.grounded = resolved.grounded; + this.bodyFrame = resolved.bodyFrame; + this.wheels = resolved.wheels; + this.runPluginHook("commitMovement", { + controller: this, + resolved, + basis: this.basis, + }); + } + } + + initMotion(position: VecLike, yaw: number): void { + this.position = toVec3(position); + this.rotation = quatFromYaw(yaw, this.basis); + this.velocity = new Vector3(); + this.angularVelocity = new Vector3(); + this.speed = 0; + this.horizontalSpeed = 0; + this.vehicleSpeed = 0; + this.grounded = false; + this.bodyFrame = basisFromRotation(this.rotation, this.basis); + this.wheels = []; + } + + initControls(): void { + this.inputSteer = 0; + this.steer = 0; + this.steeringAngle = 0; + this.throttle = 0; + this.reverse = 0; + this.brake = 0; + this.handbrake = false; + this.boost = false; + } + + runPluginHook(hook: keyof DynamicCarPlugin, frame: unknown, intent: DynamicCarIntent | null = null): void { + for (const plugin of this.plugins) { + mergePluginResult(this, intent, callPlugin(plugin, hook, frame)); + } + } +} diff --git a/playset/modules/actor-motion/kinematic-batch-resolver.ts b/playset/modules/actor-motion/kinematic-batch-resolver.ts new file mode 100644 index 0000000..b4ffc11 --- /dev/null +++ b/playset/modules/actor-motion/kinematic-batch-resolver.ts @@ -0,0 +1,392 @@ +// playset/modules/actor-motion/kinematic-batch-resolver.ts — frame-batched +// kinematic character resolution: actors register capsule-ish colliders, +// queue movement intents, and get grounded/blocked outcomes resolved through +// the deterministic CollisionWorld. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/KinematicBatchResolver.js. REENGINEERED: +// Rapier's KinematicCharacterController is replaced by +// CollisionWorld.resolveCapsule (planar wall slide + climb step-up + ground +// snap), so the `rapier` constructor argument is gone and options arrive as a +// single bag. Actor-vs-actor collision is a planar circle push-out (radius = +// capsule radius) resolved in registration order for determinism; all three +// KINEMATIC_ACTOR_COLLISION_MODES keep their original semantics. Collider +// friction/restitution/group options are accepted but inert (no dynamic +// bodies in the v1 core); the native Rust physics block is the planned +// upgrade path. + +import { Vector3 } from "../../math/index.ts"; +import { toVec3, VECTOR_EPS } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../math/world-basis.ts"; +import type { CollisionWorld } from "../physics/collision-world.ts"; + +export const KINEMATIC_ACTOR_COLLISION_MODES = Object.freeze({ + // Resolve against static world only; queued actors do not block each other. + ignoreActors: "ignoreActors", + // Resolve all actors from their frame-start positions; movement order does not matter. + startPositions: "startPositions", + // Resolve actors one at a time; earlier moves can block later moves. + sequential: "sequential", +} as const); + +export type KinematicActorCollisionMode = + (typeof KINEMATIC_ACTOR_COLLISION_MODES)[keyof typeof KINEMATIC_ACTOR_COLLISION_MODES]; + +const DEFAULT_ACTOR_COLLISION_MODE = KINEMATIC_ACTOR_COLLISION_MODES.startPositions; + +export type KinematicColliderShape = + | { type: "capsule"; halfHeight: number; radius: number } + | { type: "cuboid" | "box"; halfX: number; halfY: number; halfZ: number } + | { type: "ball" | "sphere"; radius: number }; + +/** Accepted for API compatibility; inert in the kinematic v1 core. */ +export interface KinematicColliderOptions { + friction?: number; + restitution?: number; + collisionGroups?: number; + solverGroups?: number; + sensor?: boolean; +} + +export interface KinematicControllerOptions { + offset?: number; + up?: VecLike; + /** autostep.maxHeight maps onto CollisionWorld's `climb`. */ + autostep?: { + enabled?: boolean; + maxHeight?: number; + minWidth?: number; + includeDynamicBodies?: boolean; + }; + /** Positive number maps onto CollisionWorld's `snap`. */ + snapToGround?: number | null; + maxSlopeClimbAngle?: number; + minSlopeSlideAngle?: number; + applyImpulses?: boolean; + characterMass?: number; + slide?: boolean; + normalNudgeFactor?: number; +} + +export interface KinematicActorOptions { + position?: VecLike | null; + bodyOffset?: VecLike | null; + actorCollisionMode?: KinematicActorCollisionMode | null; + groundedProbeDistance?: number; + colliderShape: KinematicColliderShape; + colliderOptions?: KinematicColliderOptions; + controllerOptions?: KinematicControllerOptions; + basis?: WorldBasis; +} + +export interface KinematicActor { + /** Body (collider-center) position — gameplay position + physicsBodyOffset. */ + bodyPosition: Vector3; + physicsBodyOffset: Vector3; + basis: WorldBasis; + up: Vector3; + groundedProbeDistance: number; + actorCollisionMode: KinematicActorCollisionMode | null; + radius: number; + halfHeight: number; + climb: number; + snap: number; +} + +export interface KinematicMoveResult { + position: Vector3; + velocity: Vector3; + correctedDelta: Vector3; + grounded: boolean; + blocked: boolean; + collisions: number; + desiredDelta: Vector3; + startPosition: Vector3; +} + +interface QueuedMove { + actor: KinematicActor; + startPosition: Vector3; + desiredDelta: Vector3; + deltaSeconds: number | undefined; +} + +function capsuleDims( + shape: KinematicColliderShape, + basis: WorldBasis, +): { radius: number; halfHeight: number } { + if (shape.type === "capsule") { + // Rapier capsule half-height excludes the caps; the resolve capsule's + // half height is the full vertical half-extent. + return { radius: shape.radius, halfHeight: shape.halfHeight + shape.radius }; + } + if (shape.type === "cuboid" || shape.type === "box") { + const half = { x: shape.halfX, y: shape.halfY, z: shape.halfZ }; + const hUp = Math.abs(basis.upComponent(half)); + const hRight = Math.abs(basis.rightComponent(half)); + const hForward = Math.abs(basis.forwardComponent(half)); + return { radius: Math.max(hRight, hForward), halfHeight: hUp }; + } + if (shape.type === "ball" || shape.type === "sphere") { + return { radius: shape.radius, halfHeight: shape.radius }; + } + throw new Error( + `KinematicBatchResolver: unsupported shape type "${(shape as { type: string }).type}"`, + ); +} + +export class KinematicBatchResolver { + world: CollisionWorld; + actorCollisionMode: KinematicActorCollisionMode; + basis: WorldBasis; + minDeltaSeconds: number; + worldConfig: { basis: WorldBasis; minDeltaSeconds: number }; + actors: Set; + queuedMoves: QueuedMove[]; + results: Map; + + constructor( + world: CollisionWorld, + { + minDeltaSeconds = 1 / 240, + actorCollisionMode = DEFAULT_ACTOR_COLLISION_MODE, + basis = DEFAULT_WORLD_BASIS, + }: { + minDeltaSeconds?: number; + actorCollisionMode?: KinematicActorCollisionMode; + basis?: WorldBasis; + } = {}, + ) { + if (!world) { + throw new Error("KinematicBatchResolver: world is required"); + } + + this.world = world; + this.actorCollisionMode = actorCollisionMode; + this.basis = basis; + this.minDeltaSeconds = minDeltaSeconds; + this.worldConfig = { + basis: this.basis, + minDeltaSeconds: this.minDeltaSeconds, + }; + + this.actors = new Set(); + this.queuedMoves = []; + this.results = new Map(); + } + + setActorCollisionMode(mode: KinematicActorCollisionMode): void { + this.actorCollisionMode = mode; + } + + createActor({ + position = null, + bodyOffset = null, + actorCollisionMode = null, + groundedProbeDistance = 0, + colliderShape, + colliderOptions = {}, + controllerOptions = {}, + basis = this.basis, + }: KinematicActorOptions): KinematicActor { + void colliderOptions; // accepted for API compatibility; inert in v1 + const gameplayPosition = toVec3(position); + const physicsBodyOffset = toVec3(bodyOffset); + // Public actor position is the gameplay anchor; the body position is + // offset to the collider center. + const bodyPosition = gameplayPosition.clone().add(physicsBodyOffset); + + const basisUp = basis.upVector(); + const dims = capsuleDims(colliderShape, basis); + const autostep = controllerOptions.autostep; + const climb = autostep?.enabled ? (autostep.maxHeight ?? 0) : 0; + const snapToGround = controllerOptions.snapToGround; + const snap = typeof snapToGround === "number" && snapToGround > 0 ? snapToGround : 0; + + const actor: KinematicActor = { + bodyPosition, + physicsBodyOffset, + basis, + up: toVec3(controllerOptions.up ?? basisUp, basisUp), + groundedProbeDistance, + actorCollisionMode, + radius: dims.radius, + halfHeight: dims.halfHeight, + climb, + snap, + }; + + this.actors.add(actor); + + return actor; + } + + beginFrame(): void { + this.queuedMoves.length = 0; + this.results.clear(); + } + + syncActor(actor: KinematicActor | null | undefined, position: VecLike | null | undefined): void { + if (!actor || !position) return; + const bodyPosition = toVec3(position).add(actor.physicsBodyOffset); + actor.bodyPosition.copy(bodyPosition); + } + + queueMove( + actor: KinematicActor, + movement: { startPosition?: VecLike | null; desiredDelta?: VecLike | null; deltaSeconds?: number } = {}, + ): void { + if (!actor || !this.actors.has(actor)) { + throw new Error("KinematicBatchResolver: unknown actor handle"); + } + + this.queuedMoves.push({ + actor, + startPosition: toVec3(movement.startPosition), + desiredDelta: toVec3(movement.desiredDelta), + deltaSeconds: movement.deltaSeconds, + }); + } + + resolveQueuedMoves( + deltaSeconds = 1 / 60, + actorCollisionMode: KinematicActorCollisionMode = this.actorCollisionMode, + ): Map { + void deltaSeconds; // the original stepped the Rapier world here; nothing to step in v1 + const mode = actorCollisionMode; + this.results.clear(); + + if (mode === KINEMATIC_ACTOR_COLLISION_MODES.sequential) { + for (const move of this.queuedMoves) { + this.syncActor(move.actor, move.startPosition); + this.results.set(move.actor, this._resolveMove(move, mode, true)); + } + return this.results; + } + + for (const move of this.queuedMoves) { + this.syncActor(move.actor, move.startPosition); + } + + const commits: { actor: KinematicActor; bodyPosition: Vector3 }[] = []; + for (const move of this.queuedMoves) { + const moveMode = move.actor.actorCollisionMode ?? mode; + const result = this._resolveMove(move, moveMode, false); + this.results.set(move.actor, result); + commits.push({ + actor: move.actor, + bodyPosition: result.position.clone().add(move.actor.physicsBodyOffset), + }); + } + // Bodies only "move" after all resolutions, matching the original's + // deferred kinematic translations (last queued move per actor wins). + for (const commit of commits) { + commit.actor.bodyPosition.copy(commit.bodyPosition); + } + + return this.results; + } + + getResult(actor: KinematicActor): KinematicMoveResult | null { + return this.results.get(actor) ?? null; + } + + _resolveMove( + move: QueuedMove, + mode: KinematicActorCollisionMode, + commitCurrentTranslation: boolean, + ): KinematicMoveResult { + const { actor, startPosition, desiredDelta, deltaSeconds } = move; + const b = actor.basis; + + const desired = actor.bodyPosition.clone().add(desiredDelta); + const resolved = this.world.resolveCapsule(actor.bodyPosition, desired, { + radius: actor.radius, + halfHeight: actor.halfHeight, + climb: actor.climb, + snap: actor.snap, + }); + + let collisions = resolved.hitWall ? 1 : 0; + let grounded = resolved.grounded; + let right = b.rightComponent(resolved.position); + let up = b.upComponent(resolved.position); + let forward = b.forwardComponent(resolved.position); + + if (mode !== KINEMATIC_ACTOR_COLLISION_MODES.ignoreActors) { + // Planar circle push-out vs the other actors, registration order. + for (const other of this.actors) { + if (other === actor) continue; + const oRight = b.rightComponent(other.bodyPosition); + const oUp = b.upComponent(other.bodyPosition); + const oForward = b.forwardComponent(other.bodyPosition); + const feet = up - actor.halfHeight; + const head = up + actor.halfHeight; + const oBottom = oUp - other.halfHeight; + const oTop = oUp + other.halfHeight; + if (head <= oBottom + VECTOR_EPS || feet >= oTop - VECTOR_EPS) continue; + + const dRight = right - oRight; + const dForward = forward - oForward; + const minDist = actor.radius + other.radius; + const distSq = dRight * dRight + dForward * dForward; + if (distSq >= minDist * minDist) continue; + + const dist = Math.sqrt(distSq); + const nRight = dist > VECTOR_EPS ? dRight / dist : 1; + const nForward = dist > VECTOR_EPS ? dForward / dist : 0; + right = oRight + nRight * minDist; + forward = oForward + nForward * minDist; + collisions += 1; + } + + // Re-ground after any push-out (same climb/snap semantics as the world pass). + const ground = this.world.groundHeightAt(right, forward); + const feet = up - actor.halfHeight; + if (feet <= ground + VECTOR_EPS) { + up = ground + actor.halfHeight; + grounded = true; + } else if (actor.snap > 0 && feet - ground <= actor.snap) { + up = ground + actor.halfHeight; + grounded = true; + } + } + + const nextBodyPosition = b.fromBasisComponents(right, up, forward); + const correctedDelta = nextBodyPosition.clone().sub(actor.bodyPosition); + + if (commitCurrentTranslation) { + actor.bodyPosition.copy(nextBodyPosition); + } + + const position = new Vector3( + nextBodyPosition.x - actor.physicsBodyOffset.x, + nextBodyPosition.y - actor.physicsBodyOffset.y, + nextBodyPosition.z - actor.physicsBodyOffset.z, + ); + const velocity = + typeof deltaSeconds === "number" && deltaSeconds > VECTOR_EPS + ? correctedDelta.clone().multiplyScalar(1 / deltaSeconds) + : new Vector3(); + + if (!grounded) grounded = this._queryGrounded(actor, right, up, forward); + + return { + position, + velocity, + correctedDelta, + grounded, + blocked: collisions > 0, + collisions, + desiredDelta: desiredDelta.clone(), + startPosition: startPosition.clone(), + }; + } + + _queryGrounded(actor: KinematicActor, right: number, up: number, forward: number): boolean { + const probeDistance = Math.max(0, actor.groundedProbeDistance); + if (probeDistance <= 0) return false; + const feet = up - actor.halfHeight; + return feet - this.world.groundHeightAt(right, forward) <= probeDistance; + } +} diff --git a/playset/modules/actor-motion/plate-tilt-controller.ts b/playset/modules/actor-motion/plate-tilt-controller.ts new file mode 100644 index 0000000..784353e --- /dev/null +++ b/playset/modules/actor-motion/plate-tilt-controller.ts @@ -0,0 +1,105 @@ +// playset/modules/actor-motion/plate-tilt-controller.ts — smoothed two-axis +// plate tilt (labyrinth/marble-board style) plus the downhill slope signal +// gameplay reads for acceleration. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/PlateTiltController.js. Verbatim semantics. + +import { clamp, smoothToward } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../math/world-basis.ts"; + +export interface PlateTiltControllerOptions { + maxTiltRadians?: number; + tiltLag?: number; + basis?: WorldBasis; +} + +export interface PlateTiltMoveOptions { + forward?: number; + backward?: number; + left?: number; + right?: number; + deltaSeconds?: number; +} + +export interface PlateSlopeSignal { + right: number; + forward: number; +} + +export interface PlateTiltSnapshot { + rightTiltRadians: number; + forwardTiltRadians: number; + slope: PlateSlopeSignal; +} + +export class PlateTiltController { + maxTiltRadians: number; + tiltLag: number; + rightTiltRadians: number; + forwardTiltRadians: number; + basis: WorldBasis; + + constructor({ + maxTiltRadians = 0.13, + tiltLag = 0.10, + basis = DEFAULT_WORLD_BASIS, + }: PlateTiltControllerOptions) { + this.maxTiltRadians = Math.max(1e-6, maxTiltRadians); + this.tiltLag = Math.max(0, tiltLag); + this.rightTiltRadians = 0; + this.forwardTiltRadians = 0; + this.basis = basis; + } + + reset(rightTiltRadians = 0, forwardTiltRadians = 0): PlateTiltSnapshot { + this.rightTiltRadians = rightTiltRadians; + this.forwardTiltRadians = forwardTiltRadians; + return this.snapshot(); + } + + // forward/backward: 0..1 tilts toward the local forward/backward directions. + // left/right: 0..1 tilts toward the local left/right directions. + move({ + forward = 0, + backward = 0, + left = 0, + right = 0, + deltaSeconds = 1 / 60, + }: PlateTiltMoveOptions): PlateTiltSnapshot { + const targetForward = this.basis.controlSignal("clockWise", forward) + this.basis.controlSignal("counterClockWise", backward); + const targetRight = this.basis.controlSignal("clockWise", right) + this.basis.controlSignal("counterClockWise", left); + + this.forwardTiltRadians = smoothToward( + this.forwardTiltRadians, + targetForward * this.maxTiltRadians, + this.tiltLag, + deltaSeconds, + ); + this.rightTiltRadians = smoothToward( + this.rightTiltRadians, + targetRight * this.maxTiltRadians, + this.tiltLag, + deltaSeconds, + ); + + return this.snapshot(); + } + + slopeSignal(): PlateSlopeSignal { + // Gameplay acceleration follows the downhill slope, which is opposite the + // signed rotation angle for positive forward/right tilt. + return { + right: clamp(-this.rightTiltRadians / this.maxTiltRadians, -1, 1), + forward: clamp(-this.forwardTiltRadians / this.maxTiltRadians, -1, 1), + }; + } + + snapshot(): PlateTiltSnapshot { + return { + rightTiltRadians: this.rightTiltRadians, + forwardTiltRadians: this.forwardTiltRadians, + slope: this.slopeSignal(), + }; + } +} diff --git a/playset/modules/actor-motion/snake-motion-controller.ts b/playset/modules/actor-motion/snake-motion-controller.ts new file mode 100644 index 0000000..7c41120 --- /dev/null +++ b/playset/modules/actor-motion/snake-motion-controller.ts @@ -0,0 +1,245 @@ +// playset/modules/actor-motion/snake-motion-controller.ts — grid snake body: +// segment queue, pending growth, and cardinal/chase turning (reversals are +// structurally impossible). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/actor-motion/SnakeMotionController.js. Verbatim semantics. + +export interface SnakeCell { + right: number; + forward: number; +} + +export type SnakeMode = "cardinal" | "chase"; + +export interface SnakeMotionControllerOptions { + initialLength?: number; + initialDirection?: SnakeCell; + startCell?: SnakeCell; + segments?: SnakeCell[] | null; + pendingGrowth?: number; + mode?: SnakeMode; +} + +export interface SnakeResetOptions { + segments?: SnakeCell[] | null; + initialLength?: number; + direction?: SnakeCell; + startCell?: SnakeCell; + pendingGrowth?: number; +} + +export interface SnakeMoveOptions { + left?: boolean; + right?: boolean; + forward?: boolean; + backward?: boolean; +} + +export interface SnakeMoveResult { + direction: SnakeCell; + segments: SnakeCell[]; +} + +const cloneCell = (cell: SnakeCell): SnakeCell => { + return { + right: Math.floor(cell.right), + forward: Math.floor(cell.forward), + }; +}; + +const cloneCells = (cells: SnakeCell[] = []): SnakeCell[] => cells.map(cloneCell); + +const CARDINAL_DIRECTIONS: readonly SnakeCell[] = [ + { right: 0, forward: 1 }, + { right: 1, forward: 0 }, + { right: 0, forward: -1 }, + { right: -1, forward: 0 }, +]; + +function turnRelativeLeft(direction: SnakeCell): SnakeCell { + const index = CARDINAL_DIRECTIONS.findIndex((item) => + item.right === direction.right && item.forward === direction.forward, + ); + + return CARDINAL_DIRECTIONS[(index + 3) % 4]; +} + +function turnRelativeRight(direction: SnakeCell): SnakeCell { + const index = CARDINAL_DIRECTIONS.findIndex((item) => + item.right === direction.right && item.forward === direction.forward, + ); + + return CARDINAL_DIRECTIONS[(index + 1) % 4]; +} + +export function stepSnakeCell(cell: SnakeCell, direction: SnakeCell): SnakeCell { + const current = cloneCell(cell); + const vector = direction; + + return { + right: current.right + vector.right, + forward: current.forward + vector.forward, + }; +} + +function createDefaultSegments( + initialLength: number, + direction: SnakeCell, + startCell: SnakeCell, +): SnakeCell[] { + const head = cloneCell(startCell); + const segments = [head]; + let cursor = head; + const reverseDirection = { + right: -direction.right, + forward: -direction.forward, + }; + + for (let index = 1; index < initialLength; index += 1) { + cursor = stepSnakeCell(cursor, reverseDirection); + segments.push(cursor); + } + + return segments; +} + +export class SnakeMotionController { + mode: SnakeMode; + initialLength: number; + initialDirection: SnakeCell; + startCell: SnakeCell; + pendingGrowth: number; + segments: SnakeCell[]; + direction!: SnakeCell; // assigned by reset() in the constructor + + constructor({ + initialLength = 4, + initialDirection = { forward: 1, right: 0 }, + startCell = { forward: 0, right: 0 }, + segments = null, + pendingGrowth = 0, + mode = "cardinal", + }: SnakeMotionControllerOptions) { + this.mode = mode; + this.initialLength = Math.max(2, Math.floor(initialLength)); + this.initialDirection = { + right: initialDirection.right, + forward: initialDirection.forward, + }; + this.startCell = cloneCell(startCell); + + this.pendingGrowth = 0; + this.segments = []; + + this.reset({ + segments, + pendingGrowth, + }); + } + + get head(): SnakeCell | null { + return this.segments[0] ? cloneCell(this.segments[0]) : null; + } + + get tail(): SnakeCell | null { + return this.segments.length > 0 ? cloneCell(this.segments[this.segments.length - 1]) : null; + } + + get length(): number { + return this.segments.length; + } + + getDirection(): SnakeCell { + return { + right: this.direction.right, + forward: this.direction.forward, + }; + } + + reset({ + segments = null, + initialLength = this.initialLength, + direction = this.initialDirection, + startCell = this.startCell, + pendingGrowth = 0, + }: SnakeResetOptions): SnakeCell[] { + this.direction = { + right: direction.right, + forward: direction.forward, + }; + this.segments = segments + ? cloneCells(segments) + : createDefaultSegments(initialLength, direction, startCell); + this.pendingGrowth = Math.max(0, Math.floor(pendingGrowth)); + return this.getSegments(); + } + + grow(amount = 1): number { + this.pendingGrowth += Math.max(0, Math.floor(amount)); + return this.pendingGrowth; + } + + // left/right: true turns toward the relative left/right directions in chase mode. + // left/right/forward/backward: true moves toward allowed basis-cardinal directions in cardinal mode. + move({ + left = false, + right = false, + forward = false, + backward = false, + }: SnakeMoveOptions): SnakeMoveResult { + let direction: SnakeCell = { + right: this.direction.right, + forward: this.direction.forward, + }; + + if (this.mode === "chase") { + if (left && !right) { + direction = turnRelativeLeft(direction); + } else if (right && !left) { + direction = turnRelativeRight(direction); + } + } else if (direction.forward !== 0) { + if (left && !right) { + direction = CARDINAL_DIRECTIONS[3]; + } else if (right && !left) { + direction = CARDINAL_DIRECTIONS[1]; + } + } else if (direction.right !== 0) { + if (forward && !backward) { + direction = CARDINAL_DIRECTIONS[0]; + } else if (backward && !forward) { + direction = CARDINAL_DIRECTIONS[2]; + } + } + + const head = this.head!; + const nextHead = stepSnakeCell(head, direction); + const isGrowing = this.pendingGrowth > 0; + const pendingGrowth = this.pendingGrowth; + const pendingGrowthAfter = Math.max(0, pendingGrowth - (isGrowing ? 1 : 0)); + const segments = [ + nextHead, + ...this.segments.slice(0, isGrowing ? this.segments.length : -1), + ]; + + this.direction = { + right: direction.right, + forward: direction.forward, + }; + this.pendingGrowth = pendingGrowthAfter; + this.segments = cloneCells(segments); + + return { + direction: { + right: direction.right, + forward: direction.forward, + }, + segments: this.getSegments(), + }; + } + + getSegments(): SnakeCell[] { + return cloneCells(this.segments); + } +} diff --git a/playset/modules/behavior/agent-path-navigator.ts b/playset/modules/behavior/agent-path-navigator.ts new file mode 100644 index 0000000..ade6a2b --- /dev/null +++ b/playset/modules/behavior/agent-path-navigator.ts @@ -0,0 +1,108 @@ +// playset/modules/behavior/agent-path-navigator.ts — converts position and +// current waypoint into planar movement intent (direction, desired speed with +// arrival slowdown, distance). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/behavior/AgentPathNavigator.js. Verbatim semantics. + +import { Vector3 } from "../../math/index.ts"; +import { clamp } from "../math/scalar-utils.ts"; +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +const EPS = 1e-6; + +export interface NavigationIntent { + waypoint: Vector3 | null; + direction: Vector3; + desiredSpeed: number; + distance: number; +} + +export interface AgentPathNavigatorOptions { + maxSpeed?: number; + minSpeed?: number; + arriveRadius?: number; + basis?: WorldBasis; +} + +export interface AgentPathNavigatorStepInput { + position?: VecLike | null; + waypoint?: VecLike | null; + movementEnabled?: boolean; + maxSpeed?: number; +} + +function neutralIntent({ waypoint = null }: { waypoint?: VecLike | null }): NavigationIntent { + const target = waypoint ? toVec3(waypoint) : null; + return { + waypoint: target ? target.clone() : null, + direction: new Vector3(0, 0, 0), + desiredSpeed: 0, + distance: 0, + }; +} + +export class AgentPathNavigator { + maxSpeed: number; + minSpeed: number; + arriveRadius: number; + basis: WorldBasis; + last: NavigationIntent | null; + + constructor({ + maxSpeed = 3.5, + minSpeed = 0, + arriveRadius = 1.25, + basis = DEFAULT_WORLD_BASIS, + }: AgentPathNavigatorOptions) { + this.maxSpeed = maxSpeed; + this.minSpeed = minSpeed; + this.arriveRadius = arriveRadius; + this.basis = basis; + this.last = null; + } + + reset(): void { + this.last = null; + } + + step({ + position = null, + waypoint = null, + movementEnabled = true, + maxSpeed = this.maxSpeed, + }: AgentPathNavigatorStepInput): NavigationIntent { + if (movementEnabled === false || !position || !waypoint) { + this.last = neutralIntent({ waypoint }); + return this.last; + } + + const target = toVec3(waypoint); + const toTarget = target.clone().sub(toVec3(position)); + this.basis.flatten(toTarget); + + const distance = toTarget.length(); + if (distance <= EPS) { + this.last = neutralIntent({ waypoint: target }); + return this.last; + } + + const speedLimit = Math.max(0, maxSpeed); + const arrivalScale = this.arriveRadius > EPS ? clamp(distance / this.arriveRadius, 0, 1) : 1; + const desiredSpeed = clamp( + speedLimit * arrivalScale, + Math.max(0, Math.min(this.minSpeed, speedLimit)), + speedLimit, + ); + + this.last = { + waypoint: target.clone(), + direction: toTarget.multiplyScalar(1 / distance), + desiredSpeed, + distance, + }; + + return this.last; + } +} diff --git a/playset/modules/behavior/combat-behavior-director.ts b/playset/modules/behavior/combat-behavior-director.ts new file mode 100644 index 0000000..9dfedce --- /dev/null +++ b/playset/modules/behavior/combat-behavior-director.ts @@ -0,0 +1,260 @@ +// playset/modules/behavior/combat-behavior-director.ts — tactical state +// machine for shooter agents: idle, patrol, chase, attack, dead, with +// per-agent memory, repath/attack cooldowns, and prng-driven strafing. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/behavior/CombatBehaviorDirector.js. Verbatim semantics. + +import { DEFAULT_PRNG } from "../math/random-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +const distSqPlanar = (a: VecLike, b: VecLike, basis: WorldBasis): number => + basis.distanceSqPlanar(a, b); + +export const ENEMY_BEHAVIOR_STATES = Object.freeze({ + IDLE: "idle", + PATROL: "patrol", + CHASE: "chase", + ATTACK: "attack", + DEAD: "dead", +} as const); +export type EnemyBehaviorState = + (typeof ENEMY_BEHAVIOR_STATES)[keyof typeof ENEMY_BEHAVIOR_STATES]; + +/** Injected prng surface — RandomGenerator satisfies it, as do test doubles. */ +export interface CombatPrng { + random(): number; + uniform(min: number, max: number): number; +} + +interface AgentMemory { + state: EnemyBehaviorState; + waitMs: number; + repathMs: number; + attackMs: number; + preferredDirection: number; +} + +export interface CombatBehaviorDirectorOptions { + idleMinMs?: number; + idleMaxMs?: number; + attackDistance?: number; + chaseDistance?: number; + loseTargetDistance?: number; + repathIntervalMs?: number; + attackCooldownMs?: number; + strafeBias?: number; + prng?: CombatPrng; + basis?: WorldBasis; +} + +export interface CombatBehaviorStepInput { + actorId?: string; + actorPosition?: VecLike | null; + actorAlive?: boolean; + targetPosition?: VecLike | null; + canSeeTarget?: boolean; + canAttackTarget?: boolean; + hasMovePath?: boolean; + deltaMs?: number; +} + +export interface CombatBehaviorCommand { + state: EnemyBehaviorState; + moveTarget: VecLike | null; + aimTarget: VecLike | null; + wantsPatrol: boolean; + wantsPathRefresh: boolean; + wantsAttack: boolean; + movementStyle: EnemyBehaviorState; + lateralMove: number; +} + +export class CombatBehaviorDirector { + idleMinMs: number; + idleMaxMs: number; + attackDistance: number; + chaseDistance: number; + loseTargetDistance: number; + repathIntervalMs: number; + attackCooldownMs: number; + strafeBias: number; + prng: CombatPrng; + basis: WorldBasis; + memory: Map; + + constructor({ + idleMinMs = 1000, + idleMaxMs = 4500, + attackDistance = 2.2, + chaseDistance = 18, + loseTargetDistance = 26, + repathIntervalMs = 450, + attackCooldownMs = 900, + strafeBias = 0.35, + prng = DEFAULT_PRNG, + basis = DEFAULT_WORLD_BASIS, + }: CombatBehaviorDirectorOptions) { + this.idleMinMs = idleMinMs; + this.idleMaxMs = idleMaxMs; + this.attackDistance = attackDistance; + this.chaseDistance = chaseDistance; + this.loseTargetDistance = loseTargetDistance; + this.repathIntervalMs = repathIntervalMs; + this.attackCooldownMs = attackCooldownMs; + this.strafeBias = strafeBias; + this.prng = prng; + this.basis = basis; + + this.memory = new Map(); + } + + private _getMemory(agentId: string): AgentMemory { + if (!this.memory.has(agentId)) { + this.memory.set(agentId, { + state: ENEMY_BEHAVIOR_STATES.IDLE, + waitMs: this._randomIdleTime(), + repathMs: 0, + attackMs: 0, + preferredDirection: this.prng.random() < 0.5 ? 1 : -1, + }); + } + return this.memory.get(agentId)!; + } + + private _randomIdleTime(): number { + return this.prng.uniform(this.idleMinMs, this.idleMaxMs); + } + + private _setState(mem: AgentMemory, state: EnemyBehaviorState): boolean { + if (mem.state === state) return false; + mem.state = state; + if (state === ENEMY_BEHAVIOR_STATES.IDLE) mem.waitMs = this._randomIdleTime(); + if (state === ENEMY_BEHAVIOR_STATES.ATTACK) mem.attackMs = this.attackCooldownMs; + if (state === ENEMY_BEHAVIOR_STATES.CHASE) mem.repathMs = 0; + return true; + } + + reset(agentId: string): void { + this.memory.delete(agentId); + } + + step({ + actorId = "default", + actorPosition = null, + actorAlive = true, + targetPosition = null, + canSeeTarget = false, + canAttackTarget = false, + hasMovePath = false, + deltaMs = 1000 / 60, + }: CombatBehaviorStepInput): CombatBehaviorCommand { + const mem = this._getMemory(actorId); + const targetPos = targetPosition; + + if (actorAlive === false) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.DEAD); + return { + state: mem.state, + moveTarget: null, + aimTarget: null, + wantsPatrol: false, + wantsPathRefresh: false, + wantsAttack: false, + movementStyle: ENEMY_BEHAVIOR_STATES.DEAD, + lateralMove: 0, + }; + } + + if (!targetPos || !actorPosition) { + return { + state: mem.state, + moveTarget: null, + aimTarget: null, + wantsPatrol: true, + wantsPathRefresh: false, + wantsAttack: false, + movementStyle: ENEMY_BEHAVIOR_STATES.IDLE, + lateralMove: 0, + }; + } + + const toTargetDistSq = distSqPlanar(actorPosition, targetPos, this.basis); + const attackDistSq = this.attackDistance * this.attackDistance; + const chaseDistSq = this.chaseDistance * this.chaseDistance; + const loseDistSq = this.loseTargetDistance * this.loseTargetDistance; + + mem.waitMs = Math.max(0, mem.waitMs - deltaMs); + mem.repathMs = Math.max(0, mem.repathMs - deltaMs); + mem.attackMs = Math.max(0, mem.attackMs - deltaMs); + + switch (mem.state) { + case ENEMY_BEHAVIOR_STATES.IDLE: + if (canSeeTarget && toTargetDistSq <= chaseDistSq) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.CHASE); + } else if (mem.waitMs <= 0) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.PATROL); + } + break; + case ENEMY_BEHAVIOR_STATES.PATROL: + if (canSeeTarget && toTargetDistSq <= chaseDistSq) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.CHASE); + } else if (!hasMovePath) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.IDLE); + } + break; + case ENEMY_BEHAVIOR_STATES.CHASE: + if (toTargetDistSq <= attackDistSq) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.ATTACK); + } else if (!canSeeTarget && toTargetDistSq > loseDistSq) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.IDLE); + } + break; + case ENEMY_BEHAVIOR_STATES.ATTACK: + if (toTargetDistSq > attackDistSq * 1.2) { + this._setState(mem, ENEMY_BEHAVIOR_STATES.CHASE); + } + break; + default: + break; + } + + const command: CombatBehaviorCommand = { + state: mem.state, + moveTarget: null, + aimTarget: targetPos, + wantsPatrol: false, + wantsPathRefresh: false, + wantsAttack: false, + movementStyle: mem.state, + lateralMove: 0, + }; + + if (mem.state === ENEMY_BEHAVIOR_STATES.PATROL) { + if (!hasMovePath) { + command.wantsPatrol = true; + } + } + + if (mem.state === ENEMY_BEHAVIOR_STATES.CHASE) { + command.moveTarget = targetPos; + if (mem.repathMs <= 0) { + command.wantsPathRefresh = true; + mem.repathMs = this.repathIntervalMs; + } + if (canSeeTarget && this.prng.random() < this.strafeBias) { + command.lateralMove = mem.preferredDirection; + } + } + + if (mem.state === ENEMY_BEHAVIOR_STATES.ATTACK) { + command.moveTarget = targetPos; + if (canAttackTarget && mem.attackMs <= 0) { + command.wantsAttack = true; + mem.attackMs = this.attackCooldownMs; + } + } + + return command; + } +} diff --git a/playset/modules/behavior/grid-path-planner.ts b/playset/modules/behavior/grid-path-planner.ts new file mode 100644 index 0000000..654c2db --- /dev/null +++ b/playset/modules/behavior/grid-path-planner.ts @@ -0,0 +1,274 @@ +// playset/modules/behavior/grid-path-planner.ts — A* routes and flood-fill +// reachability on a grid board with blocked cells and wrapping or bounded +// edges. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/behavior/GridPathPlanner.js. Verbatim semantics. + +export interface GridCell { + right: number; + forward: number; +} + +/** Injected movement model: per-direction step vectors + expansion order. */ +export interface GridNavigation { + vectors: Readonly>; + neighborOrder: readonly string[]; +} + +export type BlockedCellsInput = + | ReadonlySet + | ReadonlyArray + | null + | undefined; + +export interface GridNeighbor { + cell: GridCell; + direction: string; +} + +interface OpenEntry { + key: string; + cell: GridCell; + f: number; +} + +function cloneCell(cell: GridCell): GridCell { + return { + right: Math.floor(cell.right), + forward: Math.floor(cell.forward), + }; +} + +export function gridCellKey(cell: GridCell): string { + return `${Math.floor(cell.right)}:${Math.floor(cell.forward)}`; +} + +export function normalizeBlockedCells(blocked: BlockedCellsInput = []): Set { + if (blocked instanceof Set) return new Set(blocked); + const keys = new Set(); + for (const cell of (blocked as ReadonlyArray) ?? []) { + if (!cell) continue; + if (typeof cell === "string") { + keys.add(cell); + } else { + keys.add(gridCellKey(cell)); + } + } + return keys; +} + +function wrapDelta(delta: number, size: number): number { + return Math.min(Math.abs(delta), size - Math.abs(delta)); +} + +function priorityInsert(open: OpenEntry[], entry: OpenEntry): void { + let index = open.length; + while (index > 0 && open[index - 1].f > entry.f) { + index -= 1; + } + open.splice(index, 0, entry); +} + +function stepCell( + cell: GridCell, + direction: string, + board: { columns: number; rows: number }, + wrap: boolean, + navigation: GridNavigation, +): GridCell | null { + const vector = navigation.vectors[direction]; + const next = { + right: cell.right + vector.right, + forward: cell.forward + vector.forward, + }; + + if (wrap) { + if (next.right < 0) next.right = board.columns - 1; + if (next.right >= board.columns) next.right = 0; + if (next.forward < 0) next.forward = board.rows - 1; + if (next.forward >= board.rows) next.forward = 0; + return next; + } + + if ( + next.right < 0 || + next.right >= board.columns || + next.forward < 0 || + next.forward >= board.rows + ) { + return null; + } + + return next; +} + +export interface GridPathPlannerOptions { + navigation: GridNavigation; + columns?: number; + rows?: number; + wrap?: boolean; + neighborOrder?: readonly string[] | null; +} + +export class GridPathPlanner { + navigation: GridNavigation; + columns: number; + rows: number; + wrap: boolean; + neighborOrder: string[]; + + constructor({ navigation, columns = 20, rows = 20, wrap = true, neighborOrder = null }: GridPathPlannerOptions) { + this.navigation = navigation; + this.columns = Math.floor(columns); + this.rows = Math.floor(rows); + this.wrap = wrap !== false; + this.neighborOrder = [...(neighborOrder ?? this.navigation.neighborOrder)]; + } + + setBoard(columns: number = this.columns, rows: number = this.rows, wrap: boolean = this.wrap): this { + this.columns = Math.floor(columns); + this.rows = Math.floor(rows); + this.wrap = wrap !== false; + return this; + } + + heuristic(a: GridCell, b: GridCell): number { + const dRight = this.wrap + ? wrapDelta(a.right - b.right, this.columns) + : Math.abs(a.right - b.right); + const dForward = this.wrap + ? wrapDelta(a.forward - b.forward, this.rows) + : Math.abs(a.forward - b.forward); + return dRight + dForward; + } + + getNeighbors(cell: GridCell, wrap: boolean = this.wrap): GridNeighbor[] { + const neighbors: GridNeighbor[] = []; + for (const direction of this.neighborOrder) { + const next = stepCell(cell, direction, this, wrap, this.navigation); + if (!next) continue; + neighbors.push({ + cell: next, + direction, + }); + } + return neighbors; + } + + findPath( + start: GridCell | null | undefined, + goal: GridCell | null | undefined, + blocked: BlockedCellsInput = [], + allowStartOccupied = true, + allowGoalOccupied = true, + wrap: boolean = this.wrap, + ): GridCell[] | null { + if (!start || !goal) return null; + + const startCell = cloneCell(start); + const goalCell = cloneCell(goal); + const startKey = gridCellKey(startCell); + const goalKey = gridCellKey(goalCell); + const blockedKeys = normalizeBlockedCells(blocked); + if (allowStartOccupied) blockedKeys.delete(startKey); + if (allowGoalOccupied) blockedKeys.delete(goalKey); + + if (startKey === goalKey) { + return [startCell]; + } + + const open: OpenEntry[] = []; + const cameFrom = new Map(); + const costSoFar = new Map(); + + costSoFar.set(startKey, 0); + priorityInsert(open, { + key: startKey, + cell: startCell, + f: this.heuristic(startCell, goalCell), + }); + + while (open.length > 0) { + const current = open.shift()!; + if (current.key === goalKey) { + const path = [goalCell]; + let cursorKey = goalKey; + while (cursorKey !== startKey) { + const prev = cameFrom.get(cursorKey); + if (!prev) return null; + path.push(prev.cell); + cursorKey = prev.key; + } + path.reverse(); + return path; + } + + const currentCost = costSoFar.get(current.key) ?? 0; + for (const neighbor of this.getNeighbors(current.cell, wrap)) { + const neighborKey = gridCellKey(neighbor.cell); + if (blockedKeys.has(neighborKey)) continue; + + const nextCost = currentCost + 1; + if (nextCost >= (costSoFar.get(neighborKey) ?? Infinity)) continue; + + costSoFar.set(neighborKey, nextCost); + cameFrom.set(neighborKey, { + key: current.key, + cell: current.cell, + direction: neighbor.direction, + }); + priorityInsert(open, { + key: neighborKey, + cell: neighbor.cell, + f: nextCost + this.heuristic(neighbor.cell, goalCell), + }); + } + } + + return null; + } + + floodFill( + start: GridCell | null | undefined, + blocked: BlockedCellsInput = [], + allowStartOccupied = true, + wrap: boolean = this.wrap, + limit = Infinity, + ): { count: number; cells: GridCell[] } { + if (!start) { + return { count: 0, cells: [] }; + } + + const startCell = cloneCell(start); + const startKey = gridCellKey(startCell); + const blockedKeys = normalizeBlockedCells(blocked); + if (allowStartOccupied) blockedKeys.delete(startKey); + + if (blockedKeys.has(startKey)) { + return { count: 0, cells: [] }; + } + + const queue = [startCell]; + const seen = new Set([startKey]); + const cells: GridCell[] = []; + + while (queue.length > 0) { + const current = queue.shift()!; + cells.push(current); + if (cells.length >= limit) break; + + for (const neighbor of this.getNeighbors(current, wrap)) { + const neighborKey = gridCellKey(neighbor.cell); + if (seen.has(neighborKey) || blockedKeys.has(neighborKey)) continue; + seen.add(neighborKey); + queue.push(neighbor.cell); + } + } + + return { + count: cells.length, + cells, + }; + } +} diff --git a/playset/modules/behavior/nearby-avoidance-steering.ts b/playset/modules/behavior/nearby-avoidance-steering.ts new file mode 100644 index 0000000..497de17 --- /dev/null +++ b/playset/modules/behavior/nearby-avoidance-steering.ts @@ -0,0 +1,134 @@ +// playset/modules/behavior/nearby-avoidance-steering.ts — planar steering +// away from nearby agents while preserving intended travel direction. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/behavior/NearbyAvoidanceSteering.js. Verbatim semantics. + +import { Vector3 } from "../../math/index.ts"; +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +const EPS = 1e-6; + +/** A neighbor is either a positioned entity or a bare position. */ +export type NeighborLike = { position?: VecLike | null } | VecLike | null | undefined; + +export interface NearbyAvoidanceSteeringOptions { + neighborDistance?: number; + separationWeight?: number; + sideStepWeight?: number; + maxSteering?: number; + blockerDot?: number; + basis?: WorldBasis; +} + +export interface AvoidanceStepInput { + selfPosition: VecLike; + neighbors?: readonly NeighborLike[]; + desiredDirection?: VecLike | null; + preferredDirection?: number; + self?: unknown; +} + +export interface AvoidanceResult { + steering: Vector3; + blockers: number; + blocked: boolean; +} + +export class NearbyAvoidanceSteering { + neighborDistance: number; + separationWeight: number; + sideStepWeight: number; + maxSteering: number; + blockerDot: number; + basis: WorldBasis; + private _toNeighbor: Vector3; + private _side: Vector3; + private _away: Vector3; + private _desired: Vector3; + + constructor({ + neighborDistance = 2.5, + separationWeight = 1.2, + sideStepWeight = 0.8, + maxSteering = 2.5, + blockerDot = 0.75, + basis = DEFAULT_WORLD_BASIS, + }: NearbyAvoidanceSteeringOptions) { + this.neighborDistance = neighborDistance; + this.separationWeight = separationWeight; + this.sideStepWeight = sideStepWeight; + this.maxSteering = maxSteering; + this.blockerDot = blockerDot; + this.basis = basis; + this._toNeighbor = new Vector3(); + this._side = new Vector3(); + this._away = new Vector3(); + this._desired = new Vector3(); + } + + step({ + selfPosition, + neighbors = [], + desiredDirection = null, + preferredDirection = 1, + self = null, + }: AvoidanceStepInput): AvoidanceResult { + const steering = new Vector3(0, 0, 0); + const resolvedSelfPosition = toVec3(selfPosition); + + this._desired.copy(toVec3(desiredDirection)); + this.basis.flatten(this._desired); + if (this._desired.lengthSq() > EPS * EPS) this._desired.normalize(); + + let blockers = 0; + const maxDistance = Math.max(EPS, this.neighborDistance); + + for (const other of neighbors) { + if (other === self) continue; + + const otherPos = + ((other as { position?: VecLike | null } | null | undefined)?.position ?? other) as + | VecLike + | null + | undefined; + if (!otherPos) continue; + + // subVectors only reads x/y/z, so bare positions work (as in the original). + this._toNeighbor.subVectors(resolvedSelfPosition, otherPos as Vector3); + this.basis.flatten(this._toNeighbor); + const distance = this._toNeighbor.length(); + if (distance <= EPS || distance > maxDistance) continue; + + const push = 1 - distance / maxDistance; + const invDistance = 1 / Math.max(EPS, distance); + + this._away.copy(this._toNeighbor).multiplyScalar(invDistance * push * this.separationWeight); + steering.add(this._away); + + this.basis + .sideVector(this._toNeighbor, preferredDirection, this._side) + .multiplyScalar(invDistance); + if (this._side.lengthSq() > EPS * EPS) { + this._side.normalize().multiplyScalar(push * this.sideStepWeight); + steering.add(this._side); + } + + if (this._desired.lengthSq() > EPS * EPS) { + const desiredDot = Math.abs(this._toNeighbor.multiplyScalar(invDistance).dot(this._desired)); + if (desiredDot > this.blockerDot) blockers += 1; + } + } + + if (steering.lengthSq() > this.maxSteering * this.maxSteering) { + steering.setLength(this.maxSteering); + } + + return { + steering, + blockers, + blocked: blockers > 0, + }; + } +} diff --git a/playset/modules/behavior/waypoint-driver.ts b/playset/modules/behavior/waypoint-driver.ts new file mode 100644 index 0000000..f321573 --- /dev/null +++ b/playset/modules/behavior/waypoint-driver.ts @@ -0,0 +1,266 @@ +// playset/modules/behavior/waypoint-driver.ts — converts waypoint, vehicle +// pose, speed, and corner profile into AI car controls (throttle, reverse, +// brake, steering, boost) with stuck detection and reverse recovery. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/behavior/WaypointDriver.js. Verbatim semantics. + +import { clamp } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +const EPS = 1e-6; + +interface PlanarDirection { + right: number; + forward: number; +} + +export interface WaypointDriverControls { + throttle: boolean; + reverse: boolean; + left: boolean; + right: boolean; + brake: boolean; + boost: boolean; +} + +export interface WaypointDriverResult extends WaypointDriverControls { + desiredSpeed: number; + yawError: number; + speedError: number; + steerIntent: number; +} + +export interface WaypointDriverOptions { + targetSpeed?: number; + minSpeed?: number; + cornerSlowdown?: number; + steerGain?: number; + steerDeadzone?: number; + brakeYawThreshold?: number; + accelerateSpeedError?: number; + brakeSpeedError?: number; + stuckSpeed?: number; + stuckYawThreshold?: number; + stuckTimeMs?: number; + reverseTimeMs?: number; + basis?: WorldBasis; +} + +export interface WaypointDriverStepInput { + position?: VecLike | null; + yaw?: number; + speed?: number; + waypoint?: VecLike | null; + cornerMagnitude?: number; + steerBias?: number; + raceStarted?: boolean; + deltaSeconds?: number; +} + +function resolveForward(yaw: number, basis: WorldBasis): PlanarDirection { + const forward = basis.yawPitchRollFrame(yaw).forward; + const planar = basis.toPlanar(forward); + const len = Math.hypot(planar.right, planar.forward); + if (len > EPS) { + return { right: planar.right / len, forward: planar.forward / len }; + } + return { right: 0, forward: 0 }; +} + +function directionToTarget( + from: VecLike, + target: VecLike, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): PlanarDirection & { len: number } { + const delta = basis.planarDelta(target, from); + const dRight = delta.right; + const dForward = delta.forward; + const len = Math.hypot(dRight, dForward); + if (len < EPS) return { right: 0, forward: 0, len: 0 }; + return { right: dRight / len, forward: dForward / len, len }; +} + +function signedYawError(forward: PlanarDirection, desired: PlanarDirection): number { + const dot = clamp(forward.right * desired.right + forward.forward * desired.forward, -1, 1); + const rightTurnCross = forward.forward * desired.right - forward.right * desired.forward; + const angle = Math.acos(dot); + return angle * Math.sign(rightTurnCross || 1); +} + +function neutralControls(): WaypointDriverControls { + return { + throttle: false, + reverse: false, + left: false, + right: false, + brake: true, + boost: false, + }; +} + +export class WaypointDriver { + targetSpeed: number; + minSpeed: number; + cornerSlowdown: number; + steerGain: number; + steerDeadzone: number; + brakeYawThreshold: number; + accelerateSpeedError: number; + brakeSpeedError: number; + stuckSpeed: number; + stuckYawThreshold: number; + stuckTimeMs: number; + reverseTimeMs: number; + basis: WorldBasis; + stuckMs: number; + reverseRemainingMs: number; + last: WaypointDriverResult | null; + + constructor({ + targetSpeed = 32, + minSpeed = 4, + cornerSlowdown = 16, + steerGain = 2.4, + steerDeadzone = 0.12, + brakeYawThreshold = 0.88, + accelerateSpeedError = 0.4, + brakeSpeedError = -0.9, + stuckSpeed = 0.35, + stuckYawThreshold = 1.35, + stuckTimeMs = 900, + reverseTimeMs = 420, + basis = DEFAULT_WORLD_BASIS, + }: WaypointDriverOptions) { + this.targetSpeed = targetSpeed; + this.minSpeed = minSpeed; + this.cornerSlowdown = cornerSlowdown; + this.steerGain = steerGain; + this.steerDeadzone = steerDeadzone; + this.brakeYawThreshold = brakeYawThreshold; + this.accelerateSpeedError = accelerateSpeedError; + this.brakeSpeedError = brakeSpeedError; + + this.stuckSpeed = stuckSpeed; + this.stuckYawThreshold = stuckYawThreshold; + this.stuckTimeMs = stuckTimeMs; + this.reverseTimeMs = reverseTimeMs; + this.basis = basis; + + this.stuckMs = 0; + this.reverseRemainingMs = 0; + this.last = null; + } + + reset(): void { + this.stuckMs = 0; + this.reverseRemainingMs = 0; + this.last = null; + } + + step({ + position = null, + yaw = 0, + speed = 0, + waypoint = null, + cornerMagnitude = 0, + steerBias = 0, + raceStarted = true, + deltaSeconds = 1 / 60, + }: WaypointDriverStepInput): WaypointDriverResult { + const dtMs = Math.max(0, deltaSeconds * 1000); + + if (raceStarted === false || !waypoint || !position) { + const controls = neutralControls(); + this.last = { + ...controls, + desiredSpeed: 0, + yawError: 0, + speedError: 0, + steerIntent: 0, + }; + return this.last; + } + + const forward = resolveForward(yaw, this.basis); + const toTarget = directionToTarget(position, waypoint, this.basis); + + if (toTarget.len < EPS) { + const controls = neutralControls(); + this.last = { + ...controls, + desiredSpeed: 0, + yawError: 0, + speedError: 0, + steerIntent: 0, + }; + return this.last; + } + + const yawError = signedYawError(forward, toTarget); + const steerIntent = clamp(yawError * this.steerGain + steerBias, -1, 1); + + const cornerPenalty = cornerMagnitude * this.cornerSlowdown; + const desiredSpeed = clamp(this.targetSpeed - cornerPenalty, this.minSpeed, this.targetSpeed); + + const speedError = desiredSpeed - speed; + + const stuck = speed <= this.stuckSpeed && Math.abs(yawError) >= this.stuckYawThreshold; + if (stuck) { + this.stuckMs += dtMs; + if (this.stuckMs >= this.stuckTimeMs) { + this.reverseRemainingMs = this.reverseTimeMs; + this.stuckMs = 0; + } + } else { + this.stuckMs = Math.max(0, this.stuckMs - dtMs * 2); + } + + if (this.reverseRemainingMs > 0) { + this.reverseRemainingMs = Math.max(0, this.reverseRemainingMs - dtMs); + const controls = { + throttle: false, + reverse: true, + left: steerIntent > this.steerDeadzone, + right: steerIntent < -this.steerDeadzone, + brake: false, + boost: false, + }; + this.last = { + ...controls, + desiredSpeed, + yawError, + speedError, + steerIntent, + }; + return this.last; + } + + const shouldBrakeForTurn = + Math.abs(yawError) >= this.brakeYawThreshold && speed > desiredSpeed * 0.7; + const shouldBrakeForSpeed = speedError <= this.brakeSpeedError; + + const brake = shouldBrakeForTurn || shouldBrakeForSpeed; + + const throttleDrive = speedError >= this.accelerateSpeedError && !brake; + + const controls = { + throttle: throttleDrive, + reverse: false, + left: steerIntent < -this.steerDeadzone, + right: steerIntent > this.steerDeadzone, + brake, + boost: throttleDrive && Math.abs(steerIntent) < 0.15, + }; + + this.last = { + ...controls, + desiredSpeed, + yawError, + speedError, + steerIntent, + }; + + return this.last; + } +} diff --git a/playset/modules/behavior/waypoint-progress-tracker.ts b/playset/modules/behavior/waypoint-progress-tracker.ts new file mode 100644 index 0000000..1698d5b --- /dev/null +++ b/playset/modules/behavior/waypoint-progress-tracker.ts @@ -0,0 +1,220 @@ +// playset/modules/behavior/waypoint-progress-tracker.ts — advances along a +// waypoint route once within reach distance and reports the current waypoint, +// distance, and corner profile. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/behavior/WaypointProgressTracker.js. Verbatim semantics. + +import { clamp } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +const EPS = 1e-6; + +export interface PlainWaypoint { + x?: number; + y?: number; + z?: number; +} + +export interface WaypointProgress { + currentIndex: number; + currentWaypoint: PlainWaypoint; + distanceToCurrent: number; + cornerSign: number; + cornerMagnitude: number; + waypointCount: number; +} + +export interface WaypointProgressTrackerOptions { + waypoints?: readonly VecLike[]; + reachDistance?: number; + closed?: boolean; + basis?: WorldBasis; +} + +function wrapIndex(index: number, len: number): number { + if (len <= 0) return 0; + return ((index % len) + len) % len; +} + +function distanceSqPlanar(a: VecLike, b: VecLike, basis: WorldBasis): number { + return basis.distanceSqPlanar(a, b); +} + +function normalizePlanar(dRight: number, dForward: number): { right: number; forward: number; len: number } { + const len = Math.hypot(dRight, dForward); + if (len < EPS) return { right: 0, forward: 0, len: 0 }; + return { right: dRight / len, forward: dForward / len, len }; +} + +function planarDelta(from: VecLike, to: VecLike, basis: WorldBasis): { right: number; forward: number; len: number } { + const fromPlanar = basis.toPlanar(from); + const toPlanar = basis.toPlanar(to); + const dRight = toPlanar.right - fromPlanar.right; + const dForward = toPlanar.forward - fromPlanar.forward; + return normalizePlanar(dRight, dForward); +} + +function asPlainWaypoint(point: VecLike): PlainWaypoint { + return { + x: point.x, + y: point.y, + z: point.z, + }; +} + +function resolveStepIndex(index: number, step: number, count: number, closed: boolean): number { + if (closed) return wrapIndex(index + step, count); + return clamp(index + step, 0, count - 1); +} + +function cornerProfile( + waypoints: readonly PlainWaypoint[], + index: number, + closed: boolean, + basis: WorldBasis, +): { sign: number; magnitude: number } { + const count = waypoints.length; + if (count < 3) { + return { sign: 0, magnitude: 0 }; + } + + const prevIndex = resolveStepIndex(index, -1, count, closed); + const nextIndex = resolveStepIndex(index, 1, count, closed); + if (!closed && (prevIndex === index || nextIndex === index)) { + return { sign: 0, magnitude: 0 }; + } + + const prev = waypoints[prevIndex]; + const curr = waypoints[index]; + const next = waypoints[nextIndex]; + + const inDir = planarDelta(prev, curr, basis); + const outDir = planarDelta(curr, next, basis); + if (inDir.len < EPS || outDir.len < EPS) { + return { sign: 0, magnitude: 0 }; + } + + const planarCross = inDir.right * outDir.forward - inDir.forward * outDir.right; + const dot = clamp(inDir.right * outDir.right + inDir.forward * outDir.forward, -1, 1); + return { + sign: Math.sign(planarCross || 1), + magnitude: Math.acos(dot), + }; +} + +export class WaypointProgressTracker { + reachDistance: number; + closed: boolean; + basis: WorldBasis; + waypoints: PlainWaypoint[]; + currentIndex: number; + initialized: boolean; + last: WaypointProgress | null; + + constructor({ + waypoints = [], + reachDistance = 4, + closed = true, + basis = DEFAULT_WORLD_BASIS, + }: WaypointProgressTrackerOptions) { + this.reachDistance = reachDistance; + this.closed = closed !== false; + this.basis = basis; + + this.waypoints = []; + this.currentIndex = 0; + this.initialized = false; + this.last = null; + + this.setWaypoints(waypoints); + } + + setWaypoints(waypoints: readonly VecLike[] = []): void { + this.waypoints = Array.isArray(waypoints) + ? waypoints + .filter((p) => this.basis.hasWorldPlanarComponents(p)) + .map((p) => asPlainWaypoint(p)) + : []; + + this.currentIndex = 0; + this.initialized = false; + this.last = null; + } + + reset(startIndex = 0): void { + const count = this.waypoints.length; + this.currentIndex = count > 0 ? wrapIndex(startIndex, count) : 0; + this.initialized = count > 0; + this.last = null; + } + + private _findNearestGlobal(position: VecLike): number { + let bestIndex = 0; + let bestDistSq = Infinity; + + for (let i = 0; i < this.waypoints.length; i += 1) { + const distSq = distanceSqPlanar(position, this.waypoints[i], this.basis); + if (distSq < bestDistSq) { + bestDistSq = distSq; + bestIndex = i; + } + } + + return bestIndex; + } + + private _advance(index: number, step: number): number { + return resolveStepIndex(index, step, this.waypoints.length, this.closed); + } + + step(position: VecLike | null | undefined): WaypointProgress | null { + const count = this.waypoints.length; + if (count === 0 || !position) { + this.last = null; + return null; + } + + let currentIndex = this.initialized ? this.currentIndex : this._findNearestGlobal(position); + this.initialized = true; + + let distanceToCurrent = Math.sqrt( + distanceSqPlanar(position, this.waypoints[currentIndex], this.basis), + ); + if (distanceToCurrent <= this.reachDistance && (this.closed || currentIndex < count - 1)) { + currentIndex = this._advance(currentIndex, 1); + distanceToCurrent = Math.sqrt( + distanceSqPlanar(position, this.waypoints[currentIndex], this.basis), + ); + } + + this.currentIndex = currentIndex; + + const corner = cornerProfile(this.waypoints, currentIndex, this.closed, this.basis); + + this.last = { + currentIndex, + currentWaypoint: this.waypoints[currentIndex], + distanceToCurrent, + cornerSign: corner.sign, + cornerMagnitude: corner.magnitude, + waypointCount: count, + }; + + return { ...this.last }; + } + + snapshot(): { + currentIndex: number; + initialized: boolean; + waypointCount: number; + last: WaypointProgress | null; + } { + return { + currentIndex: this.currentIndex, + initialized: this.initialized, + waypointCount: this.waypoints.length, + last: this.last ? { ...this.last } : null, + }; + } +} diff --git a/playset/modules/camera/base-camera-rig.ts b/playset/modules/camera/base-camera-rig.ts new file mode 100644 index 0000000..24137a9 --- /dev/null +++ b/playset/modules/camera/base-camera-rig.ts @@ -0,0 +1,219 @@ +// playset/modules/camera/base-camera-rig.ts — shared smoothing and +// basis-aware pose plumbing for the concrete camera rigs. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/camera/BaseCameraRig.js. Verbatim semantics, except +// applyToCamera drives a structural CameraLike whose `up`/`lookAt` are +// optional — scene3d's Camera3D has no `up` field — so both it and three-style +// cameras (and test doubles) work unchanged. + +import { Matrix4, Vector3, type Quaternion } from "../../math/index.ts"; +import { smoothingAlpha } from "../math/scalar-utils.ts"; +import { toUnitVec3, toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +const EPS = 1e-12; + +export const CAMERA_ROTATION_MODES = Object.freeze({ + lookAt: "lookAt", + frame: "frame", +} as const); +export type CameraRotationMode = + (typeof CAMERA_ROTATION_MODES)[keyof typeof CAMERA_ROTATION_MODES]; + +export const CAMERA_HEIGHT_SOURCES = Object.freeze({ + frameUp: "frameUp", + basisUp: "basisUp", +} as const); +export type CameraHeightSource = + (typeof CAMERA_HEIGHT_SOURCES)[keyof typeof CAMERA_HEIGHT_SOURCES]; + +/** Anything a rig can drive: scene3d Camera3D, a three camera, or a double. */ +export interface CameraLike { + position: Vector3; + quaternion: Quaternion; + up?: Vector3; + lookAt?(target: Vector3): unknown; +} + +export interface CameraPose { + position: Vector3; + lookAt: Vector3; + forward: Vector3; + right: Vector3; + up: Vector3; +} + +/** Loose target frame — any subset of axes; missing ones fall back to basis. */ +export interface TargetFrameLike { + forward?: VecLike | null; + right?: VecLike | null; + up?: VecLike | null; +} + +export interface ResolvedTargetFrame { + forward: Vector3; + right: Vector3; + up: Vector3; + back: Vector3; +} + +export interface BaseCameraRigOptions { + rotationMode?: CameraRotationMode; + basis?: WorldBasis; +} + +export interface CameraRigState { + position?: VecLike | null; + lookAt?: VecLike | null; + forward?: VecLike | null; + right?: VecLike | null; + up?: VecLike | null; + rotationMode?: CameraRotationMode; +} + +export class BaseCameraRig { + basis: WorldBasis; + rotationMode: CameraRotationMode; + position: Vector3; + lookAt: Vector3; + forward: Vector3; + right: Vector3; + up: Vector3; + initialized: boolean; + + constructor({ + rotationMode = CAMERA_ROTATION_MODES.lookAt, + basis = DEFAULT_WORLD_BASIS, + }: BaseCameraRigOptions) { + this.basis = basis; + this.rotationMode = rotationMode; + this.position = new Vector3(); + this.lookAt = this.basis.forwardVector(); + this.forward = this.basis.forwardVector(); + this.right = this.basis.rightVector(); + this.up = this.basis.upVector(); + this.initialized = false; + } + + setState({ + position = null, + lookAt = null, + forward = null, + right = null, + up = null, + rotationMode = this.rotationMode, + }: CameraRigState): this { + if (position) this.position.copy(toVec3(position, this.position)); + if (lookAt) this.lookAt.copy(toVec3(lookAt, this.lookAt)); + if (forward) this.forward.copy(toUnitVec3(forward, this.forward)); + if (right) this.right.copy(toUnitVec3(right, this.right)); + if (up) this.up.copy(toUnitVec3(up, this.up)); + this.rotationMode = rotationMode; + this.initialized = true; + return this; + } + + getPose(): CameraPose { + return { + position: this.position.clone(), + lookAt: this.lookAt.clone(), + forward: this.forward.clone(), + right: this.right.clone(), + up: this.up.clone(), + }; + } + + applyToCamera(camera: CameraLike | null | undefined, pose: CameraPose = this.getPose()): void { + if (!camera) return; + + camera.position.copy(pose.position); + camera.up?.copy(pose.up); + + if (this.rotationMode === CAMERA_ROTATION_MODES.frame) { + const matrix = new Matrix4().makeBasis( + pose.right, + pose.up, + pose.forward.clone().negate(), + ); + camera.quaternion.setFromRotationMatrix(matrix); + } else { + camera.lookAt?.(pose.lookAt); + } + } + + resolveTargetFrame(targetFrame: TargetFrameLike): ResolvedTargetFrame { + const forward = toUnitVec3(targetFrame.forward, this.basis.forwardVector()); + const up = toUnitVec3(targetFrame.up, this.basis.upVector()); + const fallbackRight = new Vector3().crossVectors(forward, up); + if (fallbackRight.lengthSq() <= EPS) fallbackRight.copy(this.basis.rightVector()); + const right = toUnitVec3(targetFrame.right, fallbackRight); + return { forward, right, up, back: forward.clone().negate() }; + } + + vectorFromSource(source: CameraHeightSource, frame: ResolvedTargetFrame): Vector3 { + return source === CAMERA_HEIGHT_SOURCES.basisUp ? this.basis.upVector() : frame.up.clone(); + } + + smoothVector( + current: Vector3, + target: Vector3, + lag: number, + deltaSeconds: number, + snapToTarget = false, + ): Vector3 { + if (snapToTarget || !this.initialized || lag <= 0) { + current.copy(target); + return current; + } + return current.lerp(target, smoothingAlpha(lag, deltaSeconds)); + } + + setLookAtPose({ + position, + lookAt, + up, + }: { + position: Vector3; + lookAt: Vector3; + up?: VecLike | null; + }): void { + this.position.copy(position); + this.lookAt.copy(lookAt); + this.up.copy(toUnitVec3(up, this.basis.upVector())); + this.forward.subVectors(this.lookAt, this.position); + if (this.forward.lengthSq() <= EPS) this.forward.copy(this.basis.forwardVector()); + else this.forward.normalize(); + this.right.crossVectors(this.forward, this.up); + if (this.right.lengthSq() <= EPS) this.right.copy(this.basis.rightVector()); + else this.right.normalize(); + this.up.crossVectors(this.right, this.forward).normalize(); + this.initialized = true; + } + + setFramePose({ + position, + forward, + right = null, + up, + }: { + position: Vector3; + forward: VecLike | null; + right?: VecLike | null; + up?: VecLike | null; + }): void { + this.position.copy(position); + this.forward.copy(toUnitVec3(forward, this.basis.forwardVector())); + this.up.copy(toUnitVec3(up, this.basis.upVector())); + this.right.copy( + right + ? toUnitVec3(right, this.basis.rightVector()) + : new Vector3().crossVectors(this.forward, this.up), + ); + if (this.right.lengthSq() <= EPS) this.right.copy(this.basis.rightVector()); + else this.right.normalize(); + this.up.crossVectors(this.right, this.forward).normalize(); + this.lookAt.copy(this.position.clone().add(this.forward)); + this.initialized = true; + } +} diff --git a/playset/modules/camera/first-person-camera-rig.ts b/playset/modules/camera/first-person-camera-rig.ts new file mode 100644 index 0000000..eb429b8 --- /dev/null +++ b/playset/modules/camera/first-person-camera-rig.ts @@ -0,0 +1,64 @@ +// playset/modules/camera/first-person-camera-rig.ts — locks the camera to a +// target's eye position and forward direction for first-person view. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/camera/FirstPersonCameraRig.js. Verbatim semantics. + +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import { + BaseCameraRig, + CAMERA_HEIGHT_SOURCES, + CAMERA_ROTATION_MODES, + type CameraHeightSource, + type CameraLike, + type CameraPose, + type TargetFrameLike, +} from "./base-camera-rig.ts"; + +export interface FirstPersonCameraRigOptions { + eyeHeight?: number; + lookDistance?: number; + heightVectorSource?: CameraHeightSource; + basis?: WorldBasis; +} + +export interface FirstPersonCameraStepInput { + targetPosition: VecLike; + targetFrame: TargetFrameLike; + camera?: CameraLike | null; +} + +export class FirstPersonCameraRig extends BaseCameraRig { + eyeHeight: number; + lookDistance: number; + heightVectorSource: CameraHeightSource; + + constructor({ + eyeHeight = 1.72, + lookDistance = 1, + heightVectorSource = CAMERA_HEIGHT_SOURCES.frameUp, + basis = DEFAULT_WORLD_BASIS, + }: FirstPersonCameraRigOptions) { + super({ basis, rotationMode: CAMERA_ROTATION_MODES.lookAt }); + this.eyeHeight = eyeHeight; + this.lookDistance = lookDistance; + this.heightVectorSource = heightVectorSource; + } + + step({ targetPosition, targetFrame, camera = null }: FirstPersonCameraStepInput): CameraPose { + const frame = this.resolveTargetFrame(targetFrame); + const heightVector = this.vectorFromSource(this.heightVectorSource, frame); + const position = toVec3(targetPosition).addScaledVector(heightVector, this.eyeHeight); + + this.setLookAtPose({ + position, + lookAt: position.clone().addScaledVector(frame.forward, this.lookDistance), + up: frame.up, + }); + + const pose = this.getPose(); + this.applyToCamera(camera, pose); + return pose; + } +} diff --git a/playset/modules/camera/look-offset-camera-rig.ts b/playset/modules/camera/look-offset-camera-rig.ts new file mode 100644 index 0000000..9d8c3e5 --- /dev/null +++ b/playset/modules/camera/look-offset-camera-rig.ts @@ -0,0 +1,112 @@ +// playset/modules/camera/look-offset-camera-rig.ts — temporary free-look +// rotation around a target that recenters when look input stops. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/camera/LookOffsetCameraRig.js. Verbatim semantics (an +// unused three Quaternion import in the original was dropped). + +import { clamp, smoothingAlpha } from "../math/scalar-utils.ts"; +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import { BaseCameraRig, type CameraLike, type CameraPose } from "./base-camera-rig.ts"; + +export interface LookOffsetCameraRigOptions { + distance?: number; + lookSensitivity?: number; + returnLag?: number; + pitchMin?: number; + pitchMax?: number; + basis?: WorldBasis; +} + +export interface LookOffsetCameraStepInput { + targetPosition: VecLike; + targetYaw?: number; + targetPitch?: number; + targetRoll?: number; + lookActive?: boolean; + lookDeltaX?: number; + lookDeltaY?: number; + deltaSeconds?: number; + camera?: CameraLike | null; +} + +export class LookOffsetCameraRig extends BaseCameraRig { + distance: number; + lookSensitivity: number; + returnLag: number; + cameraYaw: number; + cameraPitch: number; + pitchMin: number; + pitchMax: number; + + constructor({ + distance = 20, + lookSensitivity = 0.0035, // 0.2 deg per input unit + returnLag = 0.17, + pitchMin = -1.4835, // -85 deg + pitchMax = 1.4835, // 85 deg + basis = DEFAULT_WORLD_BASIS, + }: LookOffsetCameraRigOptions) { + super({ basis }); + this.distance = distance; + this.lookSensitivity = lookSensitivity; + this.returnLag = returnLag; + this.cameraYaw = 0; + this.cameraPitch = 0; + this.pitchMin = pitchMin; + this.pitchMax = pitchMax; + } + + setSensitivity(value: number): this { + this.lookSensitivity = value; + return this; + } + + setLook(cameraYaw = 0, cameraPitch = 0): this { + this.cameraYaw = cameraYaw; + this.cameraPitch = clamp(cameraPitch, this.pitchMin, this.pitchMax); + return this; + } + + step({ + targetPosition, + targetYaw = 0, + targetPitch = 0, + targetRoll = 0, + lookActive = false, + lookDeltaX = 0, + lookDeltaY = 0, + deltaSeconds = 1 / 60, + camera = null, + }: LookOffsetCameraStepInput): CameraPose { + if (lookActive) { + this.cameraYaw += lookDeltaX * this.lookSensitivity; + this.cameraPitch += lookDeltaY * this.lookSensitivity; + this.cameraPitch = clamp(this.cameraPitch, this.pitchMin, this.pitchMax); + } else { + const blend = smoothingAlpha(this.returnLag, deltaSeconds); + this.cameraYaw += (0 - this.cameraYaw) * blend; + this.cameraPitch += (0 - this.cameraPitch) * blend; + } + + const lookAtPosition = toVec3(targetPosition); + + const frame = this.basis.yawPitchRollFrame( + targetYaw + this.cameraYaw, + targetPitch + this.cameraPitch, + targetRoll, + ); + const cameraPosition = frame.back.clone().multiplyScalar(this.distance).add(lookAtPosition); + + this.setLookAtPose({ + position: cameraPosition, + lookAt: lookAtPosition, + up: frame.up, + }); + + const pose = this.getPose(); + this.applyToCamera(camera, pose); + return pose; + } +} diff --git a/playset/modules/camera/pose-follow-camera-rig.ts b/playset/modules/camera/pose-follow-camera-rig.ts new file mode 100644 index 0000000..1fa5e57 --- /dev/null +++ b/playset/modules/camera/pose-follow-camera-rig.ts @@ -0,0 +1,148 @@ +// playset/modules/camera/pose-follow-camera-rig.ts — follows a target +// position and frame with pose-relative camera/look offsets so the view moves +// and turns with the target (third-person chase camera). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/camera/PoseFollowCameraRig.js. Verbatim semantics. + +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import { + BaseCameraRig, + CAMERA_HEIGHT_SOURCES, + CAMERA_ROTATION_MODES, + type CameraHeightSource, + type CameraLike, + type CameraPose, + type CameraRotationMode, + type TargetFrameLike, +} from "./base-camera-rig.ts"; + +export interface PoseOffset { + forward: number; + up: number; + right: number; +} + +export interface PoseFollowCameraRigOptions { + cameraOffset: PoseOffset; + lookAtOffset?: PoseOffset; + speedCameraOffset?: PoseOffset; + speedLookAtOffset?: PoseOffset; + heightVectorSource?: CameraHeightSource; + lookHeightVectorSource?: CameraHeightSource; + positionLag?: number; + lookLag?: number; + frameLag?: number; + rotationMode?: CameraRotationMode; + basis?: WorldBasis; +} + +export interface PoseFollowCameraStepInput { + targetPosition: VecLike; + targetFrame: TargetFrameLike; + targetSpeed?: number | null; + snapToTarget?: boolean; + deltaSeconds?: number; + camera?: CameraLike | null; +} + +export class PoseFollowCameraRig extends BaseCameraRig { + cameraOffset: PoseOffset; + lookAtOffset: PoseOffset; + speedCameraOffset: PoseOffset; + speedLookAtOffset: PoseOffset; + heightVectorSource: CameraHeightSource; + lookHeightVectorSource: CameraHeightSource; + positionLag: number; + lookLag: number; + frameLag: number; + + constructor({ + cameraOffset, + lookAtOffset = { forward: 1, up: 0, right: 0 }, + speedCameraOffset = { forward: 0, up: 0, right: 0 }, + speedLookAtOffset = { forward: 0, up: 0, right: 0 }, + heightVectorSource = CAMERA_HEIGHT_SOURCES.frameUp, + lookHeightVectorSource = CAMERA_HEIGHT_SOURCES.frameUp, + positionLag = 0, + lookLag = 0, + frameLag = 0, + rotationMode = CAMERA_ROTATION_MODES.lookAt, + basis = DEFAULT_WORLD_BASIS, + }: PoseFollowCameraRigOptions) { + super({ basis, rotationMode }); + this.cameraOffset = cameraOffset; + this.lookAtOffset = lookAtOffset; + this.speedCameraOffset = speedCameraOffset; + this.speedLookAtOffset = speedLookAtOffset; + this.heightVectorSource = heightVectorSource; + this.lookHeightVectorSource = lookHeightVectorSource; + this.positionLag = positionLag; + this.lookLag = lookLag; + this.frameLag = frameLag; + } + + step({ + targetPosition, + targetFrame, + targetSpeed = 0, + snapToTarget = false, + deltaSeconds = 1 / 60, + camera = null, + }: PoseFollowCameraStepInput): CameraPose { + const frame = this.resolveTargetFrame(targetFrame); + const focusPosition = toVec3(targetPosition); + const speed = Math.max(0, targetSpeed ?? 0); + const heightVector = this.vectorFromSource(this.heightVectorSource, frame); + const lookHeightVector = this.vectorFromSource(this.lookHeightVectorSource, frame); + + const cameraOffset = this.offsetForSpeed(this.cameraOffset, this.speedCameraOffset, speed); + const lookAtOffset = this.offsetForSpeed(this.lookAtOffset, this.speedLookAtOffset, speed); + + const desiredPosition = focusPosition + .clone() + .addScaledVector(frame.forward, cameraOffset.forward) + .addScaledVector(frame.right, cameraOffset.right) + .addScaledVector(heightVector, cameraOffset.up); + const desiredLookAt = focusPosition + .clone() + .addScaledVector(frame.forward, lookAtOffset.forward) + .addScaledVector(frame.right, lookAtOffset.right) + .addScaledVector(lookHeightVector, lookAtOffset.up); + + this.smoothVector(this.position, desiredPosition, this.positionLag, deltaSeconds, snapToTarget); + this.smoothVector(this.lookAt, desiredLookAt, this.lookLag, deltaSeconds, snapToTarget); + + const desiredForward = frame.forward.clone(); + const desiredUp = frame.up.clone(); + this.smoothVector(this.forward, desiredForward, this.frameLag, deltaSeconds, snapToTarget).normalize(); + this.smoothVector(this.up, desiredUp, this.frameLag, deltaSeconds, snapToTarget).normalize(); + + if (this.rotationMode === CAMERA_ROTATION_MODES.frame) { + this.setFramePose({ + position: this.position, + forward: this.forward, + up: this.up, + }); + } else if (this.rotationMode === CAMERA_ROTATION_MODES.lookAt) { + this.setLookAtPose({ + position: this.position, + lookAt: this.lookAt, + up: frame.up, + }); + } + + const pose = this.getPose(); + this.applyToCamera(camera, pose); + return pose; + } + + offsetForSpeed(offset: PoseOffset, speedOffset: PoseOffset, speed: number): PoseOffset { + return { + forward: offset.forward + speedOffset.forward * speed, + up: offset.up + speedOffset.up * speed, + right: offset.right + speedOffset.right * speed, + }; + } +} diff --git a/playset/modules/camera/position-follow-camera-rig.ts b/playset/modules/camera/position-follow-camera-rig.ts new file mode 100644 index 0000000..b90396e --- /dev/null +++ b/playset/modules/camera/position-follow-camera-rig.ts @@ -0,0 +1,90 @@ +// playset/modules/camera/position-follow-camera-rig.ts — follows a target +// position with a fixed world-basis offset and viewing angle, always looking +// at the target. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/camera/PositionFollowCameraRig.js. Verbatim semantics. + +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import { + BaseCameraRig, + CAMERA_ROTATION_MODES, + type CameraLike, + type CameraPose, +} from "./base-camera-rig.ts"; + +export interface PositionFollowCameraRigOptions { + azimuth?: number; + distance?: number; + height?: number; + lookHeight?: number; + positionLag?: number; + lookLag?: number; + basis?: WorldBasis; +} + +export interface PositionFollowCameraStepInput { + targetPosition: VecLike; + snapToTarget?: boolean; + deltaSeconds?: number; + camera?: CameraLike | null; +} + +export class PositionFollowCameraRig extends BaseCameraRig { + azimuth: number; + distance: number; + height: number; + lookHeight: number; + positionLag: number; + lookLag: number; + + constructor({ + azimuth = 0, + distance = 18, + height = 16, + lookHeight = 0, + positionLag = 0.0, + lookLag = 0.0, + basis = DEFAULT_WORLD_BASIS, + }: PositionFollowCameraRigOptions) { + super({ basis, rotationMode: CAMERA_ROTATION_MODES.lookAt }); + this.azimuth = azimuth; + this.distance = distance; + this.height = height; + this.lookHeight = lookHeight; + this.positionLag = positionLag; + this.lookLag = lookLag; + } + + step({ + targetPosition, + snapToTarget = false, + deltaSeconds = 1 / 60, + camera = null, + }: PositionFollowCameraStepInput): CameraPose { + const focus = toVec3(targetPosition); + + const viewDirection = this.basis + .fromBasisComponents(Math.sin(this.azimuth), 0, Math.cos(this.azimuth)) + .normalize(); + const cameraPosition = focus.clone().addScaledVector(viewDirection, -this.distance); + const cameraLookAt = focus.clone(); + const baseHeight = this.basis.upComponent(focus); + + this.basis.setHeight(cameraPosition, baseHeight + this.height); + this.basis.setHeight(cameraLookAt, baseHeight + this.lookHeight); + this.smoothVector(this.position, cameraPosition, this.positionLag, deltaSeconds, snapToTarget); + this.smoothVector(this.lookAt, cameraLookAt, this.lookLag, deltaSeconds, snapToTarget); + + this.setLookAtPose({ + position: this.position, + lookAt: this.lookAt, + up: this.basis.upVector(), + }); + + const pose = this.getPose(); + this.applyToCamera(camera, pose); + return pose; + } +} diff --git a/playset/modules/gameplay/aim-resolver.ts b/playset/modules/gameplay/aim-resolver.ts new file mode 100644 index 0000000..e297fd8 --- /dev/null +++ b/playset/modules/gameplay/aim-resolver.ts @@ -0,0 +1,242 @@ +// playset/modules/gameplay/aim-resolver.ts — crosshair-to-world aim +// resolution: turns a camera ray (or an explicit aim ray) into hit point, +// launch position, and shooting direction. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/AimResolver.js. +// +// DELIBERATE API DEVIATION — playset has no scene raycasting (the +// presentation surface is write-only), so picking runs against gameplay +// colliders instead of THREE.Raycaster over Object3D lists: +// - `camera` is anything with `position` + `rayFromNdc(ndcX, ndcY)` +// (scene3d's Camera3D fits structurally; this module does not import +// scene3d). `rayFromNdc` replaces Raycaster.setFromCamera. +// - `objects: Object3D[]` + `recursive` are replaced by two pick sources: +// `world?: CollisionWorld` (collider raycast) and `targets?: Array<{ +// position, radius, tag? }>` (analytic ray-vs-sphere, front surface, +// matching CollisionWorld's sphere test). The NEAREST hit across BOTH +// sources wins — same nearest-intersection pick as the original, with +// the original's near=0 / far=maxDistance+|aimOrigin→launch| window. +// - The result keeps the original shape and field names (aimOrigin, +// aimDirection, launchPosition, hitPosition, shootingDirection, hasHit, +// maxDistance, aimRayDistance, launchDistanceToHit), with `hit` now +// `{ distance, point, tag } | null` and `targetObject` renamed `target`: +// the matched targets[] entry, or the hit collider's tag — the original's +// parent-walk over Object3D ancestors has no collider equivalent. +// The virtual-point fallback (no hit → point at aimRayDistance along the aim +// ray) and the launch≈hit epsilon fallback (shootingDirection = aimDirection +// when |launch→hit| ≤ 1e-6) are preserved verbatim. + +import { Vector3 } from "../../math/index.ts"; +import { toVec3 } from "../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import type { CollisionWorld } from "../physics/collision-world.ts"; + +const EPS = 1e-6; +const CENTER_NDC: Readonly<{ x: number; y: number }> = Object.freeze({ x: 0, y: 0 }); + +/** Structural camera contract (scene3d Camera3D satisfies it). */ +export interface AimCamera { + position: VecLike; + rayFromNdc(ndcX: number, ndcY: number, target?: Vector3): Vector3; +} + +export interface AimTarget { + position: VecLike; + radius: number; + tag?: unknown; +} + +export interface AimHit { + distance: number; + point: Vector3; + tag: unknown; +} + +export interface AimResult { + aimOrigin: Vector3; + aimDirection: Vector3; + launchPosition: Vector3; + hitPosition: Vector3; + shootingDirection: Vector3; + hasHit: boolean; + hit: AimHit | null; + /** The matched targets[] entry, or the hit collider's tag (null if none). */ + target: unknown; + maxDistance: number; + aimRayDistance: number; + launchDistanceToHit: number; +} + +export interface AimResolverOptions { + maxDistance?: number; + basis?: WorldBasis; +} + +export interface AimFromCameraOptions { + camera: AimCamera; + crosshairNdc?: { x: number; y: number }; + launchPosition: VecLike; + world?: CollisionWorld | null; + targets?: AimTarget[]; + maxDistance?: number; +} + +export interface AimFromAimRayOptions { + aimOrigin: VecLike; + aimDirection: VecLike; + launchPosition: VecLike; + world?: CollisionWorld | null; + targets?: AimTarget[]; + maxDistance?: number; +} + +/** Front-surface ray-vs-sphere (same convention as CollisionWorld). */ +function raySphereDistance(origin: Vector3, direction: Vector3, target: AimTarget, far: number): number | null { + const center = toVec3(target.position); + const oc = origin.clone().sub(center); + const b = oc.dot(direction); + const c = oc.lengthSq() - target.radius * target.radius; + const disc = b * b - c; + if (disc < 0) return null; + const t = -b - Math.sqrt(disc); + return t >= 0 && t <= far ? t : null; +} + +export class AimResolver { + basis: WorldBasis; + maxDistance: number; + + constructor({ maxDistance = 1000, basis = DEFAULT_WORLD_BASIS }: AimResolverOptions) { + this.basis = basis; + this.maxDistance = maxDistance; + } + + getAimDirection(camera: AimCamera, crosshairNdc: { x: number; y: number } = CENTER_NDC): Vector3 { + return camera.rayFromNdc(crosshairNdc.x, crosshairNdc.y).normalize(); + } + + getAimFromCamera({ + camera, + crosshairNdc = CENTER_NDC, + launchPosition, + world = null, + targets = [], + maxDistance = this.maxDistance, + }: AimFromCameraOptions): AimResult { + return this._resolveAim({ + aimOrigin: toVec3(camera.position), + aimDirection: camera.rayFromNdc(crosshairNdc.x, crosshairNdc.y).normalize(), + launchPosition, + world, + targets, + maxDistance, + }); + } + + getAimFromAimRay({ + aimOrigin, + aimDirection, + launchPosition, + world = null, + targets = [], + maxDistance = this.maxDistance, + }: AimFromAimRayOptions): AimResult { + return this._resolveAim({ + aimOrigin: toVec3(aimOrigin), + aimDirection: this._normalizeAimDirection(aimDirection), + launchPosition, + world, + targets, + maxDistance, + }); + } + + private _resolveAim({ + aimOrigin, + aimDirection, + launchPosition, + world, + targets, + maxDistance, + }: { + aimOrigin: Vector3; + aimDirection: Vector3; + launchPosition: VecLike; + world: CollisionWorld | null; + targets: AimTarget[]; + maxDistance: number; + }): AimResult { + const launch = toVec3(launchPosition); + const aimRayDistance = maxDistance + aimOrigin.distanceTo(launch); + const intersect = this._intersectAimRay(aimOrigin, aimDirection, world, targets, aimRayDistance); + const hit = intersect ? intersect.hit : null; + + const hitPosition = hit + ? hit.point.clone() + : aimOrigin.clone().addScaledVector(aimDirection, aimRayDistance); + + const launchToHit = hitPosition.clone().sub(launch); + const launchDistanceToHit = launchToHit.length(); + const shootingDirection = + launchDistanceToHit > EPS ? launchToHit.multiplyScalar(1 / launchDistanceToHit) : aimDirection.clone(); + + return { + aimOrigin: aimOrigin, + aimDirection: aimDirection, + launchPosition: launch, + hitPosition, + shootingDirection, + hasHit: Boolean(hit), + hit, + target: intersect ? intersect.target : null, + maxDistance, + aimRayDistance, + launchDistanceToHit, + }; + } + + private _normalizeAimDirection(aimDirection: VecLike): Vector3 { + const direction = toVec3(aimDirection); + if (direction.lengthSq() <= EPS * EPS) { + throw new TypeError("AimResolver: aimDirection must be non-zero"); + } + return direction.normalize(); + } + + private _intersectAimRay( + origin: Vector3, + direction: Vector3, + world: CollisionWorld | null, + targets: AimTarget[], + far: number, + ): { hit: AimHit; target: unknown } | null { + let best: { hit: AimHit; target: unknown } | null = null; + + if (world) { + const worldHit = world.raycast(origin, direction, far); + if (worldHit) { + best = { + hit: { distance: worldHit.distance, point: worldHit.point, tag: worldHit.tag }, + target: worldHit.tag, + }; + } + } + + for (const target of targets) { + const t = raySphereDistance(origin, direction, target, far); + if (t === null) continue; + if (best !== null && t >= best.hit.distance) continue; + best = { + hit: { + distance: t, + point: origin.clone().addScaledVector(direction, t), + tag: target.tag ?? null, + }, + target, + }; + } + + return best; + } +} diff --git a/playset/modules/gameplay/combat-play.ts b/playset/modules/gameplay/combat-play.ts new file mode 100644 index 0000000..d16a64a --- /dev/null +++ b/playset/modules/gameplay/combat-play.ts @@ -0,0 +1,288 @@ +// playset/modules/gameplay/combat-play.ts — team-combat state machine: +// health/armor bookkeeping, kill events, winner resolution. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/CombatPlay.js. Verbatim semantics. + +export const COMBAT_STATES = Object.freeze({ + WAITING: "WAITING", + STARTED: "STARTED", + FINISHED: "FINISHED", +} as const); + +export type CombatState = (typeof COMBAT_STATES)[keyof typeof COMBAT_STATES]; + +export const COMBAT_PLAY_EVENTS = Object.freeze({ + COMBAT_FINISHED: "combat.finished", + PLAYER_KILLED: "combat.player.killed", +} as const); + +export interface CombatPlayerState { + playerId: string; + teamId: string; + maxHealth: number; + health: number; + maxArmor: number; + armor: number; + alive: boolean; +} + +export type CombatPlayEvent = + | { + type: typeof COMBAT_PLAY_EVENTS.PLAYER_KILLED; + playerId: string; + sourceId: string | null; + } + | { + type: typeof COMBAT_PLAY_EVENTS.COMBAT_FINISHED; + winnerTeamId: string | null; + }; + +export interface CombatPlayOptions { + maxHealth?: number; + maxArmor?: number; + armorAbsorption?: number; +} + +export interface AddCombatPlayerOptions { + playerId: string; + teamId: string; + health?: number; + armor?: number; +} + +export interface UpdateCombatPlayerOptions { + playerId: string; + health?: number; + armor?: number; +} + +export interface CombatDamageOptions { + playerId: string; + amount: number; + sourceId?: string | null; + bypassArmor?: boolean; +} + +function clonePlayer(player: CombatPlayerState): CombatPlayerState { + return { + playerId: player.playerId, + teamId: player.teamId, + maxHealth: player.maxHealth, + health: player.health, + maxArmor: player.maxArmor, + armor: player.armor, + alive: player.alive, + }; +} + +function createPlayer({ + playerId, + teamId, + maxHealth, + health, + maxArmor, + armor, +}: { + playerId: string; + teamId: string; + maxHealth: number; + health: number; + maxArmor: number; + armor: number; +}): CombatPlayerState { + return { + playerId, + teamId, + maxHealth, + health, + maxArmor, + armor, + alive: health > 0, + }; +} + +export class CombatPlay { + maxHealth: number; + maxArmor: number; + armorAbsorption: number; + players: Map; + combatState: CombatState; + winnerTeamId: string | null; + private _events: CombatPlayEvent[]; + + constructor({ maxHealth = 100, maxArmor = 100, armorAbsorption = 0.6 }: CombatPlayOptions) { + this.maxHealth = maxHealth; + this.maxArmor = maxArmor; + this.armorAbsorption = armorAbsorption; + + this.players = new Map(); + this.combatState = COMBAT_STATES.WAITING; + this.winnerTeamId = null; + this._events = []; + } + + addPlayer({ playerId, teamId, health = this.maxHealth, armor = 0 }: AddCombatPlayerOptions): void { + if (this.combatState !== COMBAT_STATES.WAITING) { + throw new Error("players can only be added while combat is waiting"); + } + if (this.players.has(playerId)) { + throw new Error(`player already exists: ${playerId}`); + } + + this.players.set( + playerId, + createPlayer({ + playerId, + teamId, + maxHealth: this.maxHealth, + health: health, + maxArmor: this.maxArmor, + armor: armor, + }), + ); + } + + removePlayer(playerId: string): void { + if (!this.players.delete(playerId)) { + throw new Error(`unknown player: ${playerId}`); + } + } + + updatePlayer({ playerId, health, armor }: UpdateCombatPlayerOptions): void { + const player = this._getPlayer(playerId); + if (health !== undefined) { + player.health = Math.min(health, player.maxHealth); + player.alive = player.health > 0; + } + if (armor !== undefined) { + player.armor = Math.min(armor, player.maxArmor); + } + } + + startGame(): void { + if (this.combatState !== COMBAT_STATES.WAITING) { + throw new Error("combat can only be started from WAITING"); + } + if (this.players.size === 0) { + throw new Error("combat requires at least one player"); + } + + this.winnerTeamId = null; + this.combatState = COMBAT_STATES.STARTED; + } + + reset(): void { + this._resetPlayers(); + this._clearEvents(); + this.combatState = COMBAT_STATES.WAITING; + } + + getPlayer(playerId: string): CombatPlayerState { + return clonePlayer(this._getPlayer(playerId)); + } + + getCombatState(): CombatState { + return this.combatState; + } + + getAliveTeamIds(): string[] { + return Array.from( + new Set( + Array.from(this.players.values()) + .filter((player) => player.alive) + .map((player) => player.teamId), + ), + ); + } + + private _getPlayer(playerId: string): CombatPlayerState { + const player = this.players.get(playerId); + if (!player) throw new Error(`unknown player: ${playerId}`); + return player; + } + + private _resetPlayers(): void { + this.winnerTeamId = null; + for (const player of this.players.values()) { + player.health = this.maxHealth; + player.armor = 0; + player.alive = player.health > 0; + } + } + + private _queueEvent(event: CombatPlayEvent): void { + this._events.push(event); + } + + private _clearEvents(): void { + this._events = []; + } + + private _drainEvents(): CombatPlayEvent[] { + const events = this._events; + this._events = []; + return events; + } + + damage({ playerId, amount, sourceId = null, bypassArmor = false }: CombatDamageOptions): void { + if (this.combatState !== COMBAT_STATES.STARTED) return; + + const player = this._getPlayer(playerId); + if (!player.alive) { + return; + } + + const armorDamage = bypassArmor ? 0 : Math.min(player.armor, amount * this.armorAbsorption); + const healthDamage = Math.min(player.health, amount - armorDamage); + + player.armor -= armorDamage; + player.health -= healthDamage; + player.alive = player.health > 0; + + if (!player.alive) { + this._queueEvent({ + type: COMBAT_PLAY_EVENTS.PLAYER_KILLED, + playerId, + sourceId, + }); + } + } + + heal({ playerId, amount }: { playerId: string; amount: number }): void { + if (this.combatState !== COMBAT_STATES.STARTED) return; + + const player = this._getPlayer(playerId); + if (!player.alive) { + return; + } + + player.health = Math.min(player.maxHealth, player.health + amount); + } + + addArmor({ playerId, amount }: { playerId: string; amount: number }): void { + if (this.combatState !== COMBAT_STATES.STARTED) return; + + const player = this._getPlayer(playerId); + player.armor = Math.min(player.maxArmor, player.armor + amount); + } + + step(): CombatPlayEvent[] { + this._finishCombatIfResolved(); + return this._drainEvents(); + } + + private _finishCombatIfResolved(): void { + if (this.combatState !== COMBAT_STATES.STARTED) return; + + const aliveTeamIds = this.getAliveTeamIds(); + if (aliveTeamIds.length > 1) return; + + this.combatState = COMBAT_STATES.FINISHED; + this.winnerTeamId = aliveTeamIds[0] ?? null; + this._queueEvent({ + type: COMBAT_PLAY_EVENTS.COMBAT_FINISHED, + winnerTeamId: this.winnerTeamId, + }); + } +} diff --git a/playset/modules/gameplay/combat/projectile-manager.ts b/playset/modules/gameplay/combat/projectile-manager.ts new file mode 100644 index 0000000..1ff6e0d --- /dev/null +++ b/playset/modules/gameplay/combat/projectile-manager.ts @@ -0,0 +1,162 @@ +// playset/modules/gameplay/combat/projectile-manager.ts — owns the live +// projectile list: spawn bookkeeping, per-step advancement, hit-event +// collection, removal and disposal of spent projectiles. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/combat/ProjectileManager.js. One deliberate +// deviation: the original constructed `new ProjectileObject(...)` from +// world/object/ProjectileObject.js; playset gameplay modules stay decoupled +// from world/object, so the constructor takes a required `createProjectile` +// factory and entries are typed structurally (active flag, step, optional +// dispose). Spawn options and defaults, the reverse-iteration step loop, +// hit-event shape, and clear/dispose semantics are otherwise verbatim. + +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../../math/world-basis.ts"; + +export interface ProjectileTargetLike { + position: VecLike; + destroyed?: boolean; +} + +export interface ProjectileStepResult { + position: VecLike; + target: ProjectileTargetLike | null; + hittedTarget: ProjectileTargetLike | null; +} + +/** What the manager needs from a projectile (ProjectileObject fits). */ +export interface ProjectileLike { + active: boolean; + step(targets: ProjectileTargetLike[], deltaSeconds: number): ProjectileStepResult; + dispose?(): void; +} + +export interface ProjectileVisualLike { + group: unknown; +} + +/** Resolved spawn options handed to the injected factory. */ +export interface ProjectileSpawnConfig { + visual: ProjectileVisualLike; + position: VecLike; + direction: VecLike; + speed: number; + target: ProjectileTargetLike | null; + lifetimeSeconds: number; + hitRadius: number; + turnResponse: number; + basis: WorldBasis; +} + +export type ProjectileFactory = (config: ProjectileSpawnConfig) => ProjectileLike; + +export interface SpawnProjectileOptions { + visual: ProjectileVisualLike; + metadata?: unknown; + position: VecLike; + direction: VecLike; + speed: number; + target?: ProjectileTargetLike | null; + lifetimeSeconds: number; + hitRadius: number; + turnResponse?: number; + basis?: WorldBasis; +} + +export interface ProjectileHitEvent { + projectile: ProjectileLike; + position: VecLike; + target: ProjectileTargetLike | null; + hittedTarget: ProjectileTargetLike; + metadata: unknown; +} + +export interface ProjectileManagerOptions { + basis?: WorldBasis; + createProjectile: ProjectileFactory; +} + +export class ProjectileManager { + basis: WorldBasis; + createProjectile: ProjectileFactory; + projectiles: ProjectileLike[]; + projectileMetadata: Map; + + constructor({ basis = DEFAULT_WORLD_BASIS, createProjectile }: ProjectileManagerOptions) { + this.basis = basis; + this.createProjectile = createProjectile; + this.projectiles = []; + this.projectileMetadata = new Map(); + } + + spawnProjectile({ + visual, + metadata = null, + position, + direction, + speed, + target = null, + lifetimeSeconds, + hitRadius, + turnResponse = 0, + basis = this.basis, + }: SpawnProjectileOptions): ProjectileLike { + if (!visual?.group) { + throw new Error("ProjectileManager: projectile visual with group is required"); + } + + const projectile = this.createProjectile({ + visual, + position, + direction, + speed, + target, + lifetimeSeconds, + hitRadius, + turnResponse, + basis, + }); + + this.projectiles.push(projectile); + if (metadata !== null) this.projectileMetadata.set(projectile, metadata); + return projectile; + } + + step(targets: ProjectileTargetLike[] = [], deltaSeconds = 1 / 60): ProjectileHitEvent[] { + const hitEvents: ProjectileHitEvent[] = []; + + for (let i = this.projectiles.length - 1; i >= 0; i -= 1) { + const projectile = this.projectiles[i]; + const result = projectile.step(targets, deltaSeconds); + + const hittedTarget = result.hittedTarget; + if (hittedTarget) { + hitEvents.push({ + projectile, + position: result.position, + target: result.target, + hittedTarget, + metadata: this.projectileMetadata.get(projectile) ?? null, + }); + } + + if (!projectile.active) { + projectile.dispose?.(); + this.projectileMetadata.delete(projectile); + this.projectiles.splice(i, 1); + } + } + + return hitEvents; + } + + clear(): void { + for (const projectile of this.projectiles) projectile.dispose?.(); + this.projectiles.length = 0; + this.projectileMetadata.clear(); + } + + dispose(): void { + this.clear(); + } +} diff --git a/playset/modules/gameplay/combat/projectile-weapon-system.ts b/playset/modules/gameplay/combat/projectile-weapon-system.ts new file mode 100644 index 0000000..0c93032 --- /dev/null +++ b/playset/modules/gameplay/combat/projectile-weapon-system.ts @@ -0,0 +1,510 @@ +// playset/modules/gameplay/combat/projectile-weapon-system.ts — fire-control +// state machine: ammo, cooldown, gun heat, missile lock-on, launch vectors. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/combat/ProjectileWeaponSystem.js. Verbatim +// semantics; aimMode is typed to the two supported modes, so the original's +// implicit crash on an unknown mode becomes the same TypeError as a zero +// fire direction. + +import { DEFAULT_CLOCK, type Clock } from "../../math/time-utils.ts"; +import { toVec3 } from "../../math/vector3-utils.ts"; +import type { Vector3 } from "../../../math/index.ts"; +import type { VecLike } from "../../math/world-basis.ts"; + +export const WEAPON_DECISIONS = Object.freeze({ + FIRE_GUN: "fire-gun", + FIRE_MISSILE: "fire-missile", + BLOCKED: "blocked", + EMPTY_WARNING: "empty-warning", +} as const); + +export const WEAPON_TYPES = Object.freeze({ + GUN: "gun", + MISSILE: "missile", +} as const); + +export const WEAPON_AIM_MODES = Object.freeze({ + BORESIGHT: "boresight", + CROSSHAIR: "crosshair", +} as const); + +export const MISSILE_LOCK_STATUS = Object.freeze({ + NONE: "NONE", + LOCKING: "LOCKING", + LOCKED: "LOCKED", +} as const); + +export type WeaponAimMode = (typeof WEAPON_AIM_MODES)[keyof typeof WEAPON_AIM_MODES]; +export type MissileLockStatus = (typeof MISSILE_LOCK_STATUS)[keyof typeof MISSILE_LOCK_STATUS]; + +export interface WeaponLaunchOffset { + right?: number; + up?: number; + forward?: number; +} + +export interface WeaponState { + id: string; + lastFireTime: number; + ammo: number; + maxAmmo: number; + fireRate: number; + speed?: number; + launchOffset?: WeaponLaunchOffset; +} + +/** Shooter orientation frame — Vector3 axes (BasisFrame fits structurally). */ +export interface WeaponBodyFrame { + right: Vector3; + up: Vector3; + forward: Vector3; +} + +export interface WeaponTarget { + position: VecLike; + destroyed?: boolean; +} + +export type WeaponFireDecision = + | { + type: typeof WEAPON_DECISIONS.FIRE_GUN; + weapon: WeaponState; + overheated: boolean; + position: Vector3; + direction: Vector3; + speed: number | undefined; + } + | { + type: typeof WEAPON_DECISIONS.FIRE_MISSILE; + weapon: WeaponState; + target: WeaponTarget | null; + position: Vector3; + direction: Vector3; + speed: number | undefined; + } + | { type: typeof WEAPON_DECISIONS.BLOCKED; message: string; weapon?: WeaponState; weaponId?: string } + | { type: typeof WEAPON_DECISIONS.EMPTY_WARNING; weaponId: string }; + +export interface ProjectileWeaponSystemOptions { + lockRequiredSeconds?: number; + gunHeatPerShot?: number; + gunCoolRatePerSecond?: number; + gunOverheatThreshold?: number; + gunRecoveredThreshold?: number; + emptyWarningCooldownSeconds?: number; + aimMode?: WeaponAimMode; + targetAimDotMin?: number; + targetMaxDistance?: number; + clock?: Clock; +} + +function calculateDist(sourcePosition: VecLike, targetPosition: VecLike): number { + return Math.hypot( + (targetPosition.x ?? 0) - (sourcePosition.x ?? 0), + (targetPosition.y ?? 0) - (sourcePosition.y ?? 0), + (targetPosition.z ?? 0) - (sourcePosition.z ?? 0), + ); +} + +function calculateDotProduct(sourcePosition: VecLike, targetPosition: VecLike, aimDirection: Vector3): number { + const dX = (targetPosition.x ?? 0) - (sourcePosition.x ?? 0); + const dY = (targetPosition.y ?? 0) - (sourcePosition.y ?? 0); + const dZ = (targetPosition.z ?? 0) - (sourcePosition.z ?? 0); + const len = Math.hypot(dX, dY, dZ); + if (len <= 1e-6) return 0; + + return aimDirection.x * (dX / len) + aimDirection.y * (dY / len) + aimDirection.z * (dZ / len); +} + +export class ProjectileWeaponSystem { + clock: Clock; + aimMode: WeaponAimMode; + cfg: { + lockRequiredSeconds: number; + gunHeatPerShot: number; + gunCoolRatePerSecond: number; + gunOverheatThreshold: number; + gunRecoveredThreshold: number; + emptyWarningCooldownSeconds: number; + targetAimDotMin: number; + targetMaxDistance: number; + }; + weapons: Map; + weaponIds: string[]; + selectedWeaponId: string; + target: WeaponTarget | null; + isGunOverheated: boolean; + gunHeat: number; + lockTime: number; + lockStatus: MissileLockStatus; + lockingTarget: WeaponTarget | null; + emptyWarningTimers: Record; + lastEmptyWarningAtSeconds: number; + + constructor({ + lockRequiredSeconds = 1.0, + gunHeatPerShot = 0.02, + gunCoolRatePerSecond = 0.2, + gunOverheatThreshold = 1.0, + gunRecoveredThreshold = 0.3, + emptyWarningCooldownSeconds = 2.0, + aimMode = WEAPON_AIM_MODES.CROSSHAIR, + targetAimDotMin = 0.94, + targetMaxDistance = 10000, + clock = DEFAULT_CLOCK, + }: ProjectileWeaponSystemOptions) { + this.clock = clock; + this.aimMode = aimMode; + this.cfg = { + lockRequiredSeconds, + gunHeatPerShot, + gunCoolRatePerSecond, + gunOverheatThreshold, + gunRecoveredThreshold, + emptyWarningCooldownSeconds, + targetAimDotMin, + targetMaxDistance, + }; + + this.weapons = new Map(); + this.weapons.set(WEAPON_TYPES.GUN, { + id: WEAPON_TYPES.GUN, + lastFireTime: -Infinity, + ammo: Infinity, + maxAmmo: Infinity, + fireRate: 0.05, + }); + this.weapons.set(WEAPON_TYPES.MISSILE, { + id: WEAPON_TYPES.MISSILE, + lastFireTime: -Infinity, + ammo: 50, + maxAmmo: 50, + fireRate: 1.0, + }); + this.weaponIds = [WEAPON_TYPES.GUN, WEAPON_TYPES.MISSILE]; + + this.selectedWeaponId = WEAPON_TYPES.GUN; + this.target = null; + this.isGunOverheated = false; + this.gunHeat = 0; + this.lockTime = 0; + this.lockStatus = MISSILE_LOCK_STATUS.NONE; + this.lockingTarget = null; + this.emptyWarningTimers = { + [WEAPON_TYPES.GUN]: 0, + [WEAPON_TYPES.MISSILE]: 0, + }; + this.lastEmptyWarningAtSeconds = 0; + } + + updateWeaponConfig( + weaponId: string, + { + ammo, + maxAmmo, + fireRate, + speed, + launchOffset, + }: { ammo: number; maxAmmo: number; fireRate: number; speed?: number; launchOffset?: WeaponLaunchOffset }, + ): void { + const weapon = this.weapons.get(weaponId)!; + weapon.ammo = ammo; + weapon.maxAmmo = maxAmmo; + weapon.fireRate = fireRate; + weapon.speed = speed; + weapon.launchOffset = launchOffset; + } + + resetAmmo(): void { + for (const weaponId of this.weaponIds) { + const weapon = this.weapons.get(weaponId)!; + weapon.ammo = weapon.maxAmmo; + weapon.lastFireTime = -Infinity; + } + this.selectedWeaponId = WEAPON_TYPES.GUN; + this.target = null; + this.isGunOverheated = false; + this.gunHeat = 0; + this.lockTime = 0; + this.lockStatus = MISSILE_LOCK_STATUS.NONE; + this.lockingTarget = null; + this.emptyWarningTimers = { + [WEAPON_TYPES.GUN]: 0, + [WEAPON_TYPES.MISSILE]: 0, + }; + this.lastEmptyWarningAtSeconds = 0; + } + + getCurrentWeapon(): WeaponState { + return this.weapons.get(this.selectedWeaponId)!; + } + + getLaunchPosition( + shooterPosition: VecLike, + shooterBodyFrame: WeaponBodyFrame, + weaponId: string | null = null, + ): Vector3 | VecLike { + const weapon = weaponId ? this.weapons.get(weaponId) : this.getCurrentWeapon(); + if (!weapon) return shooterPosition; + const shooterOrigin = toVec3(shooterPosition); + const offset = weapon.launchOffset ?? {}; + return shooterBodyFrame.right + .clone() + .multiplyScalar(offset.right ?? 0) + .addScaledVector(shooterBodyFrame.up, offset.up ?? 0) + .addScaledVector(shooterBodyFrame.forward, offset.forward ?? 0) + .add(shooterOrigin); + } + + toggleWeapon(): void { + const currentIndex = Math.max(0, this.weaponIds.indexOf(this.selectedWeaponId)); + const nextIndex = (currentIndex + 1) % this.weaponIds.length; + this.selectedWeaponId = this.weaponIds[nextIndex]; + } + + selectWeapon(weaponId: string): null | undefined { + if (!this.weapons.has(weaponId)) return null; + this.selectedWeaponId = weaponId; + return undefined; + } + + // In crosshair mode, aimPosition is the world-space point the launched shot + // should travel toward from its launch position; it can come from + // AimResolver's getAimFromCamera(...).hitPosition or getAimFromAimRay(...). + requestFire({ + shooterPosition, + shooterBodyFrame, + aimPosition = null, + weaponId = null, + }: { + shooterPosition: VecLike; + shooterBodyFrame: WeaponBodyFrame; + aimPosition?: VecLike | null; + weaponId?: string | null; + }): WeaponFireDecision | null { + const weapon = weaponId ? this.weapons.get(weaponId) : this.getCurrentWeapon(); + if (!weapon) return null; + + if (this.aimMode === WEAPON_AIM_MODES.CROSSHAIR && !aimPosition) { + throw new TypeError("ProjectileWeaponSystem: crosshair fire requires aimPosition"); + } + + const now = this.clock.nowSeconds(); + if (weapon.ammo <= 0) return this._emptyWarning(weapon.id, now); + if (weapon.id === WEAPON_TYPES.GUN && this.isGunOverheated) { + return { type: WEAPON_DECISIONS.BLOCKED, message: "Weapon overheated", weapon }; + } + if (now - weapon.lastFireTime < weapon.fireRate) { + return { type: WEAPON_DECISIONS.BLOCKED, message: "Weapon cooldown", weapon }; + } + if (weapon.id === WEAPON_TYPES.MISSILE && this.lockStatus !== MISSILE_LOCK_STATUS.LOCKED) { + return { type: WEAPON_DECISIONS.BLOCKED, message: "Missile needs lock", weapon }; + } + + const motionState = this._computeLaunchMotionState(weapon, shooterPosition, shooterBodyFrame, aimPosition); + + weapon.lastFireTime = now; + if (weapon.ammo !== Infinity) weapon.ammo -= 1; + + if (weapon.id === WEAPON_TYPES.GUN) { + this.gunHeat += this.cfg.gunHeatPerShot; + const overheated = this.gunHeat >= this.cfg.gunOverheatThreshold; + if (overheated) this.isGunOverheated = true; + return { + type: WEAPON_DECISIONS.FIRE_GUN, + weapon, + overheated, + ...motionState, + }; + } + + if (weapon.id === WEAPON_TYPES.MISSILE) { + return { + type: WEAPON_DECISIONS.FIRE_MISSILE, + weapon, + target: this.target, + ...motionState, + }; + } + + return { type: WEAPON_DECISIONS.BLOCKED, message: "Unsupported weapon", weapon }; + } + + // In crosshair mode, aimDirection is the world-space direction the shooter + // is currently aiming; it can come from AimResolver's getAimDirection(). + step({ + shooterPosition, + shooterBodyFrame, + aimDirection = null, + targets = [], + deltaSeconds = 1 / 60, + }: { + shooterPosition: VecLike; + shooterBodyFrame: WeaponBodyFrame; + aimDirection?: VecLike | null; + targets?: WeaponTarget[]; + deltaSeconds?: number; + }): void { + const currentWeapon = this.getCurrentWeapon(); + + if (currentWeapon.id === WEAPON_TYPES.MISSILE) { + this._stepMissileLock({ + shooterPosition, + shooterBodyFrame, + aimDirection, + targets, + deltaSeconds, + }); + } else { + this.lockingTarget = null; + this.lockTime = 0; + this.lockStatus = MISSILE_LOCK_STATUS.NONE; + this.target = null; + } + + this._stepGunHeat(deltaSeconds); + this._stepEmptyWarningCooldowns(deltaSeconds); + } + + findPotentialTarget({ + shooterPosition, + shooterBodyFrame, + aimDirection = null, + targets = [], + }: { + shooterPosition: VecLike; + shooterBodyFrame: WeaponBodyFrame; + aimDirection?: VecLike | null; + targets?: WeaponTarget[]; + }): WeaponTarget | null { + const position = toVec3(shooterPosition); + let resolvedAimDirection: Vector3; + if (this.aimMode === WEAPON_AIM_MODES.BORESIGHT) { + resolvedAimDirection = shooterBodyFrame.forward.clone(); + } else { + resolvedAimDirection = toVec3(aimDirection); + } + resolvedAimDirection.normalize(); + + let bestTarget: WeaponTarget | null = null; + let maxDot = this.cfg.targetAimDotMin; + + for (const target of targets) { + if (target.destroyed) continue; + + const dot = calculateDotProduct(position, target.position, resolvedAimDirection); + if (dot <= maxDot) continue; + + const dist = calculateDist(position, target.position); + if (dist >= this.cfg.targetMaxDistance) continue; + + bestTarget = target; + maxDot = dot; + } + + return bestTarget; + } + + private _stepMissileLock({ + shooterPosition, + shooterBodyFrame, + aimDirection, + targets, + deltaSeconds, + }: { + shooterPosition: VecLike; + shooterBodyFrame: WeaponBodyFrame; + aimDirection: VecLike | null; + targets: WeaponTarget[]; + deltaSeconds: number; + }): void { + const potentialTarget = this.findPotentialTarget({ + shooterPosition, + shooterBodyFrame, + aimDirection, + targets, + }); + + if (!potentialTarget) { + this.lockingTarget = null; + this.lockTime = 0; + this.lockStatus = MISSILE_LOCK_STATUS.NONE; + this.target = null; + return; + } + + if (this.lockingTarget !== potentialTarget) { + this.lockingTarget = potentialTarget; + this.lockTime = 0; + this.lockStatus = MISSILE_LOCK_STATUS.LOCKING; + this.target = null; + return; + } + + this.lockTime += deltaSeconds; + if (this.lockTime >= this.cfg.lockRequiredSeconds) { + this.lockStatus = MISSILE_LOCK_STATUS.LOCKED; + this.target = potentialTarget; + return; + } + + this.lockStatus = MISSILE_LOCK_STATUS.LOCKING; + } + + private _computeLaunchMotionState( + weapon: WeaponState, + shooterPosition: VecLike, + shooterBodyFrame: WeaponBodyFrame, + aimPosition: VecLike | null = null, + ): { position: Vector3; direction: Vector3; speed: number | undefined } { + const position = this.getLaunchPosition(shooterPosition, shooterBodyFrame, weapon.id) as Vector3; + let direction: Vector3; + if (this.aimMode === WEAPON_AIM_MODES.BORESIGHT) { + direction = shooterBodyFrame.forward.clone(); + } else { + direction = toVec3(aimPosition).sub(position); + } + if (direction.lengthSq() <= 1e-12) { + throw new TypeError("ProjectileWeaponSystem: fire direction must be non-zero"); + } + direction.normalize(); + + return { + position: position, + direction, + speed: weapon.speed, + }; + } + + private _stepGunHeat(deltaSeconds: number): void { + if (this.gunHeat <= 0) return; + + this.gunHeat -= deltaSeconds * this.cfg.gunCoolRatePerSecond; + if (this.gunHeat <= 0) { + this.gunHeat = 0; + this.isGunOverheated = false; + } + if (this.isGunOverheated && this.gunHeat < this.cfg.gunRecoveredThreshold) { + this.isGunOverheated = false; + } + } + + private _stepEmptyWarningCooldowns(deltaSeconds: number): void { + for (const key in this.emptyWarningTimers) { + if (this.emptyWarningTimers[key] <= 0) continue; + this.emptyWarningTimers[key] -= deltaSeconds; + if (this.emptyWarningTimers[key] < 0) this.emptyWarningTimers[key] = 0; + } + } + + private _emptyWarning(weaponId: string, now: number): WeaponFireDecision { + if (now - this.lastEmptyWarningAtSeconds <= this.cfg.emptyWarningCooldownSeconds) { + return { type: WEAPON_DECISIONS.BLOCKED, message: "Weapon empty", weaponId }; + } + + this.emptyWarningTimers[weaponId] = 1.0; + this.lastEmptyWarningAtSeconds = now; + return { type: WEAPON_DECISIONS.EMPTY_WARNING, weaponId }; + } +} diff --git a/playset/modules/gameplay/flight-play.ts b/playset/modules/gameplay/flight-play.ts new file mode 100644 index 0000000..b569e83 --- /dev/null +++ b/playset/modules/gameplay/flight-play.ts @@ -0,0 +1,150 @@ +// playset/modules/gameplay/flight-play.ts — flight crash referee: marks a +// player finished when their height drops to the terrain crash height. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/FlightPlay.js. Verbatim semantics. + +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../math/world-basis.ts"; + +export const FLIGHT_PLAY_EVENTS = Object.freeze({ + PLAYER_HIT_GROUND: "flight.player.hitGround", +} as const); + +export interface FlightPosition { + x: number; + y: number; + z: number; +} + +export interface FlightPlayerState { + playerId: string; + position: FlightPosition; + finished: boolean; +} + +export type FlightPlayEvent = { + type: typeof FLIGHT_PLAY_EVENTS.PLAYER_HIT_GROUND; + playerId: string; + position: FlightPosition; + height: number; + crashHeight: number; +}; + +export type CrashHeightFn = (right: number, forward: number) => number; + +export interface FlightPlayOptions { + crashHeightAt: CrashHeightFn; + basis?: WorldBasis; +} + +function clonePosition(position: FlightPosition): FlightPosition { + return { x: position.x, y: position.y, z: position.z }; +} + +function clonePlayer(player: FlightPlayerState): FlightPlayerState { + return { + playerId: player.playerId, + position: clonePosition(player.position), + finished: player.finished, + }; +} + +function createPlayer({ playerId, position }: { playerId: string; position: FlightPosition }): FlightPlayerState { + return { + playerId, + position: clonePosition(position), + finished: false, + }; +} + +export class FlightPlay { + crashHeightAt: CrashHeightFn; + basis: WorldBasis; + players: Map; + private _events: FlightPlayEvent[]; + + constructor({ crashHeightAt, basis = DEFAULT_WORLD_BASIS }: FlightPlayOptions) { + if (typeof crashHeightAt !== "function") { + throw new Error("FlightPlay requires crashHeightAt"); + } + + this.crashHeightAt = crashHeightAt; + this.basis = basis; + this.players = new Map(); + this._events = []; + } + + addPlayer({ playerId, position }: { playerId: string; position: FlightPosition }): void { + if (this.players.has(playerId)) { + throw new Error(`player already exists: ${playerId}`); + } + this.players.set(playerId, createPlayer({ playerId, position })); + } + + movePlayer(playerId: string, position: FlightPosition): void { + this._getPlayer(playerId).position = clonePosition(position); + } + + startGame(): void { + if (this.players.size === 0) { + throw new Error("flight requires at least one player"); + } + + for (const player of this.players.values()) { + player.finished = false; + } + } + + reset(): void { + this._clearEvents(); + for (const player of this.players.values()) { + player.finished = false; + } + } + + getPlayer(playerId: string): FlightPlayerState { + return clonePlayer(this._getPlayer(playerId)); + } + + private _getPlayer(playerId: string): FlightPlayerState { + const player = this.players.get(playerId); + if (!player) throw new Error(`unknown player: ${playerId}`); + return player; + } + + private _queueEvent(event: FlightPlayEvent): void { + this._events.push(event); + } + + private _clearEvents(): void { + this._events = []; + } + + private _drainEvents(): FlightPlayEvent[] { + const events = this._events; + this._events = []; + return events; + } + + step(): FlightPlayEvent[] { + for (const player of this.players.values()) { + if (player.finished) continue; + + const planar = this.basis.toPlanar(player.position); + const crashHeight = this.crashHeightAt(planar.right, planar.forward); + const playerHeight = this.basis.upComponent(player.position); + if (playerHeight > crashHeight) continue; + + player.finished = true; + this._queueEvent({ + type: FLIGHT_PLAY_EVENTS.PLAYER_HIT_GROUND, + playerId: player.playerId, + position: clonePosition(player.position), + height: playerHeight, + crashHeight, + }); + } + + return this._drainEvents(); + } +} diff --git a/playset/modules/gameplay/race-checkpoint-lap-play.ts b/playset/modules/gameplay/race-checkpoint-lap-play.ts new file mode 100644 index 0000000..c53d42a --- /dev/null +++ b/playset/modules/gameplay/race-checkpoint-lap-play.ts @@ -0,0 +1,341 @@ +// playset/modules/gameplay/race-checkpoint-lap-play.ts — checkpoint-lap race +// state machine: countdown, per-player lap progress, standings, finish order. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/RaceCheckpointLapPlay.js. Verbatim semantics. + +export const RACE_STATES = Object.freeze({ + WAITING: "WAITING", + STARTING: "STARTING", + STARTED: "STARTED", + FINISHED: "FINISHED", +} as const); + +export type RaceState = (typeof RACE_STATES)[keyof typeof RACE_STATES]; + +export const RACE_CHECKPOINT_LAP_EVENTS = Object.freeze({ + RACE_STARTED: "race.started", + CHECKPOINT_PASSED: "checkpoint.passed", + LAP_COMPLETED: "lap.completed", + PLAYER_FINISHED: "player.finished", + RACE_FINISHED: "race.finished", +} as const); + +export interface RacePosition { + x: number; + y: number; + z: number; +} + +export interface RaceCheckpoint { + id: string | number; + position: RacePosition; + radius: number; +} + +export interface RacePlayerState { + playerId: string; + position: RacePosition; + completedLaps: number; + nextCheckpointIndex: number; + finished: boolean; + finishOrder: number | null; + finishTimeSeconds: number | null; +} + +export type RaceCheckpointLapEvent = + | { type: typeof RACE_CHECKPOINT_LAP_EVENTS.RACE_STARTED } + | { + type: typeof RACE_CHECKPOINT_LAP_EVENTS.CHECKPOINT_PASSED; + playerId: string; + checkpointId: string | number; + checkpointIndex: number; + lap: number; + } + | { + type: typeof RACE_CHECKPOINT_LAP_EVENTS.LAP_COMPLETED; + playerId: string; + lap: number; + remainingLaps: number; + } + | { + type: typeof RACE_CHECKPOINT_LAP_EVENTS.PLAYER_FINISHED; + playerId: string; + finishOrder: number; + finishTimeSeconds: number; + } + | { type: typeof RACE_CHECKPOINT_LAP_EVENTS.RACE_FINISHED; elapsedSeconds: number }; + +export interface RaceSnapshot { + raceState: RaceState; + elapsedSeconds: number; + countdownSeconds: number; + lapCount: number; + checkpointPerLap: number; + players: RacePlayerState[]; + standings: RacePlayerState[]; +} + +export interface RaceCheckpointLapPlayOptions { + checkpoints: RaceCheckpoint[]; + lapCount?: number; + startingDelaySeconds?: number; +} + +function clonePosition(position: RacePosition): RacePosition { + return { x: position.x, y: position.y, z: position.z }; +} + +function distanceSquared(a: RacePosition, b: RacePosition): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return dx * dx + dy * dy + dz * dz; +} + +function clonePlayerState(player: RacePlayerState): RacePlayerState { + return { + playerId: player.playerId, + position: clonePosition(player.position), + completedLaps: player.completedLaps, + nextCheckpointIndex: player.nextCheckpointIndex, + finished: player.finished, + finishOrder: player.finishOrder, + finishTimeSeconds: player.finishTimeSeconds, + }; +} + +function createPlayerState({ playerId, position }: { playerId: string; position: RacePosition }): RacePlayerState { + return { + playerId, + position: clonePosition(position), + completedLaps: 0, + nextCheckpointIndex: 0, + finished: false, + finishOrder: null, + finishTimeSeconds: null, + }; +} + +/** + * Owns a checkpoint-lap race state. + * + * Constructor keys: + * - checkpoints: ordered checkpoint objects with id, position, and radius. + * - lapCount: laps required to finish. + * - startingDelaySeconds: optional countdown duration used by startGame(). + */ +export class RaceCheckpointLapPlay { + checkpoints: RaceCheckpoint[]; + lapCount: number; + checkpointPerLap: number; + startingDelaySeconds: number; + players: Map; + raceState: RaceState; + elapsedSeconds: number; + countdownSeconds: number; + finishCounter: number; + private _events: RaceCheckpointLapEvent[]; + + constructor({ checkpoints, lapCount = 3, startingDelaySeconds = 0 }: RaceCheckpointLapPlayOptions) { + this.checkpoints = checkpoints; + this.lapCount = lapCount; + this.checkpointPerLap = checkpoints.length; + this.startingDelaySeconds = startingDelaySeconds; + + this.players = new Map(); + this.raceState = RACE_STATES.WAITING; + this.elapsedSeconds = 0; + this.countdownSeconds = 0; + this.finishCounter = 0; + this._events = []; + } + + addPlayer({ playerId, position }: { playerId: string; position: RacePosition }): void { + const player = createPlayerState({ playerId, position }); + if (this.raceState !== RACE_STATES.WAITING) { + throw new Error("players can only be added while the race is waiting"); + } + if (this.players.has(playerId)) { + throw new Error(`player already exists: ${playerId}`); + } + this.players.set(playerId, player); + } + + removePlayer(playerId: string): void { + if (!this.players.delete(playerId)) { + throw new Error(`unknown player: ${playerId}`); + } + } + + updatePlayer(playerId: string, position: RacePosition): void { + const player = this._getPlayer(playerId); + player.position = clonePosition(position); + } + + startGame(): void { + if (this.raceState !== RACE_STATES.WAITING) { + throw new Error("race can only be started from WAITING"); + } + if (this.players.size === 0) { + throw new Error("race requires at least one player"); + } + + this._resetProgress(); + if (this.startingDelaySeconds > 0) { + this.raceState = RACE_STATES.STARTING; + this.countdownSeconds = this.startingDelaySeconds; + return; + } + this.raceState = RACE_STATES.STARTED; + this._queueEvent({ type: RACE_CHECKPOINT_LAP_EVENTS.RACE_STARTED }); + } + + reset(): void { + this._resetProgress(); + this._clearEvents(); + this.raceState = RACE_STATES.WAITING; + this.countdownSeconds = 0; + } + + getPlayer(playerId: string): RacePlayerState { + return clonePlayerState(this._getPlayer(playerId)); + } + + getStandings(): RacePlayerState[] { + return Array.from(this.players.values(), clonePlayerState).sort((a, b) => { + // finishOrder is non-null once finished — guarded by the branch above. + if (a.finished && b.finished) return a.finishOrder! - b.finishOrder!; + if (a.finished) return -1; + if (b.finished) return 1; + if (a.completedLaps !== b.completedLaps) return b.completedLaps - a.completedLaps; + if (a.nextCheckpointIndex !== b.nextCheckpointIndex) { + return b.nextCheckpointIndex - a.nextCheckpointIndex; + } + return a.playerId.localeCompare(b.playerId); + }); + } + + snapshot(): RaceSnapshot { + return { + raceState: this.raceState, + elapsedSeconds: this.elapsedSeconds, + countdownSeconds: this.countdownSeconds, + lapCount: this.lapCount, + checkpointPerLap: this.checkpointPerLap, + players: Array.from(this.players.values(), clonePlayerState), + standings: this.getStandings(), + }; + } + + private _getPlayer(playerId: string): RacePlayerState { + const player = this.players.get(playerId); + if (!player) throw new Error(`unknown player: ${playerId}`); + return player; + } + + private _resetProgress(): void { + this.elapsedSeconds = 0; + this.finishCounter = 0; + for (const player of this.players.values()) { + player.completedLaps = 0; + player.nextCheckpointIndex = 0; + player.finished = false; + player.finishOrder = null; + player.finishTimeSeconds = null; + } + } + + private _queueEvent(event: RaceCheckpointLapEvent): void { + this._events.push(event); + } + + private _clearEvents(): void { + this._events = []; + } + + private _drainEvents(): RaceCheckpointLapEvent[] { + const events = this._events; + this._events = []; + return events; + } + + private _stepCountdown(deltaSeconds: number): void { + this.countdownSeconds = Math.max(0, this.countdownSeconds - deltaSeconds); + if (this.countdownSeconds > 0) return; + + this.raceState = RACE_STATES.STARTED; + this._queueEvent({ type: RACE_CHECKPOINT_LAP_EVENTS.RACE_STARTED }); + } + + private _stepPlayer(player: RacePlayerState): void { + if (player.finished) return; + + const checkpoint = this.checkpoints[player.nextCheckpointIndex]; + if (distanceSquared(player.position, checkpoint.position) > checkpoint.radius * checkpoint.radius) { + return; + } + + this._queueEvent({ + type: RACE_CHECKPOINT_LAP_EVENTS.CHECKPOINT_PASSED, + playerId: player.playerId, + checkpointId: checkpoint.id, + checkpointIndex: player.nextCheckpointIndex, + lap: player.completedLaps + 1, + }); + + player.nextCheckpointIndex += 1; + if (player.nextCheckpointIndex < this.checkpointPerLap) return; + + player.nextCheckpointIndex = 0; + player.completedLaps += 1; + this._queueEvent({ + type: RACE_CHECKPOINT_LAP_EVENTS.LAP_COMPLETED, + playerId: player.playerId, + lap: player.completedLaps, + remainingLaps: this.lapCount - player.completedLaps, + }); + + if (player.completedLaps < this.lapCount) return; + + this.finishCounter += 1; + player.finished = true; + player.finishOrder = this.finishCounter; + player.finishTimeSeconds = this.elapsedSeconds; + this._queueEvent({ + type: RACE_CHECKPOINT_LAP_EVENTS.PLAYER_FINISHED, + playerId: player.playerId, + finishOrder: player.finishOrder, + finishTimeSeconds: player.finishTimeSeconds, + }); + } + + step(deltaSeconds = 1 / 60): RaceCheckpointLapEvent[] { + let raceDeltaSeconds = deltaSeconds; + if (this.raceState === RACE_STATES.STARTING) { + const countdownBeforeStep = this.countdownSeconds; + this._stepCountdown(deltaSeconds); + raceDeltaSeconds = Math.max(0, deltaSeconds - countdownBeforeStep); + } + if (this.raceState === RACE_STATES.STARTED) { + this.elapsedSeconds += raceDeltaSeconds; + for (const player of this.players.values()) { + this._stepPlayer(player); + } + this._finishRaceIfComplete(); + } + return this._drainEvents(); + } + + private _finishRaceIfComplete(): void { + if (this.raceState !== RACE_STATES.STARTED || this.players.size === 0) return; + for (const player of this.players.values()) { + if (!player.finished) return; + } + this.raceState = RACE_STATES.FINISHED; + this._queueEvent({ + type: RACE_CHECKPOINT_LAP_EVENTS.RACE_FINISHED, + elapsedSeconds: this.elapsedSeconds, + }); + } +} diff --git a/playset/modules/gameplay/snake-play.ts b/playset/modules/gameplay/snake-play.ts new file mode 100644 index 0000000..e314c5d --- /dev/null +++ b/playset/modules/gameplay/snake-play.ts @@ -0,0 +1,244 @@ +// playset/modules/gameplay/snake-play.ts — grid-snake referee: wall / self / +// snake-vs-snake collisions and item pickups on a right/forward cell grid. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/SnakePlay.js. Verbatim semantics. + +export const SNAKE_PLAY_EVENTS = Object.freeze({ + ITEM_PICKED_UP: "snake.item.picked-up", + PLAYER_DIED: "snake.died", +} as const); + +export const SNAKE_DEATH_REASONS = Object.freeze({ + WALL: "wall", + SELF: "self", + SNAKE: "snake", +} as const); + +export type SnakeDeathReason = (typeof SNAKE_DEATH_REASONS)[keyof typeof SNAKE_DEATH_REASONS]; + +export interface SnakeCell { + right: number; + forward: number; +} + +export interface SnakePlayerState { + playerId: string; + segments: SnakeCell[]; + alive: boolean; +} + +export interface SnakeItemState { + cell: SnakeCell; + growth: number; +} + +export type SnakePlayEvent = + | { + type: typeof SNAKE_PLAY_EVENTS.PLAYER_DIED; + playerId: string; + reason: SnakeDeathReason; + cell: SnakeCell | null; + hitPlayerId?: string; + } + | { + type: typeof SNAKE_PLAY_EVENTS.ITEM_PICKED_UP; + playerId: string; + cell: SnakeCell; + growBy: number; + }; + +export interface SnakePlayOptions { + minRight: number; + maxRight: number; + minForward: number; + maxForward: number; +} + +function cloneCell(cell: SnakeCell): SnakeCell { + return { + right: Math.floor(cell.right), + forward: Math.floor(cell.forward), + }; +} + +function cloneCells(cells: SnakeCell[]): SnakeCell[] { + return cells.map(cloneCell); +} + +function cellKey(cell: SnakeCell): string { + return `${cell.right}:${cell.forward}`; +} + +function clonePlayer(player: SnakePlayerState): SnakePlayerState { + return { + playerId: player.playerId, + segments: cloneCells(player.segments), + alive: player.alive, + }; +} + +function createPlayer({ playerId, segments }: { playerId: string; segments: SnakeCell[] }): SnakePlayerState { + return { + playerId, + segments: cloneCells(segments), + alive: true, + }; +} + +export class SnakePlay { + minRight: number; + maxRight: number; + minForward: number; + maxForward: number; + players: Map; + items: Map; + + constructor({ minRight, maxRight, minForward, maxForward }: SnakePlayOptions) { + this.minRight = Math.floor(minRight); + this.maxRight = Math.floor(maxRight); + this.minForward = Math.floor(minForward); + this.maxForward = Math.floor(maxForward); + this.players = new Map(); + this.items = new Map(); + } + + addPlayer({ playerId, segments }: { playerId: string; segments: SnakeCell[] }): void { + if (this.players.has(playerId)) { + throw new Error(`player already exists: ${playerId}`); + } + + this.players.set(playerId, createPlayer({ playerId, segments })); + } + + movePlayer({ playerId, segments }: { playerId: string; segments: SnakeCell[] }): void { + const player = this._getPlayer(playerId); + player.segments = cloneCells(segments); + } + + addItem({ cell, growth = 1 }: { cell: SnakeCell; growth?: number }): void { + const nextItem = { + cell: cloneCell(cell), + growth: Math.max(0, Math.floor(growth)), + }; + const key = cellKey(nextItem.cell); + if (this.items.has(key)) { + throw new Error(`item already exists at cell: ${key}`); + } + + this.items.set(key, nextItem); + } + + step(): SnakePlayEvent[] { + const events: SnakePlayEvent[] = []; + const alivePlayerIds = new Set( + Array.from(this.players.values()) + .filter((player) => player.alive) + .map((player) => player.playerId), + ); + + for (const player of this.players.values()) { + if (!alivePlayerIds.has(player.playerId)) continue; + + const head = player.segments[0] ? cloneCell(player.segments[0]) : null; + if (!head || this._isWall(head)) { + player.alive = false; + events.push(this._createDeathEvent(player, SNAKE_DEATH_REASONS.WALL, head)); + continue; + } + + if (this._hitsSelf(player)) { + player.alive = false; + events.push(this._createDeathEvent(player, SNAKE_DEATH_REASONS.SELF, head)); + continue; + } + + const hitPlayer = this._playerAt(head, player.playerId, alivePlayerIds); + if (hitPlayer) { + player.alive = false; + events.push(this._createDeathEvent(player, SNAKE_DEATH_REASONS.SNAKE, head, hitPlayer.playerId)); + continue; + } + + const itemKey = cellKey(head); + const item = this.items.get(itemKey); + if (!item) continue; + + this.items.delete(itemKey); + const growBy = item.growth; + events.push({ + type: SNAKE_PLAY_EVENTS.ITEM_PICKED_UP, + playerId: player.playerId, + cell: cloneCell(item.cell), + growBy, + }); + } + + return events; + } + + getPlayerState(playerId: string): SnakePlayerState { + return clonePlayer(this._getPlayer(playerId)); + } + + getItemState(): SnakeItemState[] { + return Array.from(this.items.values()).map((item) => { + return { + cell: cloneCell(item.cell), + growth: item.growth, + }; + }); + } + + private _createDeathEvent( + player: SnakePlayerState, + reason: SnakeDeathReason, + cell: SnakeCell | null, + hitPlayerId: string | null = null, + ): SnakePlayEvent { + const event: SnakePlayEvent = { + type: SNAKE_PLAY_EVENTS.PLAYER_DIED, + playerId: player.playerId, + reason, + cell: cell ? cloneCell(cell) : null, + }; + if (hitPlayerId) event.hitPlayerId = hitPlayerId; + + return event; + } + + private _isWall(cell: SnakeCell): boolean { + return ( + cell.right < this.minRight || + cell.right > this.maxRight || + cell.forward < this.minForward || + cell.forward > this.maxForward + ); + } + + private _hitsSelf(player: SnakePlayerState): boolean { + if (player.segments.length <= 1) return false; + const headKey = cellKey(player.segments[0]); + return player.segments.slice(1).some((segment) => cellKey(segment) === headKey); + } + + private _playerAt( + cell: SnakeCell, + excludePlayerId: string, + alivePlayerIds: Set | null = null, + ): SnakePlayerState | null { + const key = cellKey(cell); + for (const player of this.players.values()) { + if (player.playerId === excludePlayerId) continue; + if (alivePlayerIds ? !alivePlayerIds.has(player.playerId) : !player.alive) continue; + if (player.segments.some((segment) => cellKey(segment) === key)) return player; + } + return null; + } + + private _getPlayer(playerId: string): SnakePlayerState { + const player = this.players.get(playerId); + if (!player) throw new Error(`unknown player: ${playerId}`); + return player; + } +} diff --git a/playset/modules/gameplay/wave-spawn-director.ts b/playset/modules/gameplay/wave-spawn-director.ts new file mode 100644 index 0000000..c230359 --- /dev/null +++ b/playset/modules/gameplay/wave-spawn-director.ts @@ -0,0 +1,236 @@ +// playset/modules/gameplay/wave-spawn-director.ts — enemy-wave pacing: wave +// sizing, unlock rules, weighted type selection, spawn planning per step. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/gameplay/WaveSpawnDirector.js. Verbatim semantics. + +import { clamp } from "../math/scalar-utils.ts"; +import { DEFAULT_PRNG } from "../math/random-utils.ts"; + +export interface WaveUnlockRule { + waveNumber: number; + type: string; +} + +export type WaveTypeWeight = number | ((waveNumber: number) => number); + +export interface WavePrng { + random(): number; +} + +export interface WaveSpawn { + /** Undefined when no unlock rule is live yet (original behavior). */ + type: string | undefined; + waveNumber: number; + spawnIndex: number; + spawnCount: number; +} + +export interface WaveStartInfo { + waveNumber: number; + unitsToSpawn: number; + availableTypes: string[]; +} + +export interface WaveStepResult { + spawns: WaveSpawn[]; +} + +export interface WaveCompletion { + completedWaveNumber: number; + nextWaveNumber: number; +} + +export interface WaveSnapshot { + waveNumber: number; + inProgress: boolean; + unitsToSpawn: number; + unitsSpawned: number; + pending: number; + activeUnits: number; + lastSpawnedType: string | null | undefined; +} + +export interface WaveSpawnDirectorOptions { + baseWaveSize?: number; + growthPerWave?: number; + maxWaveSize?: number; + unlockRules?: WaveUnlockRule[]; + typeWeights?: Record; + maxSpawnsPerStep?: number; + startWaveNumber?: number; + waveAutoStart?: boolean; + prng?: WavePrng; +} + +const DEFAULT_UNLOCK_RULES: WaveUnlockRule[] = [{ waveNumber: 1, type: "DEFAULT" }]; + +const DEFAULT_TYPE_WEIGHTS: Record = { + DEFAULT: 1, +}; + +const resolveWeight = (value: WaveTypeWeight, waveNumber: number): number => + typeof value === "function" ? value(waveNumber) : value; + +export class WaveSpawnDirector { + baseWaveSize: number; + growthPerWave: number; + maxWaveSize: number; + unlockRules: WaveUnlockRule[]; + typeWeights: Record; + maxSpawnsPerStep: number; + startWaveNumber: number; + waveAutoStart: boolean; + prng: WavePrng; + activeUnits: number; + // Assigned via reset() in the constructor. + waveNumber!: number; + inProgress!: boolean; + unitsToSpawn!: number; + unitsSpawned!: number; + lastSpawnedType!: string | null | undefined; + + constructor({ + baseWaveSize = 3, + growthPerWave = 1.5, + maxWaveSize = 500, + unlockRules = DEFAULT_UNLOCK_RULES, + typeWeights = DEFAULT_TYPE_WEIGHTS, + maxSpawnsPerStep = 100, + startWaveNumber = 1, + waveAutoStart = true, + prng = DEFAULT_PRNG, + }: WaveSpawnDirectorOptions) { + this.baseWaveSize = baseWaveSize; + this.growthPerWave = growthPerWave; + this.maxWaveSize = maxWaveSize; + this.unlockRules = [...unlockRules].sort((a, b) => a.waveNumber - b.waveNumber); + this.typeWeights = { ...typeWeights }; + this.maxSpawnsPerStep = maxSpawnsPerStep; + this.startWaveNumber = startWaveNumber; + this.waveAutoStart = waveAutoStart; + this.prng = prng; + this.activeUnits = 0; + + this.reset(this.startWaveNumber); + if (this.waveAutoStart) this.startWave(this.startWaveNumber); + } + + reset(startWaveNumber: number = this.startWaveNumber): void { + this.waveNumber = startWaveNumber; + this.inProgress = false; + this.unitsToSpawn = 0; + this.unitsSpawned = 0; + this.lastSpawnedType = null; + this.activeUnits = 0; + } + + startWave(waveNumber: number = this.waveNumber): WaveStartInfo { + this.waveNumber = waveNumber; + this.unitsToSpawn = this.getWaveSize(waveNumber); + this.unitsSpawned = 0; + this.lastSpawnedType = null; + this.inProgress = true; + + return { + waveNumber: this.waveNumber, + unitsToSpawn: this.unitsToSpawn, + availableTypes: this.getAvailableTypes(this.waveNumber), + }; + } + + step({ activeUnits }: { activeUnits: number }): WaveStepResult { + this.activeUnits = activeUnits; + + this.completeIfDone(activeUnits); + if (!this.inProgress && this.waveAutoStart) this.startWave(this.waveNumber); + + return { + spawns: this.planSpawns(), + }; + } + + setActiveUnits(activeUnits: number): void { + this.activeUnits = activeUnits; + } + + getWaveSize(waveNumber: number = this.waveNumber): number { + const raw = this.baseWaveSize + (waveNumber - 1) * this.growthPerWave; + return clamp(Math.floor(raw), 1, this.maxWaveSize); + } + + getAvailableTypes(waveNumber: number = this.waveNumber): string[] { + return this.unlockRules.filter((rule) => waveNumber >= rule.waveNumber).map((rule) => rule.type); + } + + selectType(waveNumber: number = this.waveNumber): string | undefined { + const available = this.getAvailableTypes(waveNumber); + const entries: Array<{ type: string; weight: number }> = []; + let total = 0; + + for (const type of available) { + const weight = resolveWeight(this.typeWeights[type] ?? 0, waveNumber); + if (weight <= 0) continue; + entries.push({ type, weight }); + total += weight; + } + + if (entries.length === 0) return available[0]; + + let pick = this.prng.random() * total; + for (const entry of entries) { + pick -= entry.weight; + if (pick <= 0) return entry.type; + } + return entries[entries.length - 1].type; + } + + planSpawns(): WaveSpawn[] { + if (!this.inProgress || this.unitsSpawned >= this.unitsToSpawn) return []; + + const spawns: WaveSpawn[] = []; + let guard = this.maxSpawnsPerStep; + + while (this.unitsSpawned < this.unitsToSpawn && guard > 0) { + const type = this.selectType(this.waveNumber); + const spawn = { + type, + waveNumber: this.waveNumber, + spawnIndex: this.unitsSpawned, + spawnCount: this.unitsToSpawn, + }; + + this.unitsSpawned += 1; + this.lastSpawnedType = type; + spawns.push(spawn); + guard -= 1; + } + + return spawns; + } + + completeIfDone(activeUnits: number): WaveCompletion | null { + if (!this.inProgress || this.unitsSpawned < this.unitsToSpawn || activeUnits > 0) return null; + + const completedWaveNumber = this.waveNumber; + this.inProgress = false; + this.waveNumber += 1; + + return { + completedWaveNumber, + nextWaveNumber: this.waveNumber, + }; + } + + snapshot(): WaveSnapshot { + return { + waveNumber: this.waveNumber, + inProgress: this.inProgress, + unitsToSpawn: this.unitsToSpawn, + unitsSpawned: this.unitsSpawned, + pending: 0, + activeUnits: this.activeUnits, + lastSpawnedType: this.lastSpawnedType, + }; + } +} diff --git a/playset/modules/math/random-utils.ts b/playset/modules/math/random-utils.ts new file mode 100644 index 0000000..9a1cebc --- /dev/null +++ b/playset/modules/math/random-utils.ts @@ -0,0 +1,54 @@ +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © xt4d) — +// modules/math/RandomUtils.js. Bit-identical Mulberry32 sequence to the +// original: a playset port and a GameBlocks browser build seeded alike draw +// the SAME stream, which is what makes cross-engine golden traces possible. +// +// Determinism discipline (DETERMINISM.md): never use Math.random in game +// code. Construct one RandomGenerator per subsystem with an explicit seed — +// the shared DEFAULT_PRNG exists for GameBlocks port-compatibility, but two +// subsystems drawing from it interleave their streams (order-dependent). + +export class RandomGenerator { + private state = 0; + + constructor(seed = 42) { + this.seed(seed); + } + + seed(seed = 42): this { + this.state = seed >>> 0; + return this; + } + + /** Mulberry32 — uniform in [0, 1). */ + random(): number { + this.state = (this.state + 0x6d2b79f5) >>> 0; + let t = Math.imul(this.state ^ (this.state >>> 15), 1 | this.state); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + uniform(min: number, max: number): number { + return min + (max - min) * this.random(); + } + + /** Integer in [min, max] (inclusive both ends, like the original). */ + randint(min: number, max: number): number { + return Math.floor(this.uniform(min, max + 1)); + } + + randrange(start: number, stop: number | null = null, step = 1): number { + if (stop == null) { + stop = start; + start = 0; + } + const count = Math.ceil((stop - start) / step); + return start + Math.floor(this.random() * count) * step; + } + + choice(items: readonly T[]): T { + return items[Math.floor(this.random() * items.length)]; + } +} + +export const DEFAULT_PRNG = new RandomGenerator(42); diff --git a/playset/modules/math/scalar-utils.ts b/playset/modules/math/scalar-utils.ts new file mode 100644 index 0000000..a249d2e --- /dev/null +++ b/playset/modules/math/scalar-utils.ts @@ -0,0 +1,47 @@ +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © xt4d) — +// modules/math/ScalarUtils.js. Verbatim semantics; the smoothing helpers are +// the framerate-independence backbone every motion controller leans on. + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +export function toFinite(value: number, fallback = 0): number { + return Number.isFinite(value) ? value : fallback; +} + +export function lerp(start: number, end: number, alpha: number): number { + return start + (end - start) * alpha; +} + +export function fract(value: number): number { + return value - Math.floor(value); +} + +export function toRad(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +export function toDeg(radians: number): number { + return (radians * 180) / Math.PI; +} + +/** dt-stable exponential smoothing factor: 1 - e^(-dt/lag). */ +export function smoothingAlpha(lag: number, deltaSeconds: number): number { + const safeDelta = Math.max(0, deltaSeconds); + if (lag <= 0) return 1; + return 1 - Math.exp(-safeDelta / lag); +} + +export function smoothToward( + current: number, + target: number, + lag: number, + deltaSeconds: number, +): number { + return current + (target - current) * smoothingAlpha(lag, deltaSeconds); +} diff --git a/playset/modules/math/time-utils.ts b/playset/modules/math/time-utils.ts new file mode 100644 index 0000000..ce55c76 --- /dev/null +++ b/playset/modules/math/time-utils.ts @@ -0,0 +1,60 @@ +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © xt4d) — +// modules/math/TimeUtils.js, with ONE deliberate semantic upgrade: +// +// GameBlocks' Clock falls back to Date.now() unless the caller opts into +// manual mode — the wall clock leaks in by default. In Pocket the wall +// clock is never an input (DETERMINISM.md), so the non-manual path reads +// the VIRTUAL clock (@pocketjs/framework/clock virtualNow): same API, but +// deterministic by default on every host, replayable under host-sim. +// `useManual()` behaves exactly like the original (fixed-step drivers and +// tests advance it by hand). + +import { virtualNow } from "@pocketjs/framework/clock"; + +export interface ClockOptions { + manual?: boolean; + nowMs?: number; +} + +export class Clock { + manual: boolean; + currentMs: number; + + constructor({ manual = false, nowMs = 0 }: ClockOptions = {}) { + this.manual = manual; + this.currentMs = nowMs; + } + + /** Milliseconds — manual value, or VIRTUAL time (never Date.now). */ + now(): number { + return this.manual ? this.currentMs : virtualNow() * 1000; + } + + nowSeconds(): number { + return this.now() * 0.001; + } + + /** GameBlocks name kept; "system" here means the virtual clock. */ + useSystem(): this { + this.manual = false; + return this; + } + + useManual(nowMs: number = this.currentMs): this { + this.manual = true; + this.currentMs = nowMs; + return this; + } + + setMs(nowMs: number): this { + this.currentMs = nowMs; + return this; + } + + advanceMs(deltaMs: number): this { + this.currentMs += deltaMs; + return this; + } +} + +export const DEFAULT_CLOCK = new Clock(); diff --git a/playset/modules/math/vector3-utils.ts b/playset/modules/math/vector3-utils.ts new file mode 100644 index 0000000..e038d0e --- /dev/null +++ b/playset/modules/math/vector3-utils.ts @@ -0,0 +1,55 @@ +// playset/modules/math/vector3-utils.ts — safe Vector3 coercion helpers. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/math/Vector3Utils.js. Verbatim semantics. + +import { Vector3 } from "../../math/vector3.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "./world-basis.ts"; + +export const VECTOR_EPS = 1e-6; + +export function toVec3( + value: VecLike | null | undefined, + fallback: VecLike = { x: 0, y: 0, z: 0 }, +): Vector3 { + return new Vector3( + value?.x ?? fallback.x ?? 0, + value?.y ?? fallback.y ?? 0, + value?.z ?? fallback.z ?? 0, + ); +} + +export function toUnitVec3( + value: VecLike | null | undefined, + fallback: VecLike = { x: 0, y: 1, z: 0 }, +): Vector3 { + const vector = toVec3(value, fallback); + if (vector.lengthSq() <= VECTOR_EPS * VECTOR_EPS) { + const fallbackVector = toVec3(fallback); + if (fallbackVector.lengthSq() <= VECTOR_EPS * VECTOR_EPS) { + return new Vector3(); + } + return fallbackVector.normalize(); + } + return vector.normalize(); +} + +export function toPlanarUnitVec3( + value: VecLike | null | undefined, + fallback: VecLike = { x: 0, y: 0, z: -1 }, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): Vector3 { + const worldBasis = basis; + const vector = toVec3(value, fallback); + worldBasis.flatten(vector); + if (vector.lengthSq() > VECTOR_EPS * VECTOR_EPS) { + return vector.normalize(); + } + + const fallbackVector = toVec3(fallback); + worldBasis.flatten(fallbackVector); + if (fallbackVector.lengthSq() <= VECTOR_EPS * VECTOR_EPS) { + return new Vector3(); + } + return fallbackVector.normalize(); +} diff --git a/playset/modules/math/world-basis.ts b/playset/modules/math/world-basis.ts new file mode 100644 index 0000000..c03aed7 --- /dev/null +++ b/playset/modules/math/world-basis.ts @@ -0,0 +1,344 @@ +// playset/modules/math/world-basis.ts — the single source of truth for +// gameplay-space coordinates: how right/up/forward map onto world axes, and +// every basis-aware helper (planar math, heading, control signs, frames). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/math/WorldBasis.js. Verbatim semantics. The default basis +// (right=+X, up=+Y, forward=−Z) matches both Three.js convention and +// pocket3d's camera space, so ported motion math needs no re-derivation. + +import { Matrix4 } from "../../math/matrix4.ts"; +import { Quaternion } from "../../math/quaternion.ts"; +import { Vector3 } from "../../math/vector3.ts"; + +export type AxisName = "x" | "y" | "z"; + +export interface AxisDescriptor { + axis: AxisName; + sign: 1 | -1; +} + +/** Loose axis spec: "+x" / "-z" strings or a descriptor object. */ +export type AxisSpec = string | { axis: string; sign?: number | string }; + +export interface WorldBasisConfig { + right: AxisSpec; + up: AxisSpec; + forward: AxisSpec; +} + +/** Any object with world components — Vector3, poses, plain literals. */ +export interface VecLike { + x?: number; + y?: number; + z?: number; +} + +/** A mutable xyz target (Vector3, or anything shaped like one). */ +export interface MutableVec { + x: number; + y: number; + z: number; +} + +export interface PlanarPair { + right: number; + forward: number; +} + +export interface BasisComponents { + right: number; + up: number; + forward: number; +} + +export interface BasisFrame { + right: Vector3; + up: Vector3; + forward: Vector3; + back: Vector3; +} + +export type ControlDirection = + | "left" + | "right" + | "up" + | "down" + | "forward" + | "backward" + | "counterClockWise" + | "clockWise"; + +const AXES: readonly AxisName[] = ["x", "y", "z"]; +const AXIS_EPS = 1e-9; + +const DEFAULT_AXES: WorldBasisConfig = Object.freeze({ + right: Object.freeze({ axis: "x", sign: 1 }), + up: Object.freeze({ axis: "y", sign: 1 }), + forward: Object.freeze({ axis: "z", sign: -1 }), +}); + +function readSignal(value: boolean | number | null | undefined): number { + if (value === true) return 1; + if (value === false || value == null) return 0; + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function parseAxisDescriptor(value: AxisSpec, label: string): AxisDescriptor { + const raw = + typeof value === "string" + ? value + : value?.axis + ? `${value.sign === -1 || value.sign === "-" ? "-" : "+"}${value.axis}` + : null; + if (typeof raw !== "string") { + throw new Error(`WorldBasis: ${label} must be an axis string like "+x" or "-z"`); + } + + const trimmed = raw.trim().toLowerCase(); + const sign = trimmed.startsWith("-") ? -1 : 1; + const axis = trimmed.replace(/^[+-]/, "") as AxisName; + if (!AXES.includes(axis)) { + throw new Error(`WorldBasis: invalid ${label} axis "${raw}"`); + } + return { axis, sign }; +} + +function validateAxes(right: AxisDescriptor, up: AxisDescriptor, forward: AxisDescriptor): void { + const rawAxes = [right.axis, up.axis, forward.axis]; + if (new Set(rawAxes).size !== 3) { + throw new Error("WorldBasis: right, up, and forward must use three distinct world axes"); + } + + const r = { x: 0, y: 0, z: 0 }; + const f = { x: 0, y: 0, z: 0 }; + r[right.axis] = right.sign; + f[forward.axis] = forward.sign; + const cross = { + x: r.y * f.z - r.z * f.y, + y: r.z * f.x - r.x * f.z, + z: r.x * f.y - r.y * f.x, + }; + if (cross[up.axis] * up.sign <= 0) { + throw new Error("WorldBasis: right x forward must point along up"); + } +} + +function readComponent(value: VecLike | null | undefined, axis: AxisName): number { + return value?.[axis] ?? 0; +} + +export class WorldBasis { + readonly rightAxis: Readonly; + readonly upAxis: Readonly; + readonly forwardAxis: Readonly; + /** Multiply a positive control delta by these to get signed movement or a + * right-hand-rule rotation angle. */ + readonly controlSigns: Readonly>; + + constructor(config: WorldBasisConfig = DEFAULT_AXES) { + const right = parseAxisDescriptor(config.right, "right"); + const up = parseAxisDescriptor(config.up, "up"); + const forward = parseAxisDescriptor(config.forward, "forward"); + + validateAxes(right, up, forward); + + this.rightAxis = Object.freeze(right); + this.upAxis = Object.freeze(up); + this.forwardAxis = Object.freeze(forward); + + this.controlSigns = Object.freeze({ + left: -1, + right: 1, + up: 1, + down: -1, + forward: 1, + backward: -1, + counterClockWise: 1, + clockWise: -1, + }); + } + + rightVector(target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.rightAxis.axis] = this.rightAxis.sign; + return target; + } + + upVector(target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.upAxis.axis] = this.upAxis.sign; + return target; + } + + downVector(target: Vector3 = new Vector3()): Vector3 { + return this.upVector(target).multiplyScalar(-1); + } + + forwardVector(target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.forwardAxis.axis] = this.forwardAxis.sign; + return target; + } + + rightComponent(value: VecLike | null | undefined): number { + return readComponent(value, this.rightAxis.axis) * this.rightAxis.sign; + } + + upComponent(value: VecLike | null | undefined): number { + return readComponent(value, this.upAxis.axis) * this.upAxis.sign; + } + + forwardComponent(value: VecLike | null | undefined): number { + return readComponent(value, this.forwardAxis.axis) * this.forwardAxis.sign; + } + + setHeight(target: T, height = 0): T { + target[this.upAxis.axis] = this.upAxis.sign * height; + return target; + } + + flatten(target: T): T { + return this.setHeight(target, 0); + } + + addHeight(target: T, delta = 0): T { + target[this.upAxis.axis] = + readComponent(target, this.upAxis.axis) + this.upAxis.sign * delta; + return target; + } + + hasWorldPlanarComponents(value: VecLike | null | undefined): boolean { + return ( + Boolean(value) && + Number.isFinite(value?.[this.rightAxis.axis]) && + Number.isFinite(value?.[this.forwardAxis.axis]) + ); + } + + toPlanar(value: VecLike | null | undefined, out: PlanarPair = { right: 0, forward: 0 }): PlanarPair { + out.right = this.rightComponent(value); + out.forward = this.forwardComponent(value); + return out; + } + + planarDelta( + to: VecLike | null | undefined, + from: VecLike | null | undefined, + out: PlanarPair = { right: 0, forward: 0 }, + ): PlanarPair { + out.right = this.rightComponent(to) - this.rightComponent(from); + out.forward = this.forwardComponent(to) - this.forwardComponent(from); + return out; + } + + fromBasisComponents(right = 0, up = 0, forward = 0, target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.rightAxis.axis] = this.rightAxis.sign * right; + target[this.upAxis.axis] = this.upAxis.sign * up; + target[this.forwardAxis.axis] = this.forwardAxis.sign * forward; + return target; + } + + toBasisComponents( + value: VecLike | null | undefined, + out: BasisComponents = { right: 0, up: 0, forward: 0 }, + ): BasisComponents { + out.right = this.rightComponent(value); + out.up = this.upComponent(value); + out.forward = this.forwardComponent(value); + return out; + } + + controlSignal(direction: ControlDirection, signal: boolean | number | null | undefined): number { + if (Object.prototype.hasOwnProperty.call(this.controlSigns, direction)) { + return this.controlSigns[direction] * readSignal(signal); + } + throw new Error(`WorldBasis: unknown control direction "${direction as string}"`); + } + + surfaceNormalFromSlopes(rightSlope = 0, forwardSlope = 0, target: Vector3 = new Vector3()): Vector3 { + // For P(r, f) = r*right + h(r,f)*up + f*forward, an up-facing normal is + // P_f x P_r = up - h_r*right - h_f*forward. + return this.fromBasisComponents(-rightSlope, 1, -forwardSlope, target).normalize(); + } + + // Angles are radians. Using the right-hand rule, positive rotation is + // counter-clockwise when looking from the positive end of the rotation axis + // toward the origin. + // yaw is positive CCW from the +up side; + // pitch is positive CCW from the +right side; + // roll is positive CCW from the +forward side. + yawPitchRollFrame(yaw = 0, pitch = 0, roll = 0): BasisFrame { + const pitchCos = Math.cos(pitch); + const forward = this.fromBasisComponents( + -Math.sin(yaw) * pitchCos, + Math.sin(pitch), + Math.cos(yaw) * pitchCos, + ).normalize(); + const right = this.fromBasisComponents(Math.cos(yaw), 0, Math.sin(yaw)).normalize(); + const up = new Vector3().crossVectors(right, forward).normalize(); + + if (roll) { + right.applyAxisAngle(forward, roll).normalize(); + up.applyAxisAngle(forward, roll).normalize(); + } + + return { + right, + up, + forward, + back: forward.clone().multiplyScalar(-1), + }; + } + + distanceSqPlanar(a: VecLike | null | undefined, b: VecLike | null | undefined): number { + const dRight = this.rightComponent(a) - this.rightComponent(b); + const dForward = this.forwardComponent(a) - this.forwardComponent(b); + return dRight * dRight + dForward * dForward; + } + + planarLength(value: VecLike | null | undefined): number { + const right = this.rightComponent(value); + const forward = this.forwardComponent(value); + return Math.sqrt(right * right + forward * forward); + } + + sideVector(value: VecLike | null | undefined, preferredDirection = 1, target: Vector3 = new Vector3()): Vector3 { + const right = this.rightComponent(value); + const forward = this.forwardComponent(value); + return this.fromBasisComponents(forward * preferredDirection, 0, -right * preferredDirection, target); + } + + threeObjectCanonicalToBasisQuaternion(target: Quaternion = new Quaternion()): Quaternion { + // Upright mesh canonical: +X <-> right, +Y <-> up, -Z <-> forward + return target.setFromRotationMatrix( + new Matrix4().makeBasis( + this.rightVector(), + this.upVector(), + this.forwardVector().multiplyScalar(-1), + ), + ); + } + + threePlaneCanonicalToBasisQuaternion(target: Quaternion = new Quaternion()): Quaternion { + // PlaneGeometry canonical: +X <-> right, +Y <-> forward, +Z <-> up + return target.setFromRotationMatrix( + new Matrix4().makeBasis(this.rightVector(), this.forwardVector(), this.upVector()), + ); + } + + forwardToYaw(forward: VecLike | null | undefined): number { + const right = this.rightComponent(forward); + const forwardComponent = this.forwardComponent(forward); + if (right * right + forwardComponent * forwardComponent <= AXIS_EPS) return 0; + return Math.atan2(-right, forwardComponent); + } +} + +export const DEFAULT_WORLD_BASIS: WorldBasis = Object.freeze(new WorldBasis(DEFAULT_AXES)); + +export function createWorldBasis(config: WorldBasisConfig = DEFAULT_AXES): WorldBasis { + return new WorldBasis(config); +} diff --git a/playset/modules/physics/collision-world.ts b/playset/modules/physics/collision-world.ts new file mode 100644 index 0000000..f0e2bb5 --- /dev/null +++ b/playset/modules/physics/collision-world.ts @@ -0,0 +1,492 @@ +// playset/modules/physics/collision-world.ts — the deterministic collision +// core. Playset-native (no GameBlocks counterpart): it replaces the injected +// Rapier `world` in the ported environments and batch resolvers. +// +// Scope is deliberately v1 (what the GameBlocks demo class actually needs): +// static cuboid / y-cylinder / ball colliders, a terrain heightfield sampler +// as the ground authority, planar capsule push-out with wall sliding and +// ground snapping, and raycasts for aiming. Dynamic rigid bodies, wedge +// trimeshes and stacked-shape climbing are the native Rust block follow-up; +// resolution here is planar (walls block, walkable tops carry you), which +// covers arenas, tracks and natural terrain worlds faithfully. +// +// Everything is deterministic: colliders resolve in insertion order, all +// math is plain f64, no wall clock, no Math.random. + +import { Vector3 } from "../../math/vector3.ts"; +import { Quaternion } from "../../math/quaternion.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../math/world-basis.ts"; +import { clamp } from "../math/scalar-utils.ts"; + +/** Anything with a heightAt — every terrain sampler qualifies. */ +export interface TerrainLike { + heightAt(right: number, forward: number): number; +} + +export interface ColliderCommon { + /** Walls block planar motion; non-solid shapes only answer raycasts. */ + solid?: boolean; + /** Walkable shapes contribute their top face to groundHeightAt. */ + walkable?: boolean; + /** Opaque owner tag returned by queries (entity lookup). */ + tag?: unknown; +} + +export interface CuboidDesc extends ColliderCommon { + position: VecLike; + /** Yaw-only orientation is the common case; full quaternions accepted. */ + quaternion?: { x: number; y: number; z: number; w: number }; + halfExtents: VecLike; +} + +export interface CylinderDesc extends ColliderCommon { + position: VecLike; + halfHeight: number; + radius: number; +} + +export interface BallDesc extends ColliderCommon { + position: VecLike; + radius: number; +} + +export type ColliderHandle = number; + +export interface CapsuleOptions { + radius: number; + /** Capsule half height (feet at position - halfHeight along up). */ + halfHeight: number; + /** Max ground-height rise the mover steps onto without being blocked. */ + climb?: number; + /** Snap-down distance when airborne-but-near-ground (0 disables). */ + snap?: number; +} + +export interface CapsuleResult { + position: Vector3; + grounded: boolean; + /** True when a solid collider clipped the planar motion this resolve. */ + hitWall: boolean; +} + +export interface RaycastHit { + distance: number; + point: Vector3; + handle: ColliderHandle; + tag: unknown; +} + +interface Shape { + handle: ColliderHandle; + kind: "cuboid" | "cylinder" | "ball"; + solid: boolean; + walkable: boolean; + tag: unknown; + // basis-space cache (right/up/forward components) + right: number; + up: number; + forward: number; + // cuboid + hRight: number; + hUp: number; + hForward: number; + yaw: number; // planar rotation about up + // cylinder/ball + radius: number; + halfHeight: number; +} + +const EPS = 1e-9; + +export class CollisionWorld { + readonly basis: WorldBasis; + private terrain: TerrainLike | null = null; + private shapes: Shape[] = []; + private nextHandle = 1; + + constructor({ basis = DEFAULT_WORLD_BASIS }: { basis?: WorldBasis } = {}) { + this.basis = basis; + } + + setTerrain(sampler: TerrainLike | null): void { + this.terrain = sampler; + } + + addCuboid(desc: CuboidDesc): ColliderHandle { + const s = this.baseShape("cuboid", desc); + s.hRight = Math.abs(this.basis.rightComponent(desc.halfExtents)); + s.hUp = Math.abs(this.basis.upComponent(desc.halfExtents)); + s.hForward = Math.abs(this.basis.forwardComponent(desc.halfExtents)); + s.yaw = desc.quaternion ? quatToPlanarYaw(desc.quaternion, this.basis) : 0; + this.shapes.push(s); + return s.handle; + } + + addCylinder(desc: CylinderDesc): ColliderHandle { + const s = this.baseShape("cylinder", desc); + s.radius = desc.radius; + s.halfHeight = desc.halfHeight; + this.shapes.push(s); + return s.handle; + } + + addBall(desc: BallDesc): ColliderHandle { + const s = this.baseShape("ball", desc); + s.radius = desc.radius; + s.halfHeight = desc.radius; + this.shapes.push(s); + return s.handle; + } + + remove(handle: ColliderHandle): void { + const i = this.shapes.findIndex((s) => s.handle === handle); + if (i >= 0) this.shapes.splice(i, 1); + } + + clear(): void { + this.shapes.length = 0; + } + + get colliderCount(): number { + return this.shapes.length; + } + + /** Ground authority: terrain height plus any walkable top underfoot. */ + groundHeightAt(right: number, forward: number): number { + let h = this.terrain ? this.terrain.heightAt(right, forward) : 0; + for (const s of this.shapes) { + if (!s.walkable) continue; + if (!this.planarInside(s, right, forward, 0)) continue; + const top = s.up + (s.kind === "cuboid" ? s.hUp : s.halfHeight); + if (top > h) h = top; + } + return h; + } + + /** + * Move a capsule from `current` toward `desired`: solid shapes push the + * planar motion out (slide, insertion order), then the mover grounds on + * groundHeightAt with `climb` step-up and `snap` snap-down semantics. + * `current` is not mutated; the result vector is freshly allocated. + */ + resolveCapsule(current: Vector3, desired: Vector3, opts: CapsuleOptions): CapsuleResult { + const { radius, halfHeight } = opts; + const climb = opts.climb ?? 0.55; + const snap = opts.snap ?? 0.3; + const b = this.basis; + + let right = b.rightComponent(desired); + let forward = b.forwardComponent(desired); + let up = b.upComponent(desired); + const feetOffset = halfHeight; + let hitWall = false; + + // Planar push-out vs solids whose vertical span overlaps the capsule. + for (const s of this.shapes) { + if (!s.solid) continue; + const feet = up - feetOffset; + const head = up + feetOffset; + const sBottom = s.up - (s.kind === "cuboid" ? s.hUp : s.halfHeight); + const sTop = s.up + (s.kind === "cuboid" ? s.hUp : s.halfHeight); + if (head <= sBottom + EPS || feet >= sTop - EPS) continue; + // Walkable shapes we can step onto don't wall us when the rise fits. + if (s.walkable && sTop - feet <= climb) continue; + + if (s.kind === "cuboid") { + const pushed = pushOutOfBox(s, right, forward, radius); + if (pushed) { + right = pushed.right; + forward = pushed.forward; + hitWall = true; + } + } else { + const dr = right - s.right; + const df = forward - s.forward; + const minDist = s.radius + radius; + const distSq = dr * dr + df * df; + if (distSq < minDist * minDist) { + const dist = Math.sqrt(distSq); + const nr = dist > EPS ? dr / dist : 1; + const nf = dist > EPS ? df / dist : 0; + right = s.right + nr * minDist; + forward = s.forward + nf * minDist; + hitWall = true; + } + } + } + + // Ground pass. + const ground = this.groundHeightAt(right, forward); + const feet = up - feetOffset; + let grounded = false; + if (feet <= ground + EPS) { + up = ground + feetOffset; + grounded = true; + } else if (snap > 0 && feet - ground <= snap) { + up = ground + feetOffset; + grounded = true; + } + + const position = b.fromBasisComponents(right, up, forward); + return { position, grounded, hitWall }; + } + + /** + * Nearest hit along a ray (solid and non-solid shapes both report; terrain + * is ray-marched at fixed 0.5-unit steps then bisected — deterministic). + */ + raycast(origin: Vector3, direction: Vector3, maxDistance: number): RaycastHit | null { + const b = this.basis; + const o = { r: b.rightComponent(origin), u: b.upComponent(origin), f: b.forwardComponent(origin) }; + const dir = b.toBasisComponents(direction); + const dLen = Math.sqrt(dir.right ** 2 + dir.up ** 2 + dir.forward ** 2); + if (dLen <= EPS || !(maxDistance > 0)) return null; + const d = { r: dir.right / dLen, u: dir.up / dLen, f: dir.forward / dLen }; + + let best: { t: number; s: Shape | null } | null = null; + + for (const s of this.shapes) { + const t = + s.kind === "cuboid" + ? rayVsBox(o, d, s, maxDistance) + : s.kind === "ball" + ? rayVsSphere(o, d, s.right, s.up, s.forward, s.radius, maxDistance) + : rayVsCylinder(o, d, s, maxDistance); + if (t !== null && (best === null || t < best.t)) best = { t, s }; + } + + // Terrain march. + if (this.terrain) { + const limit = best ? best.t : maxDistance; + const t = rayVsTerrain(o, d, this.terrain, limit); + if (t !== null && (best === null || t < best.t)) best = { t, s: null }; + } + + if (!best) return null; + const point = b.fromBasisComponents(o.r + d.r * best.t, o.u + d.u * best.t, o.f + d.f * best.t); + return { + distance: best.t, + point, + handle: best.s ? best.s.handle : 0, + tag: best.s ? best.s.tag : null, + }; + } + + private baseShape(kind: Shape["kind"], desc: ColliderCommon & { position: VecLike }): Shape { + return { + handle: this.nextHandle++, + kind, + solid: desc.solid ?? true, + walkable: desc.walkable ?? false, + tag: desc.tag ?? null, + right: this.basis.rightComponent(desc.position), + up: this.basis.upComponent(desc.position), + forward: this.basis.forwardComponent(desc.position), + hRight: 0, + hUp: 0, + hForward: 0, + yaw: 0, + radius: 0, + halfHeight: 0, + }; + } + + private planarInside(s: Shape, right: number, forward: number, inflate: number): boolean { + if (s.kind === "cuboid") { + const { lr, lf } = toBoxLocal(s, right, forward); + return Math.abs(lr) <= s.hRight + inflate && Math.abs(lf) <= s.hForward + inflate; + } + const dr = right - s.right; + const df = forward - s.forward; + const rad = s.radius + inflate; + return dr * dr + df * df <= rad * rad; + } +} + +// --------------------------------------------------------------------------- +// Shape math (planar, basis space) +// --------------------------------------------------------------------------- + +function quatToPlanarYaw( + q: { x: number; y: number; z: number; w: number }, + basis: WorldBasis, +): number { + // Rotate the forward axis, read its planar heading. Yaw-only in v1: any + // tilt component is dropped (environments only yaw their wall boxes). + const f = basis.forwardVector(); + const v = new Vector3(f.x, f.y, f.z).applyQuaternion( + new Quaternion(q.x, q.y, q.z, q.w), + ); + return basis.forwardToYaw(v); +} + +function toBoxLocal(s: Shape, right: number, forward: number): { lr: number; lf: number } { + const dr = right - s.right; + const df = forward - s.forward; + const c = Math.cos(-s.yaw); + const sn = Math.sin(-s.yaw); + // planar rotation: +yaw turns forward toward -right (right-hand rule, up axis) + return { lr: c * dr + sn * df, lf: -sn * dr + c * df }; +} + +function fromBoxLocal(s: Shape, lr: number, lf: number): { right: number; forward: number } { + const c = Math.cos(s.yaw); + const sn = Math.sin(s.yaw); + return { right: s.right + c * lr + sn * lf, forward: s.forward + -sn * lr + c * lf }; +} + +function pushOutOfBox( + s: Shape, + right: number, + forward: number, + radius: number, +): { right: number; forward: number } | null { + const { lr, lf } = toBoxLocal(s, right, forward); + // Closest point on the box in local planar space. + const cr = clamp(lr, -s.hRight, s.hRight); + const cf = clamp(lf, -s.hForward, s.hForward); + const dr = lr - cr; + const df = lf - cf; + const distSq = dr * dr + df * df; + if (distSq >= radius * radius) return null; + let outLr: number; + let outLf: number; + if (distSq > EPS * EPS) { + // Outside the box face: push along the separation direction. + const dist = Math.sqrt(distSq); + outLr = cr + (dr / dist) * radius; + outLf = cf + (df / dist) * radius; + } else { + // Center inside the box: exit through the nearest face. + const exitR = s.hRight + radius - Math.abs(lr); + const exitF = s.hForward + radius - Math.abs(lf); + if (exitR <= exitF) { + outLr = (lr >= 0 ? 1 : -1) * (s.hRight + radius); + outLf = lf; + } else { + outLr = lr; + outLf = (lf >= 0 ? 1 : -1) * (s.hForward + radius); + } + } + return fromBoxLocal(s, outLr, outLf); +} + +interface BasisPoint { + r: number; + u: number; + f: number; +} + +function rayVsSphere( + o: BasisPoint, + d: BasisPoint, + cr: number, + cu: number, + cf: number, + radius: number, + maxT: number, +): number | null { + const or = o.r - cr; + const ou = o.u - cu; + const of_ = o.f - cf; + const b = or * d.r + ou * d.u + of_ * d.f; + const c = or * or + ou * ou + of_ * of_ - radius * radius; + const disc = b * b - c; + if (disc < 0) return null; + const t = -b - Math.sqrt(disc); + return t >= 0 && t <= maxT ? t : null; +} + +function rayVsCylinder(o: BasisPoint, d: BasisPoint, s: Shape, maxT: number): number | null { + // Infinite y-cylinder intersection clipped to the shape's vertical span. + const or = o.r - s.right; + const of_ = o.f - s.forward; + const a = d.r * d.r + d.f * d.f; + let tSide: number | null = null; + if (a > EPS) { + const b = or * d.r + of_ * d.f; + const c = or * or + of_ * of_ - s.radius * s.radius; + const disc = b * b - a * c; + if (disc >= 0) { + const t = (-b - Math.sqrt(disc)) / a; + if (t >= 0 && t <= maxT) { + const u = o.u + d.u * t; + if (Math.abs(u - s.up) <= s.halfHeight) tSide = t; + } + } + } + // Caps. + let tCap: number | null = null; + if (Math.abs(d.u) > EPS) { + for (const capU of [s.up + s.halfHeight, s.up - s.halfHeight]) { + const t = (capU - o.u) / d.u; + if (t < 0 || t > maxT) continue; + const rr = o.r + d.r * t - s.right; + const ff = o.f + d.f * t - s.forward; + if (rr * rr + ff * ff <= s.radius * s.radius) { + if (tCap === null || t < tCap) tCap = t; + } + } + } + if (tSide === null) return tCap; + if (tCap === null) return tSide; + return Math.min(tSide, tCap); +} + +function rayVsBox(o: BasisPoint, d: BasisPoint, s: Shape, maxT: number): number | null { + // Rotate the ray into box-local planar space (up axis unchanged), then slab. + const c = Math.cos(-s.yaw); + const sn = Math.sin(-s.yaw); + const olr = c * (o.r - s.right) + sn * (o.f - s.forward); + const olf = -sn * (o.r - s.right) + c * (o.f - s.forward); + const dlr = c * d.r + sn * d.f; + const dlf = -sn * d.r + c * d.f; + const ou = o.u - s.up; + + let tMin = 0; + let tMax = maxT; + for (const [oc, dc, h] of [ + [olr, dlr, s.hRight], + [ou, d.u, s.hUp], + [olf, dlf, s.hForward], + ] as const) { + if (Math.abs(dc) < EPS) { + if (Math.abs(oc) > h) return null; + continue; + } + let t1 = (-h - oc) / dc; + let t2 = (h - oc) / dc; + if (t1 > t2) [t1, t2] = [t2, t1]; + tMin = Math.max(tMin, t1); + tMax = Math.min(tMax, t2); + if (tMin > tMax) return null; + } + return tMin; +} + +function rayVsTerrain(o: BasisPoint, d: BasisPoint, terrain: TerrainLike, maxT: number): number | null { + const STEP = 0.5; + let prevT = 0; + let prevAbove = o.u - terrain.heightAt(o.r, o.f); + if (prevAbove <= 0) return 0; + for (let t = STEP; t <= maxT + EPS; t += STEP) { + const tt = Math.min(t, maxT); + const above = o.u + d.u * tt - terrain.heightAt(o.r + d.r * tt, o.f + d.f * tt); + if (above <= 0) { + // Bisect the crossing for a stable hit point. + let lo = prevT; + let hi = tt; + for (let i = 0; i < 16; i++) { + const mid = (lo + hi) / 2; + const a = o.u + d.u * mid - terrain.heightAt(o.r + d.r * mid, o.f + d.f * mid); + if (a > 0) lo = mid; + else hi = mid; + } + return hi; + } + prevT = tt; + prevAbove = above; + if (tt >= maxT) break; + } + return null; +} diff --git a/playset/modules/user-interface/flight-hud.ts b/playset/modules/user-interface/flight-hud.ts new file mode 100644 index 0000000..a34a746 --- /dev/null +++ b/playset/modules/user-interface/flight-hud.ts @@ -0,0 +1,443 @@ +// playset/modules/user-interface/flight-hud.ts — fighter-cockpit HUD overlay: +// compass heading + cardinal, pitch tape (7 px/degree, roll-rotated), SPD/THR/ +// AOA-ROLL and ALT/AGL/WPN data boxes, status row, PULL UP warning. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/FlightHud.js. The state→presentation +// mapping (renderDashboard field names, cardinal buckets, heat %, pitch-tape +// geometry 620×880 @ 7 px/deg, line widths 210/330) is verbatim and exposed +// as computeFlightHudReadouts(). Deviations, all forced by the DOM→PocketJS +// move: the injected-CSS animations, dashed negative pitch lines, +// waterline/boresight/roll-scale pseudo-element art and vw/vh responsive +// sizing are dropped; box paddings/margins and the status-row placement are +// compacted so the whole cockpit fits a 480×272 screen (the original's vw/vh +// values assumed a browser canvas); rgba() colors are converted to #rrggbbaa; +// `pullUpWarning: null` (original: "keep previous") renders as hidden — a +// reactive tree has no imperative latch. The component accepts either a +// props-accessor or a UiStateModel (via createUiSignal). +// +// Every Text carries a font class (text-xs/text-sm) — on native hosts a text +// node without a compiled style gets no baked font atlas slot and renders +// nothing, so class-less Text is a blank HUD outside the mirror-tree tests. + +import type { JSX as SolidJSX } from "solid-js"; +import { Show, createMemo } from "solid-js"; +import { Text, View, type ViewProps } from "@pocketjs/framework/components"; +import { clamp, toFinite } from "../math/scalar-utils.ts"; +import { HudBar } from "./hud-binder.ts"; +import { UiStateModel, createUiSignal } from "./ui-state-model.ts"; + +type StyleObject = NonNullable; + +// spec/spec.ts ENUMS ordinals (stable wire values; playset avoids a spec dep). +const POS_ABSOLUTE = 1; +const OVERFLOW_HIDDEN = 1; +const FLEX_ROW = 0; +const FLEX_COL = 1; +const ALIGN_CENTER = 1; +const JUSTIFY_BETWEEN = 3; +const DISPLAY_FLEX = 0; +const DISPLAY_NONE = 1; + +// Palette — original CSS rgba() values converted to #rrggbbaa. +const HUD_GREEN = "#7dffb7"; +const HUD_BRIGHT = "#d8ffe8"; +const LABEL_COLOR = "#b7ffd9b8"; // rgba(183,255,217,0.72) +const STATUS_COLOR = "#d8ffe8db"; // rgba(216,255,232,0.86) +const BOX_BG = "#00120a38"; // rgba(0,18,10,0.22) +const BOX_BORDER = "#7dffb752"; // rgba(125,255,183,0.32) +const PITCH_LINE = "#7dffb7b8"; // rgba(125,255,183,0.72) +const PITCH_LINE_ZERO = "#7dffb7eb"; // rgba(125,255,183,0.92) +const PITCH_LABEL = "#b5ffd7db"; // rgba(181,255,215,0.86) +const RETICLE = "#7dffb7e6"; // rgba(125,255,183,0.9) +const WARNING_TEXT = "#ffecec"; +const WARNING_BG = "#9d0a0ac7"; // rgba(157,10,10,0.78) +const WARNING_BORDER = "#ff9b9bad"; // rgba(255,155,155,0.68) + +// Pitch-tape geometry, verbatim from the original markup/CSS. +const PITCH_TAPE_W = 620; +const PITCH_TAPE_H = 880; +const PITCH_CENTER_TOP = 440; +const PITCH_PX_PER_DEGREE = 7; + +export function padNumber(value: number, width: number): string { + return String(Math.abs(Math.round(Number(value) || 0))).padStart(width, "0"); +} + +export function normalizeCompassHeadingDegrees(compassHeadingDegrees = 0): number { + const numericCompassHeading = Number(compassHeadingDegrees); + if (!Number.isFinite(numericCompassHeading)) return 0; + return ((numericCompassHeading % 360) + 360) % 360; +} + +export function cardinalForCompassHeadingDegrees(compassHeadingDegrees = 0): string { + const wrapped = normalizeCompassHeadingDegrees(compassHeadingDegrees); + if (wrapped >= 337.5 || wrapped < 22.5) return "N"; + if (wrapped < 67.5) return "NE"; + if (wrapped < 112.5) return "E"; + if (wrapped < 157.5) return "SE"; + if (wrapped < 202.5) return "S"; + if (wrapped < 247.5) return "SW"; + if (wrapped < 292.5) return "W"; + return "NW"; +} + +/** renderDashboard's input fields — names verbatim from the original. */ +export type FlightHudState = { + regionName: string; + speed: number; + altitude: number; + agl: number; + waveLabel: string; + waveDetail: string; + compassHeadingDegrees: number; + compassHeadingText: string; + timeText: string; + scoreText: string; + throttle: number; + pitchDegrees: number; + rollDegrees: number; + weaponLabel: string; + lockStatus: string; + gunHeat: number; + pullUpWarning: boolean | null; +}; + +/** Derived readouts — keys match the original's data-hud element names. */ +export type FlightHudReadouts = { + compassHeading: string; + cardinal: string; + speed: string; + altitude: string; + agl: string; + throttle: string; + attitude: string; + weapon: string; + region: string; + wave: string; + lock: string; + score: string; + time: string; + throttleRatio: number; + translatePitch: number; + safeRoll: number; + pullUpWarning: boolean; +}; + +// HUD presentation angles are degrees; simulation angles stay radians. +export function computeFlightHudReadouts({ + regionName = "Hold Pattern", + speed = 0, + altitude = 0, + agl = 0, + waveLabel = "FREE", + waveDetail = "", + compassHeadingDegrees = 0, + compassHeadingText = "", + timeText = "", + scoreText = "", + throttle = 0, + pitchDegrees = 0, + rollDegrees = 0, + weaponLabel = "--", + lockStatus = "NONE", + gunHeat = 0, + pullUpWarning = null, +}: Partial = {}): FlightHudReadouts { + const safeCompassHeadingDegrees = normalizeCompassHeadingDegrees( + Number.isFinite(compassHeadingDegrees) + ? compassHeadingDegrees + : Number.parseFloat(compassHeadingText), + ); + const safePitch = ((toFinite(pitchDegrees, 0) % 360) + 360) % 360; + const safeRoll = toFinite(rollDegrees, 0); + const throttleRatio = clamp(toFinite(throttle, 0), 0, 1); + const heatPercent = Math.round(clamp(toFinite(gunHeat, 0), 0, 1) * 100); + + const translatePitch = safePitch < 180 ? safePitch : safePitch - 360; + + return { + compassHeading: padNumber(safeCompassHeadingDegrees, 3), + cardinal: cardinalForCompassHeadingDegrees(safeCompassHeadingDegrees), + speed: padNumber(speed, 3), + altitude: padNumber(altitude, 4), + agl: padNumber(agl, 3), + throttle: `${Math.round(throttleRatio * 100).toString().padStart(3, "0")}%`, + attitude: `${safePitch >= 0 ? "+" : ""}${safePitch.toFixed(1)} / ${safeRoll >= 0 ? "+" : ""}${safeRoll.toFixed(1)}`, + weapon: weaponLabel, + region: regionName, + wave: waveDetail ? `${waveLabel} ${waveDetail}` : waveLabel, + lock: `LOCK ${lockStatus} HEAT ${heatPercent}%`, + score: scoreText, + time: timeText, + throttleRatio, + translatePitch, + safeRoll, + pullUpWarning: Boolean(pullUpWarning), + }; +} + +export type FlightHudSource = + | (() => Partial) + | UiStateModel>; + +export interface FlightHudProps { + /** Accessor of dashboard state, or a UiStateModel bridged via createUiSignal. */ + state: FlightHudSource; + /** setShowHorizonLines equivalent — hides the pitch tape (default true). */ + showHorizonLines?: boolean | (() => boolean); + width?: number; + height?: number; +} + +function resolveStateAccessor(source: FlightHudSource): () => Partial { + if (typeof source === "function") return source; + return createUiSignal(source); +} + +function pitchLine(degrees: number): SolidJSX.Element { + const zero = degrees === 0; + const lineWidth = zero ? 330 : 210; + const labelText = String(Math.abs(degrees)); + const label = (side: "left" | "right"): SolidJSX.Element => + Text({ + class: "text-xs", + style: { + posType: POS_ABSOLUTE, + insetT: -9, + ...(side === "left" ? { insetL: -48 } : { insetR: -48 }), + textColor: PITCH_LABEL, + }, + children: labelText, + }); + return View({ + style: { + posType: POS_ABSOLUTE, + insetL: (PITCH_TAPE_W - lineWidth) / 2, + insetT: PITCH_CENTER_TOP - degrees * PITCH_PX_PER_DEGREE, + width: lineWidth, + height: zero ? 2 : 1, + bgColor: zero ? PITCH_LINE_ZERO : PITCH_LINE, + }, + children: [label("left"), label("right")], + }); +} + +function dataBox(label: string, value: () => string, meter?: SolidJSX.Element): SolidJSX.Element { + return View({ + style: { + width: 136, + flexDir: FLEX_COL, + paddingT: 5, + paddingR: 10, + paddingB: 5, + paddingL: 10, + marginB: 9, + bgColor: BOX_BG, + borderColor: BOX_BORDER, + borderWidth: 1, + }, + children: [ + Text({ class: "text-xs", style: { textColor: LABEL_COLOR }, children: label }), + Text({ + class: "text-sm", + style: { marginT: 2, textColor: HUD_BRIGHT }, + children: value as unknown as SolidJSX.Element, + }), + meter ?? null, + ], + }); +} + +export function FlightHud(props: FlightHudProps): SolidJSX.Element { + const state = resolveStateAccessor(props.state); + const view = createMemo(() => computeFlightHudReadouts(state())); + const width = props.width ?? 480; + const height = props.height ?? 272; + const showHorizon = (): boolean => { + const s = props.showHorizonLines; + return typeof s === "function" ? s() : (s ?? true); + }; + + const horizonW = Math.round(width * 0.72); + const horizonH = Math.round(height * 0.58); + + const pitchLines: SolidJSX.Element[] = []; + for (let degrees = -60; degrees <= 60; degrees += 10) { + pitchLines.push(pitchLine(degrees)); + } + + const centeredColumn = (top: number, children: SolidJSX.Element): SolidJSX.Element => + View({ + style: { + posType: POS_ABSOLUTE, + insetT: top, + insetL: 0, + width, + flexDir: FLEX_COL, + align: ALIGN_CENTER, + }, + children, + }); + + return View({ + debugName: "FlightHud", + style: { width, height, overflow: OVERFLOW_HIDDEN }, + children: [ + // -- compass heading ------------------------------------------------- + centeredColumn(18, [ + Text({ class: "text-xs", style: { textColor: LABEL_COLOR }, children: "HDG" }), + Text({ + debugName: "hud:compassHeading", + class: "text-sm font-bold", + style: { textColor: HUD_BRIGHT }, + children: (() => view().compassHeading) as unknown as SolidJSX.Element, + }), + Text({ + debugName: "hud:cardinal", + class: "text-xs", + style: { textColor: LABEL_COLOR }, + children: (() => view().cardinal) as unknown as SolidJSX.Element, + }), + ] as unknown as SolidJSX.Element), + + // -- horizon window: pitch tape + reticle ------------------------------ + View({ + style: { + posType: POS_ABSOLUTE, + insetL: (width - horizonW) / 2, + insetT: (height - horizonH) / 2, + width: horizonW, + height: horizonH, + overflow: OVERFLOW_HIDDEN, + }, + children: [ + View({ + debugName: "hud:pitchTape", + get style(): StyleObject { + const v = view(); + return { + posType: POS_ABSOLUTE, + insetL: (horizonW - PITCH_TAPE_W) / 2, + insetT: (horizonH - PITCH_TAPE_H) / 2, + width: PITCH_TAPE_W, + height: PITCH_TAPE_H, + rotate: -v.safeRoll, + translateY: v.translatePitch * PITCH_PX_PER_DEGREE, + display: showHorizon() ? DISPLAY_FLEX : DISPLAY_NONE, + }; + }, + children: pitchLines as unknown as SolidJSX.Element, + }), + View({ + // reticle ring + center dot + style: { + posType: POS_ABSOLUTE, + insetL: horizonW / 2 - 74, + insetT: horizonH / 2 - 74, + width: 148, + height: 148, + radius: 74, + borderColor: RETICLE, + borderWidth: 2, + }, + }), + View({ + style: { + posType: POS_ABSOLUTE, + insetL: horizonW / 2 - 3.5, + insetT: horizonH / 2 - 3.5, + width: 7, + height: 7, + radius: 3.5, + bgColor: RETICLE, + }, + }), + ], + }), + + // -- data columns ------------------------------------------------------ + View({ + style: { + posType: POS_ABSOLUTE, + insetL: Math.round(width * 0.07), + insetT: Math.round(height * 0.16), + flexDir: FLEX_COL, + }, + children: [ + dataBox("SPD", () => view().speed), + dataBox( + "THR", + () => view().throttle, + View({ + style: { marginT: 6 }, + children: HudBar({ ratio: () => view().throttleRatio, width: 116, height: 6 }), + }), + ), + dataBox("AOA / ROLL", () => view().attitude), + ], + }), + View({ + style: { + posType: POS_ABSOLUTE, + insetR: Math.round(width * 0.07), + insetT: Math.round(height * 0.16), + flexDir: FLEX_COL, + }, + children: [ + dataBox("ALT", () => view().altitude), + dataBox("AGL", () => view().agl), + dataBox("WPN", () => view().weapon), + ], + }), + + // -- status row -------------------------------------------------------- + View({ + style: { + posType: POS_ABSOLUTE, + insetB: 10, + insetL: Math.round(width * 0.07), + width: Math.round(width * 0.78), + flexDir: FLEX_ROW, + justify: JUSTIFY_BETWEEN, + gap: 8, + }, + children: (["region", "wave", "lock", "score", "time"] as const).map((key) => + Text({ + debugName: `hud:${key}`, + class: "text-xs", + style: { textColor: STATUS_COLOR }, + children: (() => view()[key]) as unknown as SolidJSX.Element, + }), + ) as unknown as SolidJSX.Element, + }), + + // -- PULL UP warning ---------------------------------------------------- + Show({ + get when() { + return view().pullUpWarning; + }, + get children() { + return centeredColumn( + Math.round(height * 0.28), + Text({ + debugName: "hud:warning", + class: "text-sm font-bold", + style: { + paddingT: 9, + paddingR: 18, + paddingB: 9, + paddingL: 18, + textColor: WARNING_TEXT, + bgColor: WARNING_BG, + borderColor: WARNING_BORDER, + borderWidth: 1, + }, + children: "PULL UP", + }), + ); + }, + }) as unknown as SolidJSX.Element, + ], + }); +} diff --git a/playset/modules/user-interface/heading-relative-radar.ts b/playset/modules/user-interface/heading-relative-radar.ts new file mode 100644 index 0000000..f857827 --- /dev/null +++ b/playset/modules/user-interface/heading-relative-radar.ts @@ -0,0 +1,327 @@ +// playset/modules/user-interface/heading-relative-radar.ts — heading-relative +// radar: contacts projected into the player's yaw frame, range-clamped to the +// scope edge, rendered as dot Views inside a fixed square scope. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/HeadingRelativeRadar.js. The projection +// math (radar radius = min(w,h)/2 − 11, ±range planar bounds, yaw-frame +// rotation, range clamp) is verbatim in HeadingRelativeRadarProjection; the +// Canvas2D painting is replaced by a PocketJS View tree: background/ring/ +// crosshair as static Views, contacts as an of translated dot Views +// (Index, not For: rows are positional projections recomputed per update — +// For would tear every dot down each frame). Deviations forced by the move: +// cross/triangle contact shapes and the player arrow collapse to dots (no +// path primitive), contact yaw rotation is dropped with them, and the +// devicePixelRatio canvas-resolution sync is moot for native views. rgba() +// colors converted to #rrggbbaa. + +import type { JSX as SolidJSX } from "solid-js"; +import { Index, Show, createMemo } from "solid-js"; +import { View, type ViewProps } from "@pocketjs/framework/components"; +import { clamp01 } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import { MinimapProjector2D, type Point2D } from "./minimap-projector-2d.ts"; + +type StyleObject = NonNullable; + +// spec/spec.ts ENUMS ordinals (stable wire values; playset avoids a spec dep). +const POS_ABSOLUTE = 1; + +const RADAR_BG = "#060c14db"; // rgba(6,12,20,0.86) +const RADAR_AXES = "#8caad238"; // rgba(140,170,210,0.22) +const RADAR_RING = "#8caad257"; // rgba(140,170,210,0.34) +const RADAR_BORDER = "#7794bc61"; // rgba(119,148,188,0.38) +const PLAYER_DOT_RADIUS = 5.6; // the original arrow's half-width + +export interface Vec3Reading { + x: number; + y: number; + z: number; +} + +export function parseVec3Reading(value: VecLike | null | undefined): Vec3Reading | null { + if (!value) return null; + + const x = Number(value.x); + const y = Number(value.y); + const z = Number(value.z); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null; + return { x, y, z }; +} + +function toCssColor(value: string | number | null | undefined, fallback = "#53fe8e"): string { + if (typeof value === "string" && value.length > 0) return value; + if (Number.isFinite(value)) return `#${(value as number).toString(16).padStart(6, "0")}`; + return fallback; +} + +export interface HeadingRelativeRadarProjectionOptions { + width?: number; + height?: number; + range?: number; + basis?: WorldBasis; +} + +/** The original's scope geometry + projection, minus the canvas. */ +export class HeadingRelativeRadarProjection { + basis: WorldBasis; + projector: MinimapProjector2D; + range!: number; + width!: number; + height!: number; + radarRadius!: number; + radarCenterX!: number; + radarCenterY!: number; + radarOriginX!: number; + radarOriginY!: number; + + constructor({ + width = 250, + height = 200, + range = 20, + basis = DEFAULT_WORLD_BASIS, + }: HeadingRelativeRadarProjectionOptions = {}) { + this.basis = basis; + this.projector = new MinimapProjector2D({ + planarBounds: { + minRight: -range, + maxRight: range, + minForward: -range, + maxForward: range, + }, + width, + height, + }); + this.setRange(range); + this.setSize(width, height); + } + + setRange(range: number): void { + this.range = Math.max(0.5, range); + this.projector.setPlanarBounds(-this.range, this.range, -this.range, this.range); + } + + setBasis(basis: WorldBasis = DEFAULT_WORLD_BASIS): this { + this.basis = basis; + return this; + } + + setSize(width: number, height: number): void { + this.width = Math.max(1, Math.floor(width)); + this.height = Math.max(1, Math.floor(height)); + this.radarRadius = Math.max(1, Math.min(this.width, this.height) * 0.5 - 11); + this.radarCenterX = this.width * 0.5; + this.radarCenterY = this.height * 0.5; + this.radarOriginX = this.radarCenterX - this.radarRadius; + this.radarOriginY = this.radarCenterY - this.radarRadius; + this.projector.setViewport(this.radarRadius * 2, this.radarRadius * 2, 0); + } + + yawFromForward(forward: VecLike | null | undefined): number { + return this.basis.forwardToYaw(forward); + } + + projectRelativePoint(localRight: number, localForward: number, out: Point2D = { x: 0, y: 0 }): Point2D { + const distance = Math.hypot(localRight, localForward); + let clampedRight = localRight; + let clampedForward = localForward; + + if (distance > this.range) { + const scale = this.range / distance; + clampedRight *= scale; + clampedForward *= scale; + } + + this.projector.projectPlanar(clampedRight, clampedForward, out); + out.x += this.radarOriginX; + out.y += this.radarOriginY; + return out; + } + + projectContact( + position: VecLike, + playerPosition: VecLike, + playerYaw: number, + out: Point2D = { x: 0, y: 0 }, + ): Point2D { + const delta = this.basis.planarDelta(position, playerPosition); + const dRight = delta.right; + const dForward = delta.forward; + const cos = Math.cos(playerYaw); + const sin = Math.sin(playerYaw); + return this.projectRelativePoint( + cos * dRight + sin * dForward, + -sin * dRight + cos * dForward, + out, + ); + } +} + +export interface RadarContact extends Record { + position?: VecLike | null; + x?: number; + y?: number; + z?: number; + color?: string | number; + opacity?: number; + size?: number; + yaw?: number; + shape?: string; +} + +export interface HeadingRelativeRadarProps { + playerPosition: () => VecLike | null | undefined; + /** Omitted entirely, or returning null ⇒ basis forward (original semantics). */ + playerForward?: () => VecLike | null | undefined; + contacts?: () => RadarContact[]; + width?: number; + height?: number; + range?: number | (() => number); + playerColor?: string | number; + contactColor?: string | number; + contactOpacity?: number; + basis?: WorldBasis; +} + +interface ProjectedContact { + x: number; + y: number; + size: number; + color: string; + opacity: number; +} + +export function HeadingRelativeRadar(props: HeadingRelativeRadarProps): SolidJSX.Element { + const width = Math.max(1, Math.floor(props.width ?? 250)); + const height = Math.max(1, Math.floor(props.height ?? 200)); + const basis = props.basis ?? DEFAULT_WORLD_BASIS; + const playerColor = toCssColor(props.playerColor ?? 0x53fe8e); + const contactColor = toCssColor(props.contactColor ?? 0xff4444, "#ff4444"); + const contactOpacity = clamp01(props.contactOpacity ?? 0.85); + const range = (): number => + typeof props.range === "function" ? props.range() : (props.range ?? 20); + + const projection = createMemo( + () => new HeadingRelativeRadarProjection({ width, height, range: range(), basis }), + ); + // Scope geometry depends only on the (static) width/height. + const { radarRadius, radarCenterX, radarCenterY, radarOriginX, radarOriginY } = projection(); + + const player = createMemo<{ position: Vec3Reading; yaw: number } | null>(() => { + const position = parseVec3Reading(props.playerPosition()); + if (!position) return null; + + const forwardReading = props.playerForward ? (props.playerForward() ?? null) : null; + const forward = forwardReading === null ? basis.forwardVector() : parseVec3Reading(forwardReading); + if (!forward) return null; + return { position, yaw: projection().yawFromForward(forward) }; + }); + + const dots = createMemo(() => { + const current = player(); + if (!current) return []; + const proj = projection(); + const out: ProjectedContact[] = []; + for (const contact of props.contacts?.() ?? []) { + const contactPosition = parseVec3Reading(contact?.position ?? contact); + if (!contactPosition) continue; + + const point = proj.projectContact(contactPosition, current.position, current.yaw, { + x: 0, + y: 0, + }); + out.push({ + x: point.x, + y: point.y, + size: Math.max(2, Number(contact.size) || 4.2), + color: toCssColor(contact.color, contactColor), + opacity: clamp01(Number(contact.opacity ?? contactOpacity) || 0), + }); + } + return out; + }); + + const dot = (style: StyleObject): SolidJSX.Element => View({ style }); + + return View({ + debugName: "HeadingRelativeRadar", + style: { + width, + height, + bgColor: RADAR_BG, + borderColor: RADAR_BORDER, + borderWidth: 1, + }, + children: [ + // crosshair axes + dot({ + posType: POS_ABSOLUTE, + insetL: radarCenterX - 0.5, + insetT: radarOriginY, + width: 1, + height: radarRadius * 2, + bgColor: RADAR_AXES, + }), + dot({ + posType: POS_ABSOLUTE, + insetL: radarOriginX, + insetT: radarCenterY - 0.5, + width: radarRadius * 2, + height: 1, + bgColor: RADAR_AXES, + }), + // range ring + dot({ + posType: POS_ABSOLUTE, + insetL: radarOriginX, + insetT: radarOriginY, + width: radarRadius * 2, + height: radarRadius * 2, + radius: radarRadius, + borderColor: RADAR_RING, + borderWidth: 1, + }), + // contacts + Index({ + get each() { + return dots(); + }, + children: (item: () => ProjectedContact) => + View({ + get style(): StyleObject { + const d = item(); + return { + posType: POS_ABSOLUTE, + insetL: 0, + insetT: 0, + width: d.size * 2, + height: d.size * 2, + radius: d.size, + bgColor: d.color, + opacity: d.opacity, + translateX: d.x - d.size, + translateY: d.y - d.size, + }; + }, + }), + }) as unknown as SolidJSX.Element, + // player marker — projectRelativePoint(0,0) is always the scope center + Show({ + get when() { + return player() !== null; + }, + get children() { + return dot({ + posType: POS_ABSOLUTE, + insetL: radarCenterX - PLAYER_DOT_RADIUS, + insetT: radarCenterY - PLAYER_DOT_RADIUS, + width: PLAYER_DOT_RADIUS * 2, + height: PLAYER_DOT_RADIUS * 2, + radius: PLAYER_DOT_RADIUS, + bgColor: playerColor, + }); + }, + }) as unknown as SolidJSX.Element, + ], + }); +} diff --git a/playset/modules/user-interface/hud-binder.ts b/playset/modules/user-interface/hud-binder.ts new file mode 100644 index 0000000..b130a17 --- /dev/null +++ b/playset/modules/user-interface/hud-binder.ts @@ -0,0 +1,84 @@ +// playset/modules/user-interface/hud-binder.ts — tiny reactive HUD bindings: +// HudValue (a Text bound to an accessor) and HudBar (a fill View whose width +// tracks a 0..1 accessor). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/DomHudRenderer.js. The original walked DOM +// selectors and re-rendered on UiStateModel changedKeys; under PocketJS Solid +// reactivity IS the binder, so DomHudRenderer is superseded and deliberately +// NOT exported: bindText → HudValue, bindStyleWidth → HudBar (+ hudBarRatio, +// the original's current/max→0..1 math), bindClassToggle/bindAttribute → +// plain Solid expressions at the call site. DEFAULT_FORMATTER is verbatim. + +import type { JSX as SolidJSX } from "solid-js"; +import { Text, View, type ViewProps } from "@pocketjs/framework/components"; +import { clamp01 } from "../math/scalar-utils.ts"; + +type StyleObject = NonNullable; + +export const DEFAULT_FORMATTER = (value: unknown): string => String(value ?? ""); + +/** bindStyleWidth's ratio rule, verbatim: non-positive max ⇒ 0. */ +export function hudBarRatio(current: number, max: number): number { + const safeCurrent = Number(current ?? 0); + const safeMax = Number(max ?? 1); + return safeMax <= 0 ? 0 : clamp01(safeCurrent / safeMax); +} + +export interface HudValueProps { + /** Reactive source — a Solid accessor (or createUiSignal(...) selector). */ + value: () => unknown; + format?: (value: unknown) => string; + class?: string; + style?: StyleObject; + debugName?: string; +} + +export function HudValue(props: HudValueProps): SolidJSX.Element { + return Text({ + class: props.class, + style: props.style, + debugName: props.debugName ?? "HudValue", + children: (() => + (props.format ?? DEFAULT_FORMATTER)(props.value())) as unknown as SolidJSX.Element, + }); +} + +const DEFAULT_TRACK_COLOR = "#7dffb729"; // rgba(125,255,183,0.16) +const DEFAULT_FILL_COLOR = "#7dffb7e6"; // rgba(125,255,183,0.9) + +export interface HudBarProps { + /** Fill fraction accessor; clamped to 0..1 (pair with hudBarRatio). */ + ratio: () => number; + /** Track width in px — PocketJS has no percent widths beyond w-full. */ + width: number; + height?: number; + trackColor?: string; + fillColor?: string; + class?: string; + style?: StyleObject; + debugName?: string; +} + +export function HudBar(props: HudBarProps): SolidJSX.Element { + const height = props.height ?? 6; + return View({ + class: props.class, + debugName: props.debugName ?? "HudBar", + style: { + width: props.width, + height, + bgColor: props.trackColor ?? DEFAULT_TRACK_COLOR, + ...(props.style ?? {}), + }, + children: View({ + get style(): StyleObject { + return { + width: Math.round(clamp01(props.ratio()) * props.width), + height, + bgColor: props.fillColor ?? DEFAULT_FILL_COLOR, + }; + }, + }), + }); +} diff --git a/playset/modules/user-interface/minimap-projector-2d.ts b/playset/modules/user-interface/minimap-projector-2d.ts new file mode 100644 index 0000000..1a21060 --- /dev/null +++ b/playset/modules/user-interface/minimap-projector-2d.ts @@ -0,0 +1,167 @@ +// playset/modules/user-interface/minimap-projector-2d.ts — pure world→minimap +// projection: planar bounds to viewport pixels, yaw to map rotation. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/MinimapProjector2D.js. Verbatim semantics. + +import { clamp01 } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; + +export interface Point2D { + x: number; + y: number; +} + +export interface PlanarBounds { + minRight: number; + maxRight: number; + minForward: number; + maxForward: number; +} + +export type PositiveForwardDirection = "down" | "up"; + +export function projectRelativePlanar( + right: number, + forward: number, + originRight = 0, + originForward = 0, + range = 1, + width = 1, + height = 1, + positiveForwardDirection: PositiveForwardDirection = "down", + out: Point2D = { x: 0, y: 0 }, +): Point2D { + const safeRange = Math.max(1e-6, range); + const halfWidth = Math.max(0, width) * 0.5; + const halfHeight = Math.max(0, height) * 0.5; + const forwardSign = positiveForwardDirection === "up" ? -1 : 1; + + out.x = halfWidth + ((right - originRight) / safeRange) * halfWidth; + out.y = halfHeight + forwardSign * ((forward - originForward) / safeRange) * halfHeight; + return out; +} + +export interface MinimapProjector2DOptions { + planarBounds: PlanarBounds; + width?: number; + height?: number; + padding?: number; + invertRight?: boolean; + invertForward?: boolean; + basis?: WorldBasis; +} + +export interface OrthoFrustum { + left: number; + right: number; + top: number; + bottom: number; +} + +export class MinimapProjector2D { + planarBounds: PlanarBounds; + width: number; + height: number; + padding: number; + invertRight: boolean; + invertForward: boolean; + basis: WorldBasis; + + constructor({ + planarBounds: { minRight, maxRight, minForward, maxForward }, + width = 200, + height = 200, + padding = 0, + invertRight = false, + invertForward = false, + basis = DEFAULT_WORLD_BASIS, + }: MinimapProjector2DOptions) { + this.planarBounds = { minRight, maxRight, minForward, maxForward }; + this.width = Math.max(1, Math.floor(width)); + this.height = Math.max(1, Math.floor(height)); + this.padding = Math.max(0, padding); + this.invertRight = Boolean(invertRight); + this.invertForward = Boolean(invertForward); + this.basis = basis; + } + + setPlanarBounds(minRight: number, maxRight: number, minForward: number, maxForward: number): this { + this.planarBounds.minRight = minRight; + this.planarBounds.maxRight = maxRight; + this.planarBounds.minForward = minForward; + this.planarBounds.maxForward = maxForward; + return this; + } + + setPlanarBoundsFromCenterSize( + centerRight: number, + centerForward: number, + spanRight: number, + spanForward: number, + ): this { + this.planarBounds.minRight = centerRight - spanRight * 0.5; + this.planarBounds.maxRight = centerRight + spanRight * 0.5; + this.planarBounds.minForward = centerForward - spanForward * 0.5; + this.planarBounds.maxForward = centerForward + spanForward * 0.5; + return this; + } + + setViewport(width: number = this.width, height: number = this.height, padding: number = this.padding): this { + this.width = Math.max(1, Math.floor(width)); + this.height = Math.max(1, Math.floor(height)); + this.padding = Math.max(0, padding); + return this; + } + + setInvert(invertRight: boolean = this.invertRight, invertForward: boolean = this.invertForward): this { + this.invertRight = Boolean(invertRight); + this.invertForward = Boolean(invertForward); + return this; + } + + setBasis(basis: WorldBasis = DEFAULT_WORLD_BASIS): this { + this.basis = basis; + return this; + } + + projectPlanar(right: number, forward: number, out: Point2D = { x: 0, y: 0 }): Point2D { + const rangeRight = Math.max(1e-6, this.planarBounds.maxRight - this.planarBounds.minRight); + const rangeForward = Math.max(1e-6, this.planarBounds.maxForward - this.planarBounds.minForward); + const normalizedRight = clamp01((right - this.planarBounds.minRight) / rangeRight); + const normalizedForward = clamp01((forward - this.planarBounds.minForward) / rangeForward); + const drawableWidth = Math.max(0, this.width - this.padding * 2); + const drawableHeight = Math.max(0, this.height - this.padding * 2); + + out.x = this.padding + (this.invertRight ? 1 - normalizedRight : normalizedRight) * drawableWidth; + out.y = this.padding + (this.invertForward ? normalizedForward : 1 - normalizedForward) * drawableHeight; + return out; + } + + project(worldPosition: VecLike | null | undefined, out: Point2D = { x: 0, y: 0 }): Point2D { + const planar = this.basis.toPlanar(worldPosition); + return this.projectPlanar(planar.right, planar.forward, out); + } + + projectYaw(forwardVector: VecLike | null | undefined): number { + const planar = this.basis.toPlanar(forwardVector); + const right = planar.right; + const forward = planar.forward; + const mapDx = (this.invertRight ? -1 : 1) * right; + const mapDy = (this.invertForward ? 1 : -1) * forward; + return Math.atan2(mapDx, -mapDy); + } + + projectPath(path: (VecLike | null | undefined)[] = []): Point2D[] { + return path.map((point) => this.project(point, { x: 0, y: 0 })); + } + + getOrthoFrustumFromBounds(): OrthoFrustum { + return { + left: this.planarBounds.minRight, + right: this.planarBounds.maxRight, + top: this.planarBounds.maxForward, + bottom: this.planarBounds.minForward, + }; + } +} diff --git a/playset/modules/user-interface/notification-queue.ts b/playset/modules/user-interface/notification-queue.ts new file mode 100644 index 0000000..ed3dae0 --- /dev/null +++ b/playset/modules/user-interface/notification-queue.ts @@ -0,0 +1,266 @@ +// playset/modules/user-interface/notification-queue.ts — capped visible-toast +// queue (pending promotion, sticky, per-item lifetimes) plus an expiring +// message feed, both driven by an injected Clock. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/NotificationQueue.js. Verbatim semantics. + +import { DEFAULT_CLOCK, type Clock } from "../math/time-utils.ts"; + +export interface NotificationItem extends Record { + id: number | string; + content: unknown; + type: string; + sticky: boolean; + lifetimeMs: number; + createdAt: number; + shownAt: number; + expiresAt: number; +} + +export type NotificationListener = (visible: NotificationItem[]) => void; + +function cloneItem>(item: T): T { + return { ...item }; +} + +export class NotificationQueue { + clock: Clock; + maxVisible: number; + defaultLifetimeMs: number; + idPrefix: string | null; + currentTimeMs: number; + pending: NotificationItem[]; + visible: NotificationItem[]; + listeners: Set; + nextId: number; + + constructor( + maxVisible = 3, + defaultLifetimeMs = 1500, + idPrefix: string | null = null, + clock: Clock = DEFAULT_CLOCK, + ) { + this.clock = clock; + this.maxVisible = Math.max(1, Math.floor(maxVisible)); + this.defaultLifetimeMs = Math.max(0, defaultLifetimeMs); + this.idPrefix = idPrefix; + this.currentTimeMs = this.readNow(); + this.pending = []; + this.visible = []; + this.listeners = new Set(); + this.nextId = 1; + } + + readNow(): number { + return this.clock.now(); + } + + setTime(nowMs: number = this.readNow()): number { + this.currentTimeMs = nowMs; + return this.currentTimeMs; + } + + syncClock(): number { + return this.setTime(this.readNow()); + } + + subscribe(listener: NotificationListener, emitInitial = false): () => boolean { + if (typeof listener !== "function") { + throw new Error("NotificationQueue.subscribe: listener must be a function"); + } + + this.listeners.add(listener); + if (emitInitial) listener(this.getVisible()); + return () => this.listeners.delete(listener); + } + + _emit(): void { + const visible = this.getVisible(); + for (const listener of this.listeners) listener(visible); + } + + _createId(): number | string { + const id = this.idPrefix == null ? this.nextId : `${this.idPrefix}${this.nextId}`; + this.nextId += 1; + return id; + } + + _promote(): void { + while (this.visible.length < this.maxVisible && this.pending.length > 0) { + const item = this.pending.shift() as NotificationItem; + item.shownAt = this.currentTimeMs; + item.expiresAt = item.sticky ? 0 : this.currentTimeMs + item.lifetimeMs; + this.visible.push(item); + } + } + + add( + content: unknown, + type = "info", + lifetimeMs: number = this.defaultLifetimeMs, + sticky = false, + extra: Record = {}, + ): NotificationItem { + const item = { + id: this._createId(), + content, + type, + sticky: Boolean(sticky), + lifetimeMs: lifetimeMs, + createdAt: this.currentTimeMs, + shownAt: 0, + expiresAt: 0, + ...extra, + } as NotificationItem; + + this.pending.push(item); + this._promote(); + this._emit(); + return cloneItem(item); + } + + remove(id: number | string): boolean { + const visibleIndex = this.visible.findIndex((item) => item.id === id); + if (visibleIndex >= 0) { + this.visible.splice(visibleIndex, 1); + this._promote(); + this._emit(); + return true; + } + + const pendingIndex = this.pending.findIndex((item) => item.id === id); + if (pendingIndex >= 0) { + this.pending.splice(pendingIndex, 1); + this._emit(); + return true; + } + + return false; + } + + clear(): void { + this.pending = []; + this.visible = []; + this._emit(); + } + + expire(): NotificationItem[] { + this.visible = this.visible.filter((item) => { + if (item.sticky) return true; + return item.expiresAt > this.currentTimeMs; + }); + this._promote(); + this._emit(); + return this.getVisible(); + } + + tick(deltaMs = 0): NotificationItem[] { + const safeDelta = Math.max(0, deltaMs); + this.currentTimeMs += safeDelta; + return this.expire(); + } + + tickAt(nowMs: number = this.readNow()): NotificationItem[] { + this.setTime(nowMs); + return this.expire(); + } + + getVisible(): NotificationItem[] { + return this.visible.map(cloneItem); + } + + getPending(): NotificationItem[] { + return this.pending.map(cloneItem); + } + + dispose(): void { + this.clear(); + this.listeners.clear(); + } +} + +export interface FeedMessage extends Record { + messageId: string; + text: unknown; + kind: string; + atMs: number; + expiresAtMs: number; +} + +export type FeedListener = (messages: FeedMessage[]) => void; + +export class ExpiringMessageFeed { + clock: Clock; + lingerMs: number; + maxEntries: number; + idPrefix: string; + sequence: number; + messages: FeedMessage[]; + listeners: Set; + + constructor(lingerMs = 3000, maxEntries = 6, idPrefix = "message-", clock: Clock = DEFAULT_CLOCK) { + this.clock = clock; + this.lingerMs = lingerMs; + this.maxEntries = maxEntries; + this.idPrefix = idPrefix; + this.sequence = 0; + this.messages = []; + this.listeners = new Set(); + } + + subscribe(listener: FeedListener): () => boolean { + if (typeof listener !== "function") { + throw new Error("ExpiringMessageFeed.subscribe: listener must be a function"); + } + + this.listeners.add(listener); + listener(this.snapshot()); + return () => this.listeners.delete(listener); + } + + _notify(): void { + const snapshot = this.snapshot(); + for (const listener of this.listeners) { + listener(snapshot); + } + } + + push( + text: unknown, + kind = "info", + atMs: number = this.clock.now(), + lingerMs: number = this.lingerMs, + extra: Record = {}, + ): FeedMessage { + this.sequence += 1; + const message = { + messageId: `${this.idPrefix}${this.sequence}`, + text, + kind, + atMs, + expiresAtMs: atMs + lingerMs, + ...extra, + } as FeedMessage; + + this.messages.unshift(message); + this.messages = this.messages.slice(0, this.maxEntries); + this._notify(); + return cloneItem(message); + } + + tick(nowMs: number = this.clock.now()): FeedMessage[] { + this.messages = this.messages.filter((message) => nowMs < message.expiresAtMs); + this._notify(); + return this.snapshot(); + } + + clear(): void { + this.messages = []; + this._notify(); + } + + snapshot(): FeedMessage[] { + return this.messages.map(cloneItem); + } +} diff --git a/playset/modules/user-interface/race-minimap.ts b/playset/modules/user-interface/race-minimap.ts new file mode 100644 index 0000000..7fbc1a7 --- /dev/null +++ b/playset/modules/user-interface/race-minimap.ts @@ -0,0 +1,251 @@ +// playset/modules/user-interface/race-minimap.ts — race overview minimap: +// checkpoints, AI competitors (+ leader ring) and the local vehicle projected +// through MinimapProjector2D onto a fixed rectangle of dot Views. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/RaceMinimap.js. The projection (inherited +// MinimapProjector2D), dot radii (checkpoint 2.1 / next 3.4 / ai 2.8 / leader +// ring 4.8), style keys and next-checkpoint modulo rule are verbatim; the +// Canvas2D class becomes a PocketJS component over an internal projector. +// Deviations forced by the move: the track POLYLINE is skipped in v1 (no line +// primitive — checkpoint dots still trace the circuit), the local-vehicle +// triangle collapses to a stroked dot that still carries projectYaw as a +// rotate (degrees), canvas pixelRatio/syncResolution is moot for native +// views, and rgba() style strings are converted to #rrggbbaa. Rows render via +// (not For): they are positional projections recomputed per update — +// For would tear every dot down each frame. `basis` is exposed as a prop +// (the original hardcoded the default basis in its super() call). + +import type { JSX as SolidJSX } from "solid-js"; +import { Index, Show, createMemo } from "solid-js"; +import { View, type ViewProps } from "@pocketjs/framework/components"; +import { toDeg } from "../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "../math/world-basis.ts"; +import { MinimapProjector2D, type PlanarBounds } from "./minimap-projector-2d.ts"; + +type StyleObject = NonNullable; + +// spec/spec.ts ENUMS ordinal (stable wire value; playset avoids a spec dep). +const POS_ABSOLUTE = 1; + +export const DEFAULT_STYLES = Object.freeze({ + background: "#060a10e6", // rgba(6,10,16,0.9) + border: "#8099bf9e", // rgba(128,153,191,0.62) + track: "#71b9ffb8", // rgba(113,185,255,0.72) — unused until the v1 polyline gap closes + checkpoint: "#ccdfffb3", // rgba(204,223,255,0.7) + nextCheckpoint: "#ffe88a", + localFill: "#f16a45", + localStroke: "#fff0db", + leaderRing: "#ffe88a", +}); + +export type RaceMinimapStyles = { -readonly [K in keyof typeof DEFAULT_STYLES]: string }; + +function toCssColor(value: string | number | null | undefined, fallback = "#8ab4d8"): string { + if (typeof value === "string" && value.length > 0) { + return value; + } + if (Number.isFinite(value)) { + return `#${(value as number).toString(16).padStart(6, "0")}`; + } + return fallback; +} + +export interface LocalVehicle { + position?: VecLike | null; + bodyFrame?: { forward?: VecLike | null } | null; +} + +export interface RaceProgress { + nextCheckpointIndex: number; +} + +export interface AiCar extends Record { + id?: unknown; + position?: VecLike | null; + motion?: { position?: VecLike | null } | null; + color?: string | number; +} + +export interface RaceMinimapProps { + planarBounds: PlanarBounds; + width?: number; + height?: number; + padding?: number; + invertRight?: boolean; + invertForward?: boolean; + basis?: WorldBasis; + styles?: Partial; + checkpoints?: () => (VecLike | null | undefined)[]; + localVehicle?: () => LocalVehicle | null | undefined; + localProgress?: () => RaceProgress | null | undefined; + aiCars?: () => AiCar[]; + aiLeaderId?: () => unknown; +} + +interface ProjectedCheckpoint { + x: number; + y: number; + radius: number; + color: string; +} + +interface ProjectedCar { + x: number; + y: number; + color: string; + leader: boolean; +} + +const LOCAL_MARKER_RADIUS = 4.5; // the original triangle's half-width + +export function RaceMinimap(props: RaceMinimapProps): SolidJSX.Element { + const styles: RaceMinimapStyles = { ...DEFAULT_STYLES, ...(props.styles ?? {}) }; + const basis = props.basis ?? DEFAULT_WORLD_BASIS; + const projector = new MinimapProjector2D({ + planarBounds: { ...props.planarBounds }, + width: props.width ?? 200, + height: props.height ?? 200, + padding: props.padding ?? 0, + invertRight: props.invertRight ?? false, + invertForward: props.invertForward ?? false, + basis, + }); + + const checkpointDots = createMemo(() => { + const checkpoints = props.checkpoints?.() ?? []; + const progress = props.localProgress?.() ?? null; + const nextCheckpointIndex = progress + ? progress.nextCheckpointIndex % checkpoints.length + : -1; + const out: ProjectedCheckpoint[] = []; + for (let i = 0; i < checkpoints.length; i += 1) { + const point = projector.project(checkpoints[i], { x: 0, y: 0 }); + const isNext = i === nextCheckpointIndex; + out.push({ + x: point.x, + y: point.y, + radius: isNext ? 3.4 : 2.1, + color: isNext ? styles.nextCheckpoint : styles.checkpoint, + }); + } + return out; + }); + + const carDots = createMemo(() => { + const leaderId = props.aiLeaderId?.(); + const out: ProjectedCar[] = []; + for (const aiCar of props.aiCars?.() ?? []) { + const position = aiCar?.position ?? aiCar?.motion?.position ?? null; + if (!position) continue; + + const point = projector.project(position, { x: 0, y: 0 }); + out.push({ + x: point.x, + y: point.y, + color: toCssColor(aiCar?.color), + leader: aiCar?.id === leaderId && leaderId != null, + }); + } + return out; + }); + + const local = createMemo<{ x: number; y: number; rotate: number } | null>(() => { + const vehicle = props.localVehicle?.() ?? null; + const localPosition = vehicle?.position; + if (!localPosition) return null; + + const point = projector.project(localPosition, { x: 0, y: 0 }); + const yaw = projector.projectYaw(vehicle?.bodyFrame?.forward ?? basis.forwardVector()); + return { x: point.x, y: point.y, rotate: toDeg(yaw) }; + }); + + const dotStyle = (x: number, y: number, radius: number, rest: StyleObject): StyleObject => ({ + posType: POS_ABSOLUTE, + insetL: 0, + insetT: 0, + width: radius * 2, + height: radius * 2, + radius, + translateX: x - radius, + translateY: y - radius, + ...rest, + }); + + return View({ + debugName: "RaceMinimap", + style: { + width: projector.width, + height: projector.height, + bgColor: styles.background, + borderColor: styles.border, + borderWidth: 1, + }, + children: [ + // checkpoints (the v1 track line is these dots' circuit) + Index({ + get each() { + return checkpointDots(); + }, + children: (item: () => ProjectedCheckpoint) => + View({ + get style(): StyleObject { + const d = item(); + return dotStyle(d.x, d.y, d.radius, { bgColor: d.color }); + }, + }), + }) as unknown as SolidJSX.Element, + // AI competitors + leader ring + Index({ + get each() { + return carDots(); + }, + children: (item: () => ProjectedCar) => + [ + View({ + get style(): StyleObject { + const d = item(); + return dotStyle(d.x, d.y, 2.8, { bgColor: d.color }); + }, + }), + Show({ + get when() { + return item().leader; + }, + get children() { + return View({ + get style(): StyleObject { + const d = item(); + return dotStyle(d.x, d.y, 4.8, { + borderColor: styles.leaderRing, + borderWidth: 1, + }); + }, + }); + }, + }), + ] as unknown as SolidJSX.Element, + }) as unknown as SolidJSX.Element, + // local vehicle — triangle collapsed to a stroked dot, yaw kept as rotate + Show({ + get when() { + return local() !== null; + }, + get children() { + return View({ + get style(): StyleObject { + const d = local(); + if (!d) return {}; + return dotStyle(d.x, d.y, LOCAL_MARKER_RADIUS, { + bgColor: styles.localFill, + borderColor: styles.localStroke, + borderWidth: 1, + rotate: d.rotate, + }); + }, + }); + }, + }) as unknown as SolidJSX.Element, + ], + }); +} diff --git a/playset/modules/user-interface/storage-settings-store.ts b/playset/modules/user-interface/storage-settings-store.ts new file mode 100644 index 0000000..a392408 --- /dev/null +++ b/playset/modules/user-interface/storage-settings-store.ts @@ -0,0 +1,121 @@ +// playset/modules/user-interface/storage-settings-store.ts — typed key/value +// settings persistence: raw readers/writers plus a JSON settings store. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/StorageSettingsStore.js. ONE deliberate +// deviation: the original resolved `window.localStorage` (or null) — a DOM +// dependency PocketJS guests don't have. Here the backend is an injectable +// {getItem/setItem/removeItem} interface and `resolveStorage` falls back to a +// shared in-memory Map backend, so settings survive within a session by +// default. Hosts can inject a persistent backend (e.g. via the effect shell's +// storage capability) later without touching callers. All parsing/merging +// semantics are verbatim. + +export interface StorageBackend { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export class MemoryStorageBackend implements StorageBackend { + private items = new Map(); + + getItem(key: string): string | null { + return this.items.has(key) ? (this.items.get(key) as string) : null; + } + + setItem(key: string, value: string): void { + this.items.set(key, String(value)); + } + + removeItem(key: string): void { + this.items.delete(key); + } +} + +/** Shared default backend — plays the role window.localStorage did. */ +export const DEFAULT_MEMORY_STORAGE = new MemoryStorageBackend(); + +export function resolveStorage(storage: StorageBackend | null = null): StorageBackend { + if (storage) return storage; + return DEFAULT_MEMORY_STORAGE; +} + +export function readStorageItem(storage: StorageBackend | null, key: string): string | null { + if (!storage || !key) return null; + try { + return storage.getItem(key); + } catch { + return null; + } +} + +export function writeStorageItem(storage: StorageBackend | null, key: string, value: string): boolean { + if (!storage || !key) return false; + try { + storage.setItem(key, value); + return true; + } catch { + return false; + } +} + +export function readBoolean(raw: string | null | undefined, fallback = false): boolean { + if (raw == null) return fallback; + if (raw === "true" || raw === "1" || raw === "on") return true; + if (raw === "false" || raw === "0" || raw === "off") return false; + return fallback; +} + +export function readInteger(raw: string | null | undefined, fallback = 0): number { + const parsed = Number.parseInt(raw ?? "", 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function readJsonStorageItem(storage: StorageBackend | null, key: string, fallback: T | null = null): T | null { + const raw = readStorageItem(storage, key); + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export class JsonSettingsStore = Record> { + storage: StorageBackend; + storageKey: string; + defaults: T; + settings: T; + + constructor(storage: StorageBackend | null = null, storageKey = "", defaults: T = {} as T) { + this.storage = resolveStorage(storage); + this.storageKey = storageKey; + this.defaults = { ...defaults }; + this.settings = { ...defaults }; + } + + load(): T { + const saved = readJsonStorageItem>(this.storage, this.storageKey, null); + if (saved && typeof saved === "object") { + this.settings = { + ...this.settings, + ...saved, + }; + } + return this.settings; + } + + save(): T { + writeStorageItem(this.storage, this.storageKey, JSON.stringify(this.settings)); + return this.settings; + } + + update(nextSettings: Partial = {}): T { + this.settings = { + ...this.settings, + ...nextSettings, + }; + return this.settings; + } +} diff --git a/playset/modules/user-interface/ui-state-model.ts b/playset/modules/user-interface/ui-state-model.ts new file mode 100644 index 0000000..bc3d5e9 --- /dev/null +++ b/playset/modules/user-interface/ui-state-model.ts @@ -0,0 +1,106 @@ +// playset/modules/user-interface/ui-state-model.ts — observable flat UI state: +// patch/replace with per-key equality; listeners get (snapshot, changedKeys). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/user-interface/UiStateModel.js. Verbatim semantics; adds +// createUiSignal(), a Solid bridge (model.subscribe → signal) so PocketJS +// HUDs consume snapshots idiomatically. + +import { createSignal, getOwner, onCleanup } from "solid-js"; + +export type UiState = Record; +export type UiStateListener = (state: T, changedKeys: string[]) => void; +export type UiStateEquality = (a: unknown, b: unknown) => boolean; + +function cloneState(state: T): T { + return { ...state }; +} + +export class UiStateModel { + state: T; + emitInitial: boolean; + equality: UiStateEquality; + listeners: Set>; + + constructor( + initialState: T = {} as T, + emitInitial = false, + equality: UiStateEquality = Object.is, + ) { + this.state = cloneState(initialState); + this.emitInitial = emitInitial; + this.equality = equality; + this.listeners = new Set(); + } + + getState(): T { + return cloneState(this.state); + } + + subscribe(listener: UiStateListener, emitInitial: boolean = this.emitInitial): () => boolean { + if (typeof listener !== "function") { + throw new Error("UiStateModel.subscribe: listener must be a function"); + } + + this.listeners.add(listener); + if (emitInitial) { + listener(this.getState(), Object.keys(this.state)); + } + + return () => this.listeners.delete(listener); + } + + patch(partialState: Partial = {}): string[] { + const nextState = cloneState(this.state) as UiState; + const changedKeys: string[] = []; + + for (const [key, value] of Object.entries(partialState)) { + if (this.equality((this.state as UiState)[key], value)) continue; + nextState[key] = value; + changedKeys.push(key); + } + + if (changedKeys.length === 0) return []; + + this.state = nextState as T; + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot, changedKeys); + } + + return changedKeys; + } + + replace(nextState: T = {} as T): string[] { + const keys = new Set([...Object.keys(this.state), ...Object.keys(nextState)]); + const changedKeys: string[] = []; + + for (const key of keys) { + if (this.equality((this.state as UiState)[key], (nextState as UiState)[key])) continue; + changedKeys.push(key); + } + + if (changedKeys.length === 0) return []; + + this.state = cloneState(nextState); + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot, changedKeys); + } + + return changedKeys; + } +} + +/** + * Bridge a UiStateModel to a Solid accessor: the returned signal tracks every + * emitted snapshot. Unsubscribes with the owning reactive scope when one + * exists (safe to call outside an owner for ad-hoc use — then the caller keeps + * the model alive). + */ +export function createUiSignal(model: UiStateModel): () => T { + const [state, setState] = createSignal(model.getState()); + const unsubscribe = model.subscribe((snapshot) => setState(() => snapshot)); + if (getOwner()) onCleanup(() => unsubscribe()); + return state; +} diff --git a/playset/modules/world/blob-shadow.ts b/playset/modules/world/blob-shadow.ts new file mode 100644 index 0000000..20fa26f --- /dev/null +++ b/playset/modules/world/blob-shadow.ts @@ -0,0 +1,42 @@ +// playset/modules/world/blob-shadow.ts — flattened dark disc that fakes a +// contact shadow under an entity. +// +// Not a GameBlocks port. scene3d has no shadow maps by contract (ops.ts: +// "No shadow maps ever; use blob decals") — every castShadow/receiveShadow +// flag the world/ factories dropped is replaced by one of these: an unlit +// transparent squashed cylinder sitting just above the ground under the +// entity. The owner calls updateBlobShadow each frame before scene.flush(). + +import { MAT, type Scene3D, type SceneNode } from "../../scene3d/client.ts"; +import { rgbToAbgr } from "./color-utils.ts"; + +/** Lift above the ground surface — enough to clear terrain z-fighting. */ +const GROUND_CLEARANCE = 0.02; + +export interface BlobShadowOptions { + radius?: number; + opacity?: number; + parent?: SceneNode; +} + +export function createBlobShadow( + scene: Scene3D, + { radius = 1, opacity = 0.35, parent }: BlobShadowOptions = {}, +): SceneNode { + // A unit-height cylinder squashed flat: a filled disc facing +Y. + const geom = scene.cylinder(radius, radius, 1, 24); + const mat = scene.material(rgbToAbgr(0x000000, opacity), MAT.unlit | MAT.transparent); + const node = scene.mesh(geom, mat, parent); + node.scale.set(1, GROUND_CLEARANCE, 1); + return node; +} + +/** Snap the shadow under the entity: planar position from the entity, height + * from the ground (terrain sampler / floor), plus a small clearance. */ +export function updateBlobShadow( + node: SceneNode, + groundHeight: number, + entityPlanarPos: { x: number; z: number }, +): void { + node.position.set(entityPlanarPos.x, groundHeight + GROUND_CLEARANCE, entityPlanarPos.z); +} diff --git a/playset/modules/world/color-utils.ts b/playset/modules/world/color-utils.ts new file mode 100644 index 0000000..acfde2e --- /dev/null +++ b/playset/modules/world/color-utils.ts @@ -0,0 +1,31 @@ +// playset/modules/world/color-utils.ts — hex-RGB → scene3d u32 ABGR helpers. +// +// Not a GameBlocks port. GameBlocks hands three.js 0xRRGGBB hex + a float +// `opacity`; scene3d materials/tints/pool colors take one u32 ABGR +// ((a<<24)|(b<<16)|(g<<8)|r, spec/spec.ts abgr()). Shared by the world/ +// ports so every module folds opacity into the color the same way. + +/** 0xRRGGBB + opacity (0..1, three material semantics) → u32 ABGR. */ +export function rgbToAbgr(hex: number, alpha = 1): number { + const r = (hex >> 16) & 255; + const g = (hex >> 8) & 255; + const b = hex & 255; + const a = Math.max(0, Math.min(255, Math.round(alpha * 255))); + return ((a << 24) | (b << 16) | (g << 8) | r) >>> 0; +} + +/** Component floats (0..1, three Color semantics) + opacity → u32 ABGR. + * Components are clamped per byte, so >1 brightness folds to full. */ +export function rgbFloatsToAbgr(r: number, g: number, b: number, alpha = 1): number { + const toByte = (c: number) => Math.max(0, Math.min(255, Math.round(c * 255))); + return ((toByte(alpha) << 24) | (toByte(b) << 16) | (toByte(g) << 8) | toByte(r)) >>> 0; +} + +/** Scale a hex color's RGB bytes by `fade` (the pool fade idiom: additive + * brightness rides in RGB, per WeaponEffectsSystem's vertex-color fade). */ +export function fadeRgbToAbgr(hex: number, fade: number, alpha = 1): number { + const r = (((hex >> 16) & 255) / 255) * fade; + const g = (((hex >> 8) & 255) / 255) * fade; + const b = ((hex & 255) / 255) * fade; + return rgbFloatsToAbgr(r, g, b, alpha); +} diff --git a/playset/modules/world/environment/arena-environment.ts b/playset/modules/world/environment/arena-environment.ts new file mode 100644 index 0000000..0755d90 --- /dev/null +++ b/playset/modules/world/environment/arena-environment.ts @@ -0,0 +1,495 @@ +// playset/modules/world/environment/arena-environment.ts — a walled square +// arena: ground, grid, four walls, eight pillars, three ramps, plus spawn +// sampling and CollisionWorld colliders. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/ArenaEnvironment.js. Deliberate changes +// for the scene3d surface + CollisionWorld: +// - `scene` is a Scene3D; groups/meshes are SceneNodes. Nodes are in-scene +// from creation, so create() has no scene.add step and names are gone +// (no scene3d analog). +// - Material roughness/metalness have no fixed-function analog — dropped. +// - scene3d planes are already flat in XZ facing +Y, so the ground uses +// the OBJECT canonical rotation (identity in the default basis), not +// three's plane rotation. +// - GridHelper becomes unlit thin boxes, one per grid line (worldSize +// divisions; center lines take gridMajorColor, like three's GridHelper). +// - createPhysicsColliders(world, rapier) → createColliders(world): +// walls → addCuboid, pillars → addCylinder. The ground cuboid is SKIPPED +// (CollisionWorld's ground authority is terrain/ground height; floorUp=0 +// matches the default flat ground). Ramp colliders are SKIPPED in v1 — +// wedge trimesh pending native physics — but the ramp VISUAL keeps the +// exact 6-corner wedge geometry (host computes normals). +// - Collider records are handles (`colliders`), not {body, collider}. + +import { Vector3, Quaternion } from "../../../math/index.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; +import { DEFAULT_PRNG, type RandomGenerator } from "../../math/random-utils.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../scene3d/client.ts"; +import type { CollisionWorld, ColliderHandle } from "../../physics/collision-world.ts"; + +/** spec ABGR byte order: (a<<24)|(b<<16)|(g<<8)|r. Local on purpose. */ +function rgbToAbgr(hex: number, alpha = 255): number { + const r = (hex >> 16) & 255; + const g = (hex >> 8) & 255; + const b = hex & 255; + return (((alpha & 255) << 24) | (b << 16) | (g << 8) | r) >>> 0; +} + +// Grid lines are thin unlit boxes; half-extents of the non-span axes. +const GRID_LINE_HALF_THICKNESS = 0.02; +const GRID_LINE_HALF_HEIGHT = 0.005; + +export interface RampLayoutEntry { + right: number; + forward: number; + spanRight: number; + spanForward: number; + spanUp: number; + yaw: number; + mesh?: SceneNode; +} + +export interface PillarLayoutEntry { + right: number; + forward: number; + radius: number; + spanUp: number; + mesh?: SceneNode; +} + +export interface WallLayoutEntry { + right: number; + up: number; + forward: number; + spanRight: number; + spanUp: number; + spanForward: number; +} + +export interface ArenaBounds { + minRight: number; + maxRight: number; + minForward: number; + maxForward: number; + left: number; + right: number; + back: number; + front: number; +} + +function rampAxes(ramp: RampLayoutEntry): [{ right: number; forward: number }, { right: number; forward: number }] { + const cos = Math.cos(ramp.yaw); + const sin = Math.sin(ramp.yaw); + return [ + { right: cos, forward: sin }, + { right: -sin, forward: cos }, + ]; +} + +export function defaultRampLayout(scale = 1.0): RampLayoutEntry[] { + return [ + { + right: 0.10 * scale, + forward: -0.24 * scale, + spanRight: 0.07 * scale, + spanForward: 0.14 * scale, + spanUp: 0.032 * scale, + yaw: 0, + }, + { + right: -0.20 * scale, + forward: -0.08 * scale, + spanRight: 0.068 * scale, + spanForward: 0.112 * scale, + spanUp: 0.04 * scale, + yaw: Math.PI * 0.5, + }, + { + right: 0.30 * scale, + forward: 0.08 * scale, + spanRight: 0.084 * scale, + spanForward: 0.16 * scale, + spanUp: 0.052 * scale, + yaw: Math.PI, + }, + ]; +} + +export function defaultPillarLayout(scale = 1.0): PillarLayoutEntry[] { + const pillars: PillarLayoutEntry[] = []; + for (let i = 0; i < 8; i += 1) { + const angle = (i / 8) * Math.PI * 2; + const radiusFromCenter = i % 2 === 0 ? 0.20 : 0.28; + const height = i % 2 === 0 ? 0.07 : 0.08; + const radius = i % 2 === 0 ? 0.02 : 0.03; + pillars.push({ + right: Math.cos(angle) * radiusFromCenter * scale, + forward: -Math.sin(angle) * radiusFromCenter * scale, + radius: radius * scale, + spanUp: height * scale, + }); + } + return pillars; +} + +export function defaultWallLayout( + worldSize: number, + floorUp: number, + wallHeight: number, + wallThickness: number, +): WallLayoutEntry[] { + return [ + { + right: 0, + up: floorUp + wallHeight * 0.5, + forward: worldSize * 0.5, + spanRight: worldSize, + spanUp: wallHeight, + spanForward: wallThickness, + }, + { + right: 0, + up: floorUp + wallHeight * 0.5, + forward: -worldSize * 0.5, + spanRight: worldSize, + spanUp: wallHeight, + spanForward: wallThickness, + }, + { + right: -worldSize * 0.5, + up: floorUp + wallHeight * 0.5, + forward: 0, + spanRight: wallThickness, + spanUp: wallHeight, + spanForward: worldSize, + }, + { + right: worldSize * 0.5, + up: floorUp + wallHeight * 0.5, + forward: 0, + spanRight: wallThickness, + spanUp: wallHeight, + spanForward: worldSize, + }, + ]; +} + +export interface ArenaEnvironmentOptions { + scene: Scene3D; + worldSize?: number; + floorUp?: number; + wallHeight?: number; + wallThickness?: number; + groundColor?: number; + gridMajorColor?: number; + gridMinorColor?: number; + wallColor?: number; + pillarColor?: number; + rampColor?: number; + prng?: RandomGenerator; + basis?: WorldBasis; +} + +export class ArenaEnvironment { + scene: Scene3D; + worldSize: number; + floorUp: number; + wallHeight: number; + wallThickness: number; + groundColor: number; + gridMajorColor: number; + gridMinorColor: number; + wallColor: number; + pillarColor: number; + rampColor: number; + prng: RandomGenerator; + basis: WorldBasis; + objectRotation: Quaternion; + group: SceneNode; + bounds: ArenaBounds; + wallLayout: WallLayoutEntry[]; + pillarLayout: PillarLayoutEntry[]; + rampLayout: RampLayoutEntry[]; + collisionWorld: CollisionWorld | null; + colliders: ColliderHandle[]; + + constructor({ + scene, + worldSize = 50, + floorUp = 0, + wallHeight = 3, + wallThickness = 0.7, + groundColor = 0x58745e, + gridMajorColor = 0x89a9cc, + gridMinorColor = 0x5d7593, + wallColor = 0x3f5261, + pillarColor = 0x8d6f53, + rampColor = 0x8d6f53, + prng = DEFAULT_PRNG, + basis = DEFAULT_WORLD_BASIS, + }: ArenaEnvironmentOptions) { + this.scene = scene; + this.worldSize = worldSize; + this.floorUp = floorUp; + this.wallHeight = wallHeight; + this.wallThickness = wallThickness; + this.groundColor = groundColor; + this.gridMajorColor = gridMajorColor; + this.gridMinorColor = gridMinorColor; + this.wallColor = wallColor; + this.pillarColor = pillarColor; + this.rampColor = rampColor; + this.prng = prng; + this.basis = basis; + this.objectRotation = this.basis.threeObjectCanonicalToBasisQuaternion(); + this.group = this.scene.node(); + + this.bounds = this.createBounds(); + this.wallLayout = defaultWallLayout(this.worldSize, this.floorUp, this.wallHeight, this.wallThickness); + this.pillarLayout = defaultPillarLayout(this.worldSize); + this.rampLayout = defaultRampLayout(this.worldSize); + + this.collisionWorld = null; + this.colliders = []; + } + + create(): this { + this.createGround(); + this.createGrid(); + this.createWalls(); + this.createPillars(); + this.createRamps(); + return this; + } + + createGround(): void { + const geom = this.scene.plane(this.worldSize, this.worldSize); + const material = this.scene.material(rgbToAbgr(this.groundColor), 0); + const ground = this.scene.mesh(geom, material, this.group); + ground.position.copy(this.basis.fromBasisComponents(0, this.floorUp, 0)); + ground.quaternion.copy(this.objectRotation); + } + + createGrid(): void { + const grid = this.scene.node(this.group); + grid.position.copy(this.basis.fromBasisComponents(0, this.floorUp + 0.01, 0)); + grid.quaternion.copy(this.objectRotation); + + const size = this.worldSize; + const divisions = this.worldSize; + const half = size / 2; + const step = size / divisions; + const center = divisions / 2; + const majorMaterial = this.scene.material(rgbToAbgr(this.gridMajorColor), MAT.unlit); + const minorMaterial = this.scene.material(rgbToAbgr(this.gridMinorColor), MAT.unlit); + // Grid-local axes are three-canonical (the grid node carries the object + // rotation): lines along local X at z=k, and along local Z at x=k. + const lineAlongX = this.scene.box(half, GRID_LINE_HALF_HEIGHT, GRID_LINE_HALF_THICKNESS); + const lineAlongZ = this.scene.box(GRID_LINE_HALF_THICKNESS, GRID_LINE_HALF_HEIGHT, half); + + for (let i = 0; i <= divisions; i += 1) { + const k = -half + i * step; + const material = i === center ? majorMaterial : minorMaterial; + this.scene.mesh(lineAlongX, material, grid).position.set(0, 0, k); + this.scene.mesh(lineAlongZ, material, grid).position.set(k, 0, 0); + } + } + + createWalls(): void { + const wallMaterial = this.scene.material(rgbToAbgr(this.wallColor), 0); + + for (const wall of this.wallLayout) { + const geom = this.scene.box(wall.spanRight * 0.5, wall.spanUp * 0.5, wall.spanForward * 0.5); + const mesh = this.scene.mesh(geom, wallMaterial, this.group); + mesh.position.copy(this.basis.fromBasisComponents(wall.right, wall.up, wall.forward)); + mesh.quaternion.copy(this.objectRotation); + } + } + + createPillars(): void { + const pillarMaterial = this.scene.material(rgbToAbgr(this.pillarColor), 0); + + for (const pillar of this.pillarLayout) { + const geom = this.scene.cylinder(pillar.radius, pillar.radius, pillar.spanUp, 18); + const mesh = this.scene.mesh(geom, pillarMaterial, this.group); + const pillarUp = this.floorUp + pillar.spanUp * 0.5; + mesh.position.copy(this.basis.fromBasisComponents(pillar.right, pillarUp, pillar.forward)); + mesh.quaternion.copy(this.objectRotation); + pillar.mesh = mesh; + } + } + + createRamps(): void { + const rampMaterial = this.scene.material(rgbToAbgr(this.rampColor), 0); + + for (const ramp of this.rampLayout) { + const { positions, indices } = this.createRampGeometry(ramp); + const geom = this.scene.meshGeom(positions, indices, null); + // Wedge vertices are authored in world space; the node stays identity. + const mesh = this.scene.mesh(geom, rampMaterial, this.group); + ramp.mesh = mesh; + } + } + + createRampGeometry(ramp: RampLayoutEntry): { positions: Float32Array; indices: Uint32Array } { + const halfW = ramp.spanRight * 0.5; + const halfL = ramp.spanForward * 0.5; + const [rightAxis, forwardAxis] = rampAxes(ramp); + const localCorners: [number, number, number][] = [ + [-halfW, 0, halfL], + [halfW, 0, halfL], + [-halfW, 0, -halfL], + [halfW, 0, -halfL], + [-halfW, ramp.spanUp, -halfL], + [halfW, ramp.spanUp, -halfL], + ]; + const positions: number[] = []; + const indices: number[] = []; + const vertex = new Vector3(); + + const addCorner = (cornerIndex: number): number => { + const [localRight, localUp, localForward] = localCorners[cornerIndex]; + const right = ramp.right + localRight * rightAxis.right + localForward * forwardAxis.right; + const forward = ramp.forward + localRight * rightAxis.forward + localForward * forwardAxis.forward; + this.basis.fromBasisComponents(right, this.floorUp + localUp, forward, vertex); + positions.push(vertex.x, vertex.y, vertex.z); + return positions.length / 3 - 1; + }; + + const addTriangle = (a: number, b: number, c: number): void => { + indices.push(addCorner(a), addCorner(b), addCorner(c)); + }; + + const addQuad = (a: number, b: number, c: number, d: number): void => { + addTriangle(a, b, c); + addTriangle(a, c, d); + }; + + addQuad(0, 1, 3, 2); + addQuad(0, 4, 5, 1); + addQuad(2, 3, 5, 4); + addTriangle(0, 2, 4); + addTriangle(1, 5, 3); + + return { positions: new Float32Array(positions), indices: new Uint32Array(indices) }; + } + + createBounds(): ArenaBounds { + const halfSize = this.worldSize * 0.5; + return { + minRight: -halfSize + 1.2, + maxRight: halfSize - 1.2, + minForward: -halfSize + 1.2, + maxForward: halfSize - 1.2, + left: -halfSize + 1.2, + right: halfSize - 1.2, + back: -halfSize + 1.2, + front: halfSize - 1.2, + }; + } + + isPlanarPointBlockedByGeometry(right: number, forward: number, clearance = 1.2): boolean { + for (const pillar of this.pillarLayout) { + const dRight = right - pillar.right; + const dForward = forward - pillar.forward; + const minDist = pillar.radius + clearance; + if (dRight * dRight + dForward * dForward < minDist * minDist) return true; + } + + for (const ramp of this.rampLayout) { + const [rightAxis, forwardAxis] = rampAxes(ramp); + const dRight = right - ramp.right; + const dForward = forward - ramp.forward; + const localRight = dRight * rightAxis.right + dForward * rightAxis.forward; + const localForward = dRight * forwardAxis.right + dForward * forwardAxis.forward; + + if (Math.abs(localRight) <= ramp.spanRight * 0.5 + clearance + && Math.abs(localForward) <= ramp.spanForward * 0.5 + clearance + ) { + return true; + } + } + + return false; + } + + sampleSpawn( + excludePosition: Vector3 | null = null, + minDistance = 0, + attempts = 24, + clearance = 1.2, + ): Vector3 { + const excludePlanar = excludePosition + ? this.basis.toPlanar(excludePosition) + : null; + for (let i = 0; i < attempts; i += 1) { + const right = this.prng.uniform(this.bounds.minRight, this.bounds.maxRight); + const forward = this.prng.uniform(this.bounds.minForward, this.bounds.maxForward); + + if (excludePlanar) { + const dRight = right - excludePlanar.right; + const dForward = forward - excludePlanar.forward; + if (dRight * dRight + dForward * dForward < minDistance * minDistance) continue; + } + + const blocked = this.isPlanarPointBlockedByGeometry(right, forward, clearance); + + if (!blocked) return this.basis.fromBasisComponents(right, this.floorUp, forward); + } + + return this.basis.fromBasisComponents(0, this.floorUp, 0); + } + + createColliders(world: CollisionWorld): ColliderHandle[] { + this.disposeColliders(); + this.collisionWorld = world; + this.colliders = []; + + // Ground cuboid skipped: CollisionWorld's groundHeightAt is the floor + // authority (register a flat terrain sampler when floorUp != 0). + for (const wall of this.wallLayout) { + this.colliders.push(world.addCuboid({ + position: this.basis.fromBasisComponents(wall.right, wall.up, wall.forward), + halfExtents: this.basis.fromBasisComponents( + wall.spanRight * 0.5, + wall.spanUp * 0.5, + wall.spanForward * 0.5, + ), + quaternion: this.objectRotation, + solid: true, + })); + } + + for (const pillar of this.pillarLayout) { + this.colliders.push(world.addCylinder({ + position: this.basis.fromBasisComponents( + pillar.right, + this.floorUp + pillar.spanUp * 0.5, + pillar.forward, + ), + halfHeight: pillar.spanUp * 0.5, + radius: pillar.radius, + solid: true, + })); + } + + // Ramp colliders skipped in v1: wedge trimesh pending native physics. + return this.colliders; + } + + disposeColliders(): void { + if (this.collisionWorld) { + for (const handle of this.colliders) { + this.collisionWorld.remove(handle); + } + } + this.colliders = []; + this.collisionWorld = null; + } + + dispose(): void { + this.disposeColliders(); + this.group.destroy(); + } +} diff --git a/playset/modules/world/environment/board-environment.ts b/playset/modules/world/environment/board-environment.ts new file mode 100644 index 0000000..d78ab03 --- /dev/null +++ b/playset/modules/world/environment/board-environment.ts @@ -0,0 +1,334 @@ +// playset/modules/world/environment/board-environment.ts — a cell-grid game +// board: ground plane, translucent grid, ambient + key lighting, and the +// cell↔world mapping helpers. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/BoardEnvironment.js. Deliberate changes +// for the scene3d surface: +// - `scene` is a Scene3D (or null: board math only — SceneNodes need a +// scene, so visuals are skipped and `group` stays null). +// - Material roughness/metalness dropped (no fixed-function analog); node +// names dropped (no scene3d analog). +// - scene3d planes are already flat in XZ facing +Y, so the ground uses +// the OBJECT canonical rotation, not three's plane rotation. +// - GridHelper becomes unlit thin boxes under a grid node that carries the +// original's non-uniform scale; gridOpacity rides in the material color +// alpha (MAT.transparent). +// - AmbientLight → scene.ambient(c, c); DirectionalLight → scene.sun(dir, +// c) with dir derived from keyLightPosition toward the board center (the +// original's shadow-camera aim). Intensities scale the colors. Shadow +// map config dropped: shadows → blob decals, see world/blob-shadow.ts. +// `ambientLight`/`keyLight` become plain descriptor records. + +import { Vector3 } from "../../../math/index.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../scene3d/client.ts"; +import type { PlanarPoint } from "./planar-utils.ts"; + +/** spec ABGR byte order: (a<<24)|(b<<16)|(g<<8)|r. Local on purpose. */ +function rgbToAbgr(hex: number, alpha = 255): number { + const r = (hex >> 16) & 255; + const g = (hex >> 8) & 255; + const b = hex & 255; + return (((alpha & 255) << 24) | (b << 16) | (g << 8) | r) >>> 0; +} + +/** Intensity-scaled light color (fixed-function has no intensity knob). */ +function scaledRgbToAbgr(hex: number, intensity: number): number { + const scale = (channel: number): number => + Math.max(0, Math.min(255, Math.round(channel * intensity))); + const r = scale((hex >> 16) & 255); + const g = scale((hex >> 8) & 255); + const b = scale(hex & 255); + return ((255 << 24) | (b << 16) | (g << 8) | r) >>> 0; +} + +const GRID_LINE_HALF_THICKNESS = 0.02; +const GRID_LINE_HALF_HEIGHT = 0.005; + +function sanitizeBoardSize(columns: number, rows: number, cellSize: number): { + columns: number; + rows: number; + cellSize: number; +} { + return { + columns: Math.max(2, Math.floor(columns)), + rows: Math.max(2, Math.floor(rows)), + cellSize: Math.max(0.01, cellSize), + }; +} + +export function boardCenterOffset(columns: number, rows: number, cellSize: number): PlanarPoint { + return { + right: (columns - 1) * cellSize * 0.5, + forward: (rows - 1) * cellSize * 0.5, + }; +} + +export function defaultBoardOrigin(rows: number, cellSize: number, basis: WorldBasis = DEFAULT_WORLD_BASIS): Vector3 { + return basis.fromBasisComponents(0, 0, -(rows - 1) * cellSize); +} + +export function offsetBoardPoint( + origin: Vector3, + right = 0, + up = 0, + forward = 0, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): Vector3 { + return new Vector3() + .copy(origin) + .add(basis.fromBasisComponents(right, up, forward)); +} + +export interface BoardLightDescriptor { + color: number; + intensity: number; + position?: Vector3; + target?: Vector3; + direction?: Vector3; +} + +export interface BoardEnvironmentOptions { + scene?: Scene3D | null; + columns?: number; + rows?: number; + cellSize?: number; + backgroundScale?: number; + boardUp?: number; + gridUp?: number; + groundColor?: number; + gridColor?: number; + gridOpacity?: number; + /** Accepted for API compatibility; ignored (no fixed-function analog). */ + groundRoughness?: number; + /** Accepted for API compatibility; ignored (no fixed-function analog). */ + groundMetalness?: number; + lighting?: boolean; + ambientColor?: number; + ambientIntensity?: number; + keyLightColor?: number; + keyLightIntensity?: number; + keyLightPosition?: { right: number; up: number; forward: number }; + /** Accepted for API compatibility; ignored (shadows → blob decals). */ + shadowMapSize?: number; + /** Accepted for API compatibility; ignored (shadows → blob decals). */ + shadowExtent?: number; + name?: string; + basis?: WorldBasis; +} + +export class BoardEnvironment { + scene: Scene3D | null; + columns: number; + rows: number; + cellSize: number; + backgroundScale: number; + boardUp: number; + gridUp: number; + groundColor: number; + gridColor: number; + gridOpacity: number; + groundRoughness: number; + groundMetalness: number; + lighting: boolean; + ambientColor: number; + ambientIntensity: number; + keyLightColor: number; + keyLightIntensity: number; + keyLightPosition: { right: number; up: number; forward: number }; + shadowMapSize: number; + shadowExtent: number; + name: string; + basis: WorldBasis; + origin: Vector3; + centerOffset: PlanarPoint; + center: Vector3; + bounds: Readonly<{ minRight: number; maxRight: number; minForward: number; maxForward: number }>; + boardWidth: number; + boardLength: number; + group: SceneNode | null; + boardMesh: SceneNode | null; + gridHelper: SceneNode | null; + ambientLight: BoardLightDescriptor | null; + keyLight: BoardLightDescriptor | null; + created: boolean; + + constructor({ + scene = null, + columns = 20, + rows = 20, + cellSize = 1, + backgroundScale = 2.5, + boardUp = -0.5, + gridUp = -0.49, + groundColor = 0xd68a4c, + gridColor = 0xffffff, + gridOpacity = 0.3, + groundRoughness = 1, + groundMetalness = 0, + lighting = true, + ambientColor = 0xffffff, + ambientIntensity = 0.6, + keyLightColor = 0xffffff, + keyLightIntensity = 0.7, + keyLightPosition = { right: 20, up: 18, forward: 20 }, + shadowMapSize = 1024, + shadowExtent = 30, + name = 'BoardEnvironment', + basis = DEFAULT_WORLD_BASIS, + }: BoardEnvironmentOptions = {}) { + const safeSize = sanitizeBoardSize(columns, rows, cellSize); + + this.scene = scene; + this.columns = safeSize.columns; + this.rows = safeSize.rows; + this.cellSize = safeSize.cellSize; + this.backgroundScale = Math.max(1, backgroundScale); + this.boardUp = boardUp; + this.gridUp = gridUp; + this.groundColor = groundColor; + this.gridColor = gridColor; + this.gridOpacity = gridOpacity; + this.groundRoughness = groundRoughness; + this.groundMetalness = groundMetalness; + this.lighting = lighting; + this.ambientColor = ambientColor; + this.ambientIntensity = ambientIntensity; + this.keyLightColor = keyLightColor; + this.keyLightIntensity = keyLightIntensity; + this.keyLightPosition = keyLightPosition; + this.shadowMapSize = shadowMapSize; + this.shadowExtent = shadowExtent; + this.name = name; + this.basis = basis; + this.origin = defaultBoardOrigin(this.rows, this.cellSize, this.basis); + this.centerOffset = boardCenterOffset(this.columns, this.rows, this.cellSize); + this.center = offsetBoardPoint( + this.origin, + this.centerOffset.right, + 0, + this.centerOffset.forward, + this.basis, + ); + this.bounds = Object.freeze({ + minRight: 0, + maxRight: this.columns - 1, + minForward: 0, + maxForward: this.rows - 1, + }); + this.boardWidth = this.columns * this.cellSize * this.backgroundScale; + this.boardLength = this.rows * this.cellSize * this.backgroundScale; + this.group = scene ? scene.node() : null; + this.boardMesh = null; + this.gridHelper = null; + this.ambientLight = null; + this.keyLight = null; + this.created = false; + } + + create(): this { + if (this.created) return this; + + if (this.scene && this.group) { + this.createBoardMesh(); + this.createGridHelper(); + if (this.lighting) { + this.createKeyLight(); + this.createAmbientLight(); + } + } + this.created = true; + return this; + } + + cellToWorldPoint(cell: PlanarPoint, up = 0): Vector3 { + return offsetBoardPoint( + this.origin, + cell.right * this.cellSize, + up, + cell.forward * this.cellSize, + this.basis, + ); + } + + worldPoint(right = 0, up = 0, forward = 0): Vector3 { + return offsetBoardPoint(this.origin, right, up, forward, this.basis); + } + + createBoardMesh(): SceneNode { + const scene = this.scene!; + const geom = scene.plane(this.boardWidth, this.boardLength); + const material = scene.material(rgbToAbgr(this.groundColor), 0); + const boardMesh = scene.mesh(geom, material, this.group!); + boardMesh.position.copy(this.cellToWorldPoint(this.centerOffset, this.boardUp)); + boardMesh.quaternion.copy(this.basis.threeObjectCanonicalToBasisQuaternion()); + this.boardMesh = boardMesh; + return boardMesh; + } + + createGridHelper(): SceneNode { + const scene = this.scene!; + const helperSize = Math.max(this.columns, this.rows) * this.cellSize; + const helperDivisions = Math.max(this.columns, this.rows); + const gridHelper = scene.node(this.group!); + gridHelper.scale.set( + (this.columns * this.cellSize) / helperSize, + 1, + (this.rows * this.cellSize) / helperSize, + ); + gridHelper.position.copy(this.cellToWorldPoint(this.centerOffset, this.gridUp)); + gridHelper.quaternion.copy(this.basis.threeObjectCanonicalToBasisQuaternion()); + + const alpha = Math.max(0, Math.min(255, Math.round(this.gridOpacity * 255))); + const material = scene.material( + rgbToAbgr(this.gridColor, alpha), + MAT.unlit | MAT.transparent, + ); + const half = helperSize / 2; + const step = helperSize / helperDivisions; + const lineAlongX = scene.box(half, GRID_LINE_HALF_HEIGHT, GRID_LINE_HALF_THICKNESS); + const lineAlongZ = scene.box(GRID_LINE_HALF_THICKNESS, GRID_LINE_HALF_HEIGHT, half); + for (let i = 0; i <= helperDivisions; i += 1) { + const k = -half + i * step; + scene.mesh(lineAlongX, material, gridHelper).position.set(0, 0, k); + scene.mesh(lineAlongZ, material, gridHelper).position.set(k, 0, 0); + } + + this.gridHelper = gridHelper; + return gridHelper; + } + + createAmbientLight(): BoardLightDescriptor { + const ambientLight: BoardLightDescriptor = { + color: this.ambientColor, + intensity: this.ambientIntensity, + }; + const color = scaledRgbToAbgr(this.ambientColor, this.ambientIntensity); + this.scene!.ambient(color, color); + this.ambientLight = ambientLight; + return ambientLight; + } + + createKeyLight(): BoardLightDescriptor { + const position = this.worldPoint( + this.keyLightPosition.right, + this.keyLightPosition.up, + this.keyLightPosition.forward, + ); + const target = this.center.clone(); + // The direction the light travels: from the key light toward its target. + const direction = target.clone().sub(position).normalize(); + this.scene!.sun(direction, scaledRgbToAbgr(this.keyLightColor, this.keyLightIntensity)); + + const keyLight: BoardLightDescriptor = { + color: this.keyLightColor, + intensity: this.keyLightIntensity, + position, + target, + direction, + }; + this.keyLight = keyLight; + return keyLight; + } +} diff --git a/playset/modules/world/environment/natural-environment.ts b/playset/modules/world/environment/natural-environment.ts new file mode 100644 index 0000000..0f5cd2a --- /dev/null +++ b/playset/modules/world/environment/natural-environment.ts @@ -0,0 +1,366 @@ +// playset/modules/world/environment/natural-environment.ts — procedural +// natural terrain with prng-placed trees, rocks and grass, plus +// CollisionWorld colliders (terrain ground authority, tree cylinders, rock +// balls). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/NaturalEnvironment.js. Deliberate +// changes for the scene3d surface + CollisionWorld: +// - `scene` is a Scene3D; the group and prop visuals are SceneNodes (in +// scene from creation; node names dropped — no scene3d analog). +// - renderOrder has no scene3d analog: the field and applyRenderOrder are +// kept for API compatibility, but the traverse is a no-op. +// - Prop visuals come from the ported PlantVisualFactory / +// RockVisualFactory (scene-first signatures). The factory modules are +// injectable (`plantFactory` / `rockFactory` options, defaulting to the +// real modules), so headless suites can substitute stubs. The prng draw +// ORDER per prop (tree height, radius, spawn point, factory draws, +// rotations) is exactly the original's, so a given seed reproduces the +// original's layout counts. +// - Tree rotation: the original set Euler x/y/z on the Object3D (order +// 'XYZ'); the port composes the same Euler into the node quaternion. +// - createPhysicsColliders(world, rapier) → createColliders(world): the +// terrain trimesh becomes registerTerrainCollider (sampler = ground +// authority), trees → addCylinder, rocks → addBall. Collider records +// are handles (`colliders`), not {body, collider}. + +import { Euler, Quaternion, Vector3 } from "../../../math/index.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; +import { DEFAULT_PRNG, type RandomGenerator } from "../../math/random-utils.ts"; +import type { Scene3D, SceneNode } from "../../../scene3d/client.ts"; +import type { CollisionWorld, ColliderHandle } from "../../physics/collision-world.ts"; +import { createTerrainMesh, registerTerrainCollider, type MeshTerrainSampler } from "./terrain-mesh-factory.ts"; +import * as defaultPlantVisualFactory from "../object/factory/plant-visual-factory.ts"; +import * as defaultRockVisualFactory from "../object/factory/rock-visual-factory.ts"; +import { NaturalTerrainSampler } from "./terrain-sampler.ts"; +import { SpawnAreaSampler, type PlanarBounds, type SpawnRegion } from "./spawn-area-sampler.ts"; +import type { PlanarPoint } from "./planar-utils.ts"; + +/** The sampler contract this environment needs (all three samplers qualify). */ +export interface NaturalTerrainSamplerLike extends MeshTerrainSampler { + heightAt(right: number, forward: number): number; +} + +// Materials are opaque tokens minted by a factory and handed back to it +// verbatim; `any` is the honest passthrough type across this injected +// boundary (method syntax keeps the real modules assignable). +/** PlantVisualFactory surface consumed here (same export names as ported). */ +export interface PlantVisualFactoryLike { + createTreeMaterials(scene: Scene3D, options?: object): unknown; + createTreeVisual(scene: Scene3D, options: { + height: number; + radius: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + materials: any; + prng: RandomGenerator; + }): SceneNode; + createGrassMaterial(scene: Scene3D): unknown; + createGrassBladeVisual(scene: Scene3D, options: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + material: any; + prng: RandomGenerator; + }): SceneNode; +} + +/** RockVisualFactory surface consumed here (same export names as ported). */ +export interface RockVisualFactoryLike { + createRockMaterial(scene: Scene3D): unknown; + createGroundRockVisual(scene: Scene3D, options: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + material: any; + prng: RandomGenerator; + }): SceneNode; +} + +export interface TreeEntry { + visual: SceneNode; + radius: number; + height: number; +} + +export interface RockEntry { + visual: SceneNode; + radius: number; +} + +export interface NaturalEnvironmentOptions { + scene: Scene3D; + terrainSize?: number; + terrainSegments?: number; + baseHeight?: number; + undulation?: number; + hillFrequency?: number; + terrainSampler?: NaturalTerrainSamplerLike | null; + treeCount?: number; + rockCount?: number; + grassBladeCount?: number; + propSpawnRegions?: SpawnRegion[]; + propBlockRegions?: SpawnRegion[]; + renderOrder?: number; + prng?: RandomGenerator; + basis?: WorldBasis; + plantFactory?: PlantVisualFactoryLike; + rockFactory?: RockVisualFactoryLike; +} + +export class NaturalEnvironment { + scene: Scene3D; + basis: WorldBasis; + prng: RandomGenerator; + terrainSize: number; + terrainSegments: number; + terrainSampler: NaturalTerrainSamplerLike; + treeCount: number; + rockCount: number; + grassBladeCount: number; + propSpawnAreaSampler: SpawnAreaSampler; + renderOrder: number; + placementBounds: PlanarBounds; + group: SceneNode; + terrainMesh: SceneNode | null; + trees: TreeEntry[]; + rocks: RockEntry[]; + planarScratch: PlanarPoint; + propBasisQuaternion: Quaternion; + collisionWorld: CollisionWorld | null; + colliders: ColliderHandle[]; + private plantFactory: PlantVisualFactoryLike; + private rockFactory: RockVisualFactoryLike; + private terrainRegistered: boolean; + + constructor({ + scene, + terrainSize = 180, + terrainSegments = 128, + baseHeight = 0, + undulation = 3.6, + hillFrequency = 1, + terrainSampler = null, + treeCount = 155, + rockCount = 36, + grassBladeCount = 260, + propSpawnRegions = [], + propBlockRegions = [], + renderOrder = 0, + prng = DEFAULT_PRNG, + basis = DEFAULT_WORLD_BASIS, + plantFactory = defaultPlantVisualFactory, + rockFactory = defaultRockVisualFactory, + }: NaturalEnvironmentOptions) { + const resolvedTerrainSampler = terrainSampler ?? new NaturalTerrainSampler({ + baseHeight, + undulation, + hillFrequency, + basis, + }); + + this.placementBounds = { + rightMin: -0.48 * terrainSize, rightMax: 0.48 * terrainSize, forwardMin: -0.48 * terrainSize, forwardMax: 0.48 * terrainSize, + }; + + this.scene = scene; + this.basis = basis; + this.prng = prng; + this.terrainSize = terrainSize; + this.terrainSegments = terrainSegments; + this.terrainSampler = resolvedTerrainSampler; + this.treeCount = treeCount; + this.rockCount = rockCount; + this.grassBladeCount = grassBladeCount; + this.propSpawnAreaSampler = new SpawnAreaSampler({ + bounds: this.placementBounds, + spawnRegions: propSpawnRegions, + blockRegions: propBlockRegions, + }); + this.renderOrder = renderOrder; + this.group = this.scene.node(); + this.terrainMesh = null; + this.trees = []; + this.rocks = []; + this.planarScratch = { right: 0, forward: 0 }; + this.propBasisQuaternion = this.basis.threeObjectCanonicalToBasisQuaternion(new Quaternion()); + this.plantFactory = plantFactory; + this.rockFactory = rockFactory; + + this.collisionWorld = null; + this.colliders = []; + this.terrainRegistered = false; + } + + create(): this { + this.createTerrain(); + this.createForest(); + return this; + } + + terrainHeightAt(position: Vector3): number { + const p = this.basis.toPlanar(position, this.planarScratch); + return this.terrainHeightAtPlanar(p.right, p.forward); + } + + terrainHeightAtPlanar(right: number, forward: number): number { + return this.terrainSampler.sample(right, forward)?.height ?? 0; + } + + placeOnGround(object: SceneNode, rightValue: number, forwardValue: number, extraHeight = 0): Vector3 { + const position = this.basis.fromBasisComponents(rightValue, 0, forwardValue); + this.basis.setHeight(position, this.terrainHeightAt(position) + extraHeight); + object.position.copy(position); + return position; + } + + /** renderOrder has no scene3d analog; kept for API compatibility. */ + applyRenderOrder(object: T): T { + return object; + } + + samplePropPlanarPoint(radius = 0): PlanarPoint | null { + return this.propSpawnAreaSampler.sample(this.prng, radius); + } + + orientPropVisual(object: SceneNode): SceneNode { + object.quaternion.premultiply(this.propBasisQuaternion); + return object; + } + + createTerrain(): void { + const mesh = createTerrainMesh({ + scene: this.scene, + terrainSampler: this.terrainSampler, + size: this.terrainSize, + segments: this.terrainSegments, + materialOptions: { + roughness: 0.9, + metalness: 0.02, + }, + }); + this.applyRenderOrder(mesh); + this.group.add(mesh); + this.terrainMesh = mesh; + } + + createForest(): void { + if (this.treeCount > 0) this.createTrees(this.plantFactory.createTreeMaterials(this.scene, {})); + if (this.rockCount > 0) this.createRocks(this.rockFactory.createRockMaterial(this.scene)); + if (this.grassBladeCount > 0) this.createGrass(this.plantFactory.createGrassMaterial(this.scene)); + } + + createTrees(materials: unknown): void { + for (let i = 0; i < this.treeCount; i += 1) { + const height = this.prng.uniform(5, 10.5); + const radius = this.prng.uniform(0.24, 0.55); + const colliderRadius = radius + 0.35; + const point = this.samplePropPlanarPoint(colliderRadius); + if (!point) continue; + + const tree = this.plantFactory.createTreeVisual(this.scene, { height, radius, materials, prng: this.prng }); + const rotationY = this.prng.uniform(0, Math.PI * 2); + const rotationX = this.prng.uniform(-0.025, 0.025); + const rotationZ = this.prng.uniform(-0.035, 0.035); + tree.quaternion.setFromEuler(new Euler(rotationX, rotationY, rotationZ, "XYZ")); + this.orientPropVisual(tree); + this.placeOnGround(tree, point.right, point.forward); + this.applyRenderOrder(tree); + this.group.add(tree); + this.trees.push({ visual: tree, radius: colliderRadius, height }); + } + } + + createRocks(material: unknown): void { + for (let i = 0; i < this.rockCount; i += 1) { + const radius = 1.2; + const point = this.samplePropPlanarPoint(radius); + if (!point) continue; + + const rockVisual = this.rockFactory.createGroundRockVisual(this.scene, { material, prng: this.prng }); + this.orientPropVisual(rockVisual); + this.placeOnGround(rockVisual, point.right, point.forward, 0.35); + this.applyRenderOrder(rockVisual); + this.group.add(rockVisual); + this.rocks.push({ visual: rockVisual, radius }); + } + } + + createGrass(material: unknown): void { + for (let i = 0; i < this.grassBladeCount; i += 1) { + const point = this.samplePropPlanarPoint(0.1); + if (!point) continue; + + const blade = this.plantFactory.createGrassBladeVisual(this.scene, { material, prng: this.prng }); + this.orientPropVisual(blade); + this.placeOnGround(blade, point.right, point.forward, 0.25); + this.applyRenderOrder(blade); + this.group.add(blade); + } + } + + createTreeColliders(world: CollisionWorld): ColliderHandle[] { + const center = new Vector3(); + const entries: ColliderHandle[] = []; + + for (let index = 0; index < this.trees.length; index += 1) { + const { radius, height, visual } = this.trees[index]; + center.copy(visual.position); + this.basis.addHeight(center, height * 0.5); + entries.push(world.addCylinder({ + position: { x: center.x, y: center.y, z: center.z }, + halfHeight: height * 0.5, + radius, + solid: true, + })); + } + + return entries; + } + + createRockColliders(world: CollisionWorld): ColliderHandle[] { + const entries: ColliderHandle[] = []; + + for (let index = 0; index < this.rocks.length; index += 1) { + const { radius, visual } = this.rocks[index]; + entries.push(world.addBall({ + position: visual.position, + radius, + solid: true, + })); + } + + return entries; + } + + createColliders(world: CollisionWorld): ColliderHandle[] { + this.disposeColliders(); + this.collisionWorld = world; + this.colliders = []; + + registerTerrainCollider(world, this.terrainSampler); + this.terrainRegistered = true; + + this.colliders.push( + ...this.createTreeColliders(world), + ...this.createRockColliders(world), + ); + + return this.colliders; + } + + disposeColliders(): void { + if (this.collisionWorld) { + for (const handle of this.colliders) { + this.collisionWorld.remove(handle); + } + if (this.terrainRegistered) this.collisionWorld.setTerrain(null); + } + this.colliders = []; + this.collisionWorld = null; + this.terrainRegistered = false; + } + + dispose(): void { + this.disposeColliders(); + this.group.destroy(); + this.terrainMesh = null; + this.trees.length = 0; + this.rocks.length = 0; + } +} diff --git a/playset/modules/world/environment/planar-utils.ts b/playset/modules/world/environment/planar-utils.ts new file mode 100644 index 0000000..600ca70 --- /dev/null +++ b/playset/modules/world/environment/planar-utils.ts @@ -0,0 +1,104 @@ +// playset/modules/world/environment/planar-utils.ts — planar (right/forward) +// path helpers shared by the environments: tangents, centroids, terrain +// height lookups. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/PlanarUtils.js. Verbatim semantics. + +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; + +export const PLANAR_EPS = 1e-6; + +export interface PlanarPoint { + right: number; + forward: number; +} + +/** The duck-typed sampler shape PlanarUtils accepts (heightAt or sample). */ +export interface TerrainSamplerLike { + basis?: WorldBasis; + heightAt?(right: number, forward: number): number; + sample?(right: number, forward: number): { height?: number } | null | undefined; +} + +export function terrainBasis(terrainSampler: TerrainSamplerLike | null | undefined): WorldBasis { + return terrainSampler?.basis ?? DEFAULT_WORLD_BASIS; +} + +export function basisFromLayout( + layout: { basis?: WorldBasis } | null | undefined, + terrainSampler: TerrainSamplerLike | null = null, +): WorldBasis { + return layout?.basis ?? terrainSampler?.basis ?? DEFAULT_WORLD_BASIS; +} + +export function terrainHeight( + terrainSampler: TerrainSamplerLike | null | undefined, + planarPoint: PlanarPoint, +): number { + if (typeof terrainSampler?.heightAt === "function") { + return terrainSampler.heightAt(planarPoint.right, planarPoint.forward); + } + if (typeof terrainSampler?.sample === "function") { + return terrainSampler.sample(planarPoint.right, planarPoint.forward)?.height ?? 0; + } + return 0; +} + +export function normalizePlanar2D( + right: number, + forward: number, + fallback: PlanarPoint = { right: 0, forward: 1 }, + epsilon = PLANAR_EPS, +): PlanarPoint { + const length = Math.hypot(right, forward); + if (length < epsilon) return { ...fallback }; + return { right: right / length, forward: forward / length }; +} + +export function planarCentroid(points: readonly PlanarPoint[]): PlanarPoint { + let right = 0; + let forward = 0; + for (const point of points) { + right += point.right; + forward += point.forward; + } + return { + right: points.length > 0 ? right / points.length : 0, + forward: points.length > 0 ? forward / points.length : 0, + }; +} + +export interface PlanarTangentOptions { + fallback?: PlanarPoint; + retryFromCurrent?: boolean; + epsilon?: number; +} + +export function planarTangentAt( + points: readonly PlanarPoint[], + index: number, + closed: boolean, + { + fallback = { right: 0, forward: 1 }, + retryFromCurrent = false, + epsilon = PLANAR_EPS, + }: PlanarTangentOptions = {}, +): PlanarPoint { + const count = points.length; + if (count < 2) return { ...fallback }; + + const prevIndex = closed ? (index - 1 + count) % count : Math.max(0, index - 1); + const nextIndex = closed ? (index + 1) % count : Math.min(count - 1, index + 1); + const prev = points[prevIndex]; + const next = points[nextIndex]; + let right = next.right - prev.right; + let forward = next.forward - prev.forward; + + if (retryFromCurrent && Math.hypot(right, forward) < epsilon) { + right = next.right - points[index].right; + forward = next.forward - points[index].forward; + } + + return normalizePlanar2D(right, forward, fallback, epsilon); +} diff --git a/playset/modules/world/environment/race-track-environment.ts b/playset/modules/world/environment/race-track-environment.ts new file mode 100644 index 0000000..e6e815f --- /dev/null +++ b/playset/modules/world/environment/race-track-environment.ts @@ -0,0 +1,687 @@ +// playset/modules/world/environment/race-track-environment.ts — a closed(!) +// race track composed over NaturalEnvironment: road-flattened terrain, +// checkpoint gates, inner/outer barrier fences, spawn poses and +// CollisionWorld colliders. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/RaceTrackEnvironment.js. Deliberate +// changes for the scene3d surface + CollisionWorld: +// - `scene` is a Scene3D; groups/gates/posts/rails are SceneNodes (in +// scene from creation; node names dropped — no scene3d analog). +// - Material metalness/roughness dropped (no fixed-function analog). +// - castShadow/receiveShadow dropped: shadows → blob decals, see +// world/blob-shadow.ts (addShadowMesh keeps its name for fidelity). +// - createPhysicsColliders(world, rapier) → createColliders(world): +// delegates to naturalEnvironment.createColliders (terrain sampler as +// ground authority + tree/rock shapes), barrier posts → addCylinder, +// rails → addCuboid (yaw-only orientation — CollisionWorld v1 drops the +// rails' terrain-following tilt, faithful for the planar resolver). +// Collider records are handles (`colliders`), not {body, collider}. +// - naturalEnvironmentConfig may carry `prng` / `plantFactory` / +// `rockFactory` (injection flows through to the composed environment). + +import { Matrix4, Vector3 } from "../../../math/index.ts"; +import { clamp } from "../../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; +import { + PLANAR_EPS, + normalizePlanar2D, + planarCentroid, + planarTangentAt, + type PlanarPoint, +} from "./planar-utils.ts"; +import { NaturalEnvironment, type NaturalEnvironmentOptions } from "./natural-environment.ts"; +import { RoadTerrainSampler } from "./terrain-sampler.ts"; +import { SPAWN_REGION_TYPES, type SpawnRegion } from "./spawn-area-sampler.ts"; +import type { PlanarSegment } from "./spawn-area-sampler.ts"; +import type { Scene3D, SceneNode } from "../../../scene3d/client.ts"; +import type { CollisionWorld, ColliderHandle } from "../../physics/collision-world.ts"; + +/** spec ABGR byte order: (a<<24)|(b<<16)|(g<<8)|r. Local on purpose. */ +function rgbToAbgr(hex: number, alpha = 255): number { + const r = (hex >> 16) & 255; + const g = (hex >> 8) & 255; + const b = hex & 255; + return (((alpha & 255) << 24) | (b << 16) | (g << 8) | r) >>> 0; +} + +const SIDE_SIGN = Object.freeze({ outer: 1, inner: -1 }); + +export interface RaceTrackNaturalEnvironmentConfig + extends Omit {} + +export const DEFAULT_NATURAL_ENVIRONMENT_CONFIG: Readonly = Object.freeze({ + terrainSize: 180, + terrainSegments: 128, + treeCount: 155, + rockCount: 36, + grassBladeCount: 260, + renderOrder: 0, +}); + +export interface RoadTerrainSamplerConfig { + seed?: number; + roadHalfWidth?: number; + roadHeight?: number; + roadFlatnessAtHalfWidth?: number; + largeWaveScale?: number; + largeWaveAmp?: number; + midNoiseScale?: number; + midNoiseAmp?: number; + normalStep?: number; +} + +export const DEFAULT_ROAD_TERRAIN_SAMPLER_CONFIG: Readonly = Object.freeze({ + seed: 2026, + roadHalfWidth: 6, + roadHeight: 0, + roadFlatnessAtHalfWidth: 0.8, + largeWaveScale: 0.05, + largeWaveAmp: 1.45, + midNoiseScale: 0.12, + midNoiseAmp: 1.15, + normalStep: 0.2, +}); + +const DEFAULT_CHECKPOINT_RADIUS = 7.5; + +export interface CheckpointMarkerConfig { + width: number; + height: number; + postRadius: number; + postSegments: number; + crossbarThickness: number; + flagWidth: number; + flagHeight: number; + flagThickness: number; + upOffset: number; + colors: Readonly<{ post: number; crossbar: number; flagA: number; flagB: number }>; + /** Accepted for API compatibility; ignored (no fixed-function analog). */ + materialOptions: Readonly>; +} + +export const DEFAULT_CHECKPOINT_MARKER_CONFIG: Readonly = Object.freeze({ + width: 12, + height: 4, + postRadius: 0.08, + postSegments: 12, + crossbarThickness: 0.08, + flagWidth: 2, + flagHeight: 1, + flagThickness: 0.03, + upOffset: 0.02, + colors: Object.freeze({ + post: 0x3a3026, + crossbar: 0x4a3b2d, + flagA: 0xf9d66f, + flagB: 0x2f3e55, + }), + materialOptions: Object.freeze({}), +}); + +export interface BarrierConfig { + sideOffset: number; + postSpacing: number; + height: number; + postRadiusRatio: number; + postSegments: number; + railHeightRatio: number; + railThicknessRatio: number; + upOffset: number; + colors: Readonly<{ post: number; rail: number }>; + /** Accepted for API compatibility; ignored (no fixed-function analog). */ + materialOptions: Readonly>; + friction: number; + restitution: number; +} + +export const DEFAULT_BARRIER_CONFIG: Readonly = Object.freeze({ + sideOffset: 7, + postSpacing: 2.5, + height: 1.2, + postRadiusRatio: 0.35, + postSegments: 10, + railHeightRatio: 0.72, + railThicknessRatio: 0.16, + upOffset: 0.02, + colors: Object.freeze({ + post: 0xe4edf9, + rail: 0x76849a, + }), + materialOptions: Object.freeze({}), + friction: 1, + restitution: 0, +}); + +export interface Checkpoint { + id: string; + right: number; + forward: number; + position: Vector3; + radius: number; +} + +export interface BarrierPost { + visual: SceneNode; + radius: number; + height: number; +} + +export interface BarrierRail { + visual: SceneNode; + spanRight: number; + spanUp: number; + spanForward: number; +} + +export interface Barriers { + group: SceneNode; + posts: BarrierPost[]; + rails: BarrierRail[]; +} + +export interface SpawnPose { + startIndex: number; + prevIndex: number; + nextIndex: number; + prevCheckpointId: string; + startCheckpointId: string; + nextCheckpointId: string; + clockwise: boolean; + forward: { x: number; y: number; z: number }; + right: { x: number; y: number; z: number }; + position: Vector3; + yaw: number; +} + +function terrainHeight(terrainSampler: { heightAt(right: number, forward: number): number }, point: PlanarPoint): number { + return terrainSampler.heightAt(point.right, point.forward); +} + +function offsetPath( + path: readonly PlanarPoint[], + closed: boolean, + offset: number, + sideSign = 1, +): PlanarPoint[] { + const center = planarCentroid(path); + const offsetPoints: PlanarPoint[] = []; + for (let index = 0; index < path.length; index += 1) { + const point = path[index]; + const tangent = planarTangentAt(path, index, closed, { retryFromCurrent: true }); + const side = { right: tangent.forward, forward: -tangent.right }; + const radial = normalizePlanar2D(point.right - center.right, point.forward - center.forward); + const outwardSign = side.right * radial.right + side.forward * radial.forward >= 0 ? 1 : -1; + offsetPoints.push({ + right: point.right + side.right * offset * sideSign * outwardSign, + forward: point.forward + side.forward * offset * sideSign * outwardSign, + }); + } + return offsetPoints; +} + +export interface RaceTrackEnvironmentOptions { + scene: Scene3D; + trackPlanarPoints: PlanarPoint[]; + closed?: boolean; + checkpointRadius?: number; + naturalEnvironmentConfig?: RaceTrackNaturalEnvironmentConfig; + roadTerrainSamplerConfig?: RoadTerrainSamplerConfig; + roadPropClearance?: number; + checkpointMarkerConfig?: CheckpointMarkerConfig | null; + barrierConfig?: BarrierConfig | null; + basis?: WorldBasis; +} + +export class RaceTrackEnvironment { + scene: Scene3D; + closed: boolean; + basis: WorldBasis; + group: SceneNode; + trackPlanarPoints: PlanarPoint[]; + roadSegments: PlanarSegment[]; + checkpoints: Checkpoint[]; + roadPropClearance: number; + naturalEnvironment: NaturalEnvironment; + terrainSampler: RoadTerrainSampler; + checkpointMarkers: SceneNode | null; + barriers: Barriers | null; + checkpointMarkerConfig: CheckpointMarkerConfig | null; + barrierConfig: BarrierConfig | null; + collisionWorld: CollisionWorld | null; + colliders: ColliderHandle[]; + + constructor({ + scene, + trackPlanarPoints, + closed = true, + checkpointRadius = DEFAULT_CHECKPOINT_RADIUS, + naturalEnvironmentConfig = DEFAULT_NATURAL_ENVIRONMENT_CONFIG, + roadTerrainSamplerConfig = DEFAULT_ROAD_TERRAIN_SAMPLER_CONFIG, + roadPropClearance = 1.5, + checkpointMarkerConfig = DEFAULT_CHECKPOINT_MARKER_CONFIG, + barrierConfig = DEFAULT_BARRIER_CONFIG, + basis = DEFAULT_WORLD_BASIS, + }: RaceTrackEnvironmentOptions) { + this.scene = scene; + this.closed = closed; + this.basis = basis; + this.group = scene.node(); + this.trackPlanarPoints = trackPlanarPoints; + this.roadSegments = []; + this.checkpoints = []; + this.roadPropClearance = roadPropClearance; + + this.buildRoadSegments(); + this.naturalEnvironment = this.buildNaturalEnvironment(naturalEnvironmentConfig, roadTerrainSamplerConfig); + this.terrainSampler = this.naturalEnvironment.terrainSampler as RoadTerrainSampler; + this.checkpointMarkers = null; + this.barriers = null; + this.checkpointMarkerConfig = checkpointMarkerConfig; + this.barrierConfig = barrierConfig; + this.buildCheckpoints(checkpointRadius); + + this.collisionWorld = null; + this.colliders = []; + } + + create(): this { + this.naturalEnvironment.create(); + this.createCheckpointMarkers(); + this.createBarriers(); + return this; + } + + buildNaturalEnvironment( + naturalEnvironmentConfig: RaceTrackNaturalEnvironmentConfig, + roadTerrainSamplerConfig: RoadTerrainSamplerConfig, + ): NaturalEnvironment { + const terrainSampler = new RoadTerrainSampler({ + ...roadTerrainSamplerConfig, + roadSegments: this.roadSegments, + basis: this.basis, + }); + const propBlockRegions: SpawnRegion[] = []; + if (this.roadPropClearance >= 0) { + propBlockRegions.push({ + type: SPAWN_REGION_TYPES.SEGMENT_CORRIDOR, + segments: this.roadSegments, + halfWidth: terrainSampler.roadHalfWidth, + clearance: this.roadPropClearance, + }); + } + + return new NaturalEnvironment({ + ...naturalEnvironmentConfig, + scene: this.scene, + basis: this.basis, + terrainSampler, + propBlockRegions, + }); + } + + buildRoadSegments(): PlanarSegment[] { + const count = this.trackPlanarPoints.length; + this.roadSegments = []; + const segmentCount = this.closed ? count : count - 1; + for (let i = 0; i < segmentCount; i += 1) { + this.roadSegments.push({ + start: this.trackPlanarPoints[i], + end: this.trackPlanarPoints[(i + 1) % count], + }); + } + return this.roadSegments; + } + + buildCheckpoints(radius: number): Checkpoint[] { + this.checkpoints = []; + for (let index = 0; index < this.trackPlanarPoints.length; index += 1) { + const point = this.trackPlanarPoints[index]; + const height = terrainHeight(this.terrainSampler, point); + const position = this.basis.fromBasisComponents(point.right, height, point.forward); + this.checkpoints.push({ + id: `cp_${index + 1}`, + right: point.right, + forward: point.forward, + position, + radius, + }); + } + return this.checkpoints; + } + + /** Flat-color stand-in for the original's MeshStandardMaterial builder. */ + private standardMaterial(color: number): number { + return this.scene.material(rgbToAbgr(color), 0); + } + + /** castShadow/receiveShadow dropped — shadows → blob decals (see header). */ + private addShadowMesh(parent: SceneNode, geometry: number, material: number, x: number, y: number, z: number): SceneNode { + const mesh = this.scene.mesh(geometry, material, parent); + mesh.position.set(x, y, z); + return mesh; + } + + createCheckpointMarkers(): SceneNode | null { + if (!this.checkpointMarkerConfig) return null; + const config = this.checkpointMarkerConfig; + + const group = this.scene.node(this.group); + const postMaterial = this.standardMaterial(config.colors.post); + const crossbarMaterial = this.standardMaterial(config.colors.crossbar); + const flagMaterialA = this.standardMaterial(config.colors.flagA); + const flagMaterialB = this.standardMaterial(config.colors.flagB); + const postGeometry = this.scene.cylinder( + config.postRadius, + config.postRadius, + config.height, + config.postSegments, + ); + const crossbarGeometry = this.scene.box( + (config.width + config.postRadius * 2.4) * 0.5, + config.crossbarThickness * 0.5, + config.crossbarThickness * 0.5, + ); + const flagGeometry = this.scene.box( + config.flagWidth * 0.5, + config.flagHeight * 0.5, + config.flagThickness * 0.5, + ); + + for (let index = 0; index < this.checkpoints.length; index += 1) { + const checkpoint = this.checkpoints[index]; + const prev = this.checkpoints[(index - 1 + this.checkpoints.length) % this.checkpoints.length]; + const next = this.checkpoints[(index + 1) % this.checkpoints.length]; + const tangent = normalizePlanar2D(next.right - prev.right, next.forward - prev.forward); + const gate = this.scene.node(group); + gate.position.copy(this.basis.fromBasisComponents( + checkpoint.right, + terrainHeight(this.terrainSampler, checkpoint) + config.upOffset, + checkpoint.forward, + )); + gate.quaternion.setFromRotationMatrix(this.getPlanarTangentFrame(tangent)); + + for (const side of [-1, 1]) { + this.addShadowMesh( + gate, + postGeometry, + postMaterial, + side * config.width * 0.5, + config.height * 0.5, + 0, + ); + } + this.addShadowMesh(gate, crossbarGeometry, crossbarMaterial, 0, config.height, 0); + + const flagMaterials = index % 2 === 0 + ? [flagMaterialA, flagMaterialB] + : [flagMaterialB, flagMaterialA]; + const flagOffsets = [ + -config.width * 0.5 + config.flagWidth * 0.5, + config.width * 0.5 - config.flagWidth * 0.5, + ]; + for (let flag = 0; flag < 2; flag += 1) { + this.addShadowMesh( + gate, + flagGeometry, + flagMaterials[flag], + flagOffsets[flag], + config.height - config.flagHeight * 0.65, + 0, + ); + } + } + + this.checkpointMarkers = group; + return group; + } + + getPlanarTangentFrame(tangentPlanar: PlanarPoint): Matrix4 { + const up = this.basis.upVector(); + const tangent = this.basis.fromBasisComponents(tangentPlanar.right, 0, tangentPlanar.forward).normalize(); + const side = new Vector3().crossVectors(up, tangent).normalize(); + return new Matrix4().makeBasis(side, up, tangent); + } + + createBarriers(): Barriers | null { + if (!this.barrierConfig) return null; + const config = this.barrierConfig; + + const outerPath = offsetPath(this.trackPlanarPoints, this.closed, config.sideOffset, SIDE_SIGN.outer); + const innerPath = offsetPath(this.trackPlanarPoints, this.closed, config.sideOffset, SIDE_SIGN.inner); + const group = this.scene.node(this.group); + const rails: BarrierRail[] = []; + const postMaterial = this.scene.material(rgbToAbgr(config.colors.post), 0); + const railMaterial = this.scene.material(rgbToAbgr(config.colors.rail), 0); + const postGeometry = this.scene.cylinder( + config.height * config.postRadiusRatio, + config.height * config.postRadiusRatio, + config.height, + config.postSegments, + ); + const outerPosts = this.barrierPostsFromPath(outerPath, postGeometry, postMaterial); + const innerPosts = this.barrierPostsFromPath(innerPath, postGeometry, postMaterial); + const posts = [...outerPosts, ...innerPosts]; + + for (const post of posts) { + group.add(post.visual); + } + + for (const sidePosts of [outerPosts, innerPosts]) { + const sideRails = this.railsFromPosts(sidePosts, railMaterial); + for (const rail of sideRails) { + group.add(rail.visual); + rails.push(rail); + } + } + + this.barriers = { + group, + posts, + rails, + }; + return this.barriers; + } + + barrierPostsFromPath(path: readonly PlanarPoint[], geometry: number, material: number): BarrierPost[] { + const config = this.barrierConfig!; + const posts: BarrierPost[] = []; + const count = this.closed ? path.length : path.length - 1; + const radius = config.height * config.postRadiusRatio; + const height = config.height; + const rotation = this.basis.threeObjectCanonicalToBasisQuaternion(); + for (let i = 0; i < count; i += 1) { + const a = path[i]; + const b = path[(i + 1) % path.length]; + const dRight = b.right - a.right; + const dForward = b.forward - a.forward; + const length = Math.hypot(dRight, dForward); + if (length < PLANAR_EPS) continue; + + const postCount = Math.max(1, Math.floor(length / config.postSpacing)); + for (let j = 0; j < postCount; j += 1) { + const t = (j * config.postSpacing) / length; + const point = { + right: a.right + dRight * t, + forward: a.forward + dForward * t, + }; + const position = this.basis.fromBasisComponents( + point.right, + terrainHeight(this.terrainSampler, point) + + config.height * 0.5 + + config.upOffset, + point.forward, + ); + const visual = this.scene.mesh(geometry, material); + visual.position.copy(position); + visual.quaternion.copy(rotation); + posts.push({ + visual, + radius, + height, + }); + } + } + return posts; + } + + railsFromPosts(posts: readonly BarrierPost[], material: number): BarrierRail[] { + const config = this.barrierConfig!; + const rails: BarrierRail[] = []; + const count = this.closed ? posts.length : posts.length - 1; + const up = this.basis.upVector(); + const tangent = new Vector3(); + const side = new Vector3(); + const matrix = new Matrix4(); + const midpointWorld = new Vector3(); + const aPlanar = { right: 0, forward: 0 }; + const bPlanar = { right: 0, forward: 0 }; + + for (let i = 0; i < count; i += 1) { + const a = posts[i]; + const b = posts[(i + 1) % posts.length]; + this.basis.toPlanar(a.visual.position, aPlanar); + this.basis.toPlanar(b.visual.position, bPlanar); + const dRight = bPlanar.right - aPlanar.right; + const dForward = bPlanar.forward - aPlanar.forward; + const length = Math.hypot(dRight, dForward); + if (length < PLANAR_EPS) continue; + + const midpoint = { + right: (aPlanar.right + bPlanar.right) * 0.5, + forward: (aPlanar.forward + bPlanar.forward) * 0.5, + }; + const spanRight = config.height * config.railThicknessRatio; + const spanUp = config.height * config.railThicknessRatio; + const spanForward = length; + const geometry = this.scene.box(spanRight * 0.5, spanUp * 0.5, spanForward * 0.5); + const visual = this.scene.mesh(geometry, material); + + this.basis.fromBasisComponents( + midpoint.right, + terrainHeight(this.terrainSampler, midpoint) + + config.height * config.railHeightRatio + + config.upOffset, + midpoint.forward, + midpointWorld, + ); + this.basis.fromBasisComponents(dRight / length, 0, dForward / length, tangent).normalize(); + side.crossVectors(up, tangent).normalize(); + matrix.makeBasis(side, up, tangent); + + visual.position.copy(midpointWorld); + visual.quaternion.setFromRotationMatrix(matrix); + rails.push({ + visual, + spanRight, + spanUp, + spanForward, + }); + } + return rails; + } + + createBarrierColliders(world: CollisionWorld): ColliderHandle[] { + const posts = this.barriers!.posts; + const rails = this.barriers!.rails; + const entries: ColliderHandle[] = []; + + for (let index = 0; index < posts.length; index += 1) { + const post = posts[index]; + const visual = post.visual; + entries.push(world.addCylinder({ + position: visual.position, + halfHeight: post.height * 0.5, + radius: post.radius, + solid: true, + })); + } + + for (const rail of rails) { + const visual = rail.visual; + entries.push(world.addCuboid({ + position: visual.position, + quaternion: visual.quaternion, + halfExtents: this.basis.fromBasisComponents( + rail.spanRight * 0.5, + rail.spanUp * 0.5, + rail.spanForward * 0.5, + ), + solid: true, + })); + } + return entries; + } + + createColliders(world: CollisionWorld): ColliderHandle[] { + this.disposeColliders(); + this.collisionWorld = world; + this.colliders = []; + + this.naturalEnvironment.createColliders(world); + + if (this.barriers) { + const barrierColliders = this.createBarrierColliders(world); + this.colliders.push(...barrierColliders); + } + return this.colliders; + } + + disposeColliders(): void { + this.naturalEnvironment.disposeColliders(); + + if (this.collisionWorld) { + for (const handle of this.colliders) { + this.collisionWorld.remove(handle); + } + } + this.colliders = []; + this.collisionWorld = null; + } + + dispose(): void { + this.disposeColliders(); + this.naturalEnvironment.dispose(); + this.group.destroy(); + this.checkpointMarkers = null; + this.barriers = null; + } + + spawnPose( + startIndex: number, + clockwise: boolean, + spawnDistance: number, + lateralOffset: number, + upOffset: number, + ): SpawnPose { + const count = this.checkpoints.length; + const index = ((startIndex % count) + count) % count; + const prevIndex = this.closed ? (index - 1 + count) % count : clamp(index - 1, 0, count - 1); + const nextIndex = this.closed ? (index + 1) % count : clamp(index + 1, 0, count - 1); + const current = this.checkpoints[index].position; + const previous = this.checkpoints[prevIndex].position; + const forward = this.basis.planarDelta(current, previous); + const length = Math.hypot(forward.right, forward.forward); + const forwardWorld = this.basis.fromBasisComponents(forward.right / length, 0, forward.forward / length); + const yaw = this.basis.forwardToYaw(forwardWorld); + const frame = this.basis.yawPitchRollFrame(yaw); + const position = current.clone() + .addScaledVector(frame.forward, -spawnDistance) + .addScaledVector(frame.right, lateralOffset); + this.basis.addHeight(position, upOffset); + + return { + startIndex: index, + prevIndex, + nextIndex, + prevCheckpointId: this.checkpoints[prevIndex].id, + startCheckpointId: this.checkpoints[index].id, + nextCheckpointId: this.checkpoints[nextIndex].id, + clockwise, + forward: { x: frame.forward.x, y: frame.forward.y, z: frame.forward.z }, + right: { x: frame.right.x, y: frame.right.y, z: frame.right.z }, + position, + yaw, + }; + } +} diff --git a/playset/modules/world/environment/spawn-area-sampler.ts b/playset/modules/world/environment/spawn-area-sampler.ts new file mode 100644 index 0000000..5ff3e45 --- /dev/null +++ b/playset/modules/world/environment/spawn-area-sampler.ts @@ -0,0 +1,259 @@ +// playset/modules/world/environment/spawn-area-sampler.ts — rejection +// sampler for planar spawn points against allow/deny regions (rects, +// circles, polygons, segment corridors). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/SpawnAreaSampler.js. Verbatim +// semantics. + +import type { PlanarPoint } from "./planar-utils.ts"; + +const DEFAULT_MAX_ATTEMPTS = 40; + +export const SPAWN_REGION_TYPES = Object.freeze({ + RECT: "rect", + CIRCLE: "circle", + POLYGON: "polygon", + SEGMENT_CORRIDOR: "segmentCorridor", +} as const); + +export interface PlanarSegment { + start: PlanarPoint; + end: PlanarPoint; +} + +export interface RectSpawnRegion { + type: typeof SPAWN_REGION_TYPES.RECT; + center: PlanarPoint; + size: PlanarPoint; + clearance?: number; +} + +export interface CircleSpawnRegion { + type: typeof SPAWN_REGION_TYPES.CIRCLE; + center: PlanarPoint; + radius: number; + clearance?: number; +} + +export interface PolygonSpawnRegion { + type: typeof SPAWN_REGION_TYPES.POLYGON; + points: PlanarPoint[]; + clearance?: number; +} + +export interface SegmentCorridorSpawnRegion { + type: typeof SPAWN_REGION_TYPES.SEGMENT_CORRIDOR; + segments: PlanarSegment[]; + halfWidth: number; + clearance?: number; +} + +export type SpawnRegion = + | RectSpawnRegion + | CircleSpawnRegion + | PolygonSpawnRegion + | SegmentCorridorSpawnRegion; + +export interface PlanarBounds { + rightMin: number; + rightMax: number; + forwardMin: number; + forwardMax: number; +} + +/** Any prng with a uniform draw qualifies (RandomGenerator does). */ +export interface UniformPrng { + uniform(min: number, max: number): number; +} + +function distanceSqPointToSegment(point: PlanarPoint, start: PlanarPoint, end: PlanarPoint): number { + const deltaRight = end.right - start.right; + const deltaForward = end.forward - start.forward; + const lengthSq = deltaRight * deltaRight + deltaForward * deltaForward; + + if (lengthSq === 0) { + const right = point.right - start.right; + const forward = point.forward - start.forward; + return right * right + forward * forward; + } + + const t = Math.max(0, Math.min(1, ( + (point.right - start.right) * deltaRight + + (point.forward - start.forward) * deltaForward + ) / lengthSq)); + const right = point.right - (start.right + deltaRight * t); + const forward = point.forward - (start.forward + deltaForward * t); + return right * right + forward * forward; +} + +function pointInPolygon(point: PlanarPoint, points: readonly PlanarPoint[]): boolean { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { + const a = points[i]; + const b = points[j]; + if ((a.forward > point.forward) === (b.forward > point.forward)) continue; + + const edgeRight = a.right + ( + (point.forward - a.forward) * (b.right - a.right) + ) / (b.forward - a.forward); + if (point.right < edgeRight) inside = !inside; + } + return inside; +} + +function pointInRegion(point: PlanarPoint, region: SpawnRegion, padding = 0): boolean { + if (region.type === SPAWN_REGION_TYPES.RECT) { + const halfRight = region.size.right * 0.5 + padding; + const halfForward = region.size.forward * 0.5 + padding; + return halfRight >= 0 + && halfForward >= 0 + && Math.abs(point.right - region.center.right) <= halfRight + && Math.abs(point.forward - region.center.forward) <= halfForward; + } + + if (region.type === SPAWN_REGION_TYPES.CIRCLE) { + const radius = region.radius + padding; + const right = point.right - region.center.right; + const forward = point.forward - region.center.forward; + return radius >= 0 && right * right + forward * forward <= radius * radius; + } + + if (region.type === SPAWN_REGION_TYPES.POLYGON) { + return pointInPolygon(point, region.points); + } + + if (region.type === SPAWN_REGION_TYPES.SEGMENT_CORRIDOR) { + const halfWidth = region.halfWidth + padding; + return halfWidth >= 0 && region.segments.some(({ start, end }) => ( + distanceSqPointToSegment(point, start, end) <= halfWidth * halfWidth + )); + } + + return false; +} + +function pointAllowed(point: PlanarPoint, { + radius = 0, + spawnRegions = [] as readonly SpawnRegion[], + blockRegions = [] as readonly SpawnRegion[], +}): boolean { + const inSpawnRegion = spawnRegions.length === 0 + || spawnRegions.some((region) => pointInRegion(point, region, -radius)); + + return inSpawnRegion && !blockRegions.some((region) => ( + pointInRegion(point, region, radius + (region.clearance ?? 0)) + )); +} + +function addPoint(bounds: PlanarBounds, point: PlanarPoint): void { + bounds.rightMin = Math.min(bounds.rightMin, point.right); + bounds.rightMax = Math.max(bounds.rightMax, point.right); + bounds.forwardMin = Math.min(bounds.forwardMin, point.forward); + bounds.forwardMax = Math.max(bounds.forwardMax, point.forward); +} + +function addRegion(bounds: PlanarBounds, region: SpawnRegion): void { + if (region.type === SPAWN_REGION_TYPES.RECT) { + addPoint(bounds, { + right: region.center.right - region.size.right * 0.5, + forward: region.center.forward - region.size.forward * 0.5, + }); + addPoint(bounds, { + right: region.center.right + region.size.right * 0.5, + forward: region.center.forward + region.size.forward * 0.5, + }); + return; + } + + if (region.type === SPAWN_REGION_TYPES.CIRCLE) { + addPoint(bounds, { + right: region.center.right - region.radius, + forward: region.center.forward - region.radius, + }); + addPoint(bounds, { + right: region.center.right + region.radius, + forward: region.center.forward + region.radius, + }); + return; + } + + if (region.type === SPAWN_REGION_TYPES.POLYGON) { + for (const point of region.points) addPoint(bounds, point); + return; + } + + if (region.type === SPAWN_REGION_TYPES.SEGMENT_CORRIDOR) { + for (const { start, end } of region.segments) { + addPoint(bounds, { right: start.right - region.halfWidth, forward: start.forward - region.halfWidth }); + addPoint(bounds, { right: start.right + region.halfWidth, forward: start.forward + region.halfWidth }); + addPoint(bounds, { right: end.right - region.halfWidth, forward: end.forward - region.halfWidth }); + addPoint(bounds, { right: end.right + region.halfWidth, forward: end.forward + region.halfWidth }); + } + return; + } +} + +function boundsForRegions(regions: readonly SpawnRegion[]): PlanarBounds | null { + if (regions.length === 0) return null; + + const bounds: PlanarBounds = { + rightMin: Infinity, + rightMax: -Infinity, + forwardMin: Infinity, + forwardMax: -Infinity, + }; + for (const region of regions) addRegion(bounds, region); + return bounds; +} + +function samplePoint(prng: UniformPrng, bounds: PlanarBounds): PlanarPoint { + return { + right: prng.uniform(bounds.rightMin, bounds.rightMax), + forward: prng.uniform(bounds.forwardMin, bounds.forwardMax), + }; +} + +export interface SpawnAreaSamplerOptions { + bounds: PlanarBounds; + spawnRegions?: SpawnRegion[]; + blockRegions?: SpawnRegion[]; + maxAttempts?: number; +} + +export class SpawnAreaSampler { + bounds: PlanarBounds; + spawnRegions: SpawnRegion[]; + blockRegions: SpawnRegion[]; + maxAttempts: number; + sampleBounds: PlanarBounds; + + constructor({ + bounds, + spawnRegions = [], + blockRegions = [], + maxAttempts = DEFAULT_MAX_ATTEMPTS, + }: SpawnAreaSamplerOptions) { + this.bounds = bounds; + this.spawnRegions = spawnRegions; + this.blockRegions = blockRegions; + this.maxAttempts = maxAttempts; + this.sampleBounds = boundsForRegions(spawnRegions) ?? bounds; + } + + allows(point: PlanarPoint, radius = 0): boolean { + return pointAllowed(point, { + radius, + spawnRegions: this.spawnRegions, + blockRegions: this.blockRegions, + }); + } + + sample(prng: UniformPrng, radius = 0): PlanarPoint | null { + for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) { + const point = samplePoint(prng, this.sampleBounds); + if (this.allows(point, radius)) return point; + } + return null; + } +} diff --git a/playset/modules/world/environment/terrain-mesh-factory.ts b/playset/modules/world/environment/terrain-mesh-factory.ts new file mode 100644 index 0000000..078904d --- /dev/null +++ b/playset/modules/world/environment/terrain-mesh-factory.ts @@ -0,0 +1,130 @@ +// playset/modules/world/environment/terrain-mesh-factory.ts — bakes a +// terrain sampler into a scene3d heightfield mesh and registers the sampler +// as the CollisionWorld ground authority. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/TerrainMeshFactory.js. Deliberate +// changes for the scene3d surface: +// - The original built an indexed (segments+1)² BufferGeometry; the same +// regular grid maps directly onto geomHeightfield(size, size, cols, +// rows, heights, colors) — the host owns tessellation/normals, so the +// index buffer and computeVertexNormals are gone. Heights are sampled in +// the original's row/col order (forward, then right, both from -size/2). +// - Heightfields live in world axes (+Y up); non-default bases would need +// a node rotation — GameBlocks only ever uses the default basis here. +// - materialOptions (roughness/metalness) has no fixed-function analog and +// is accepted but ignored; the material is vertex-colored. +// - createTerrainTrimeshCollider(world, rapier, mesh) is replaced by +// registerTerrainCollider(world, sampler): the CollisionWorld's ground +// authority is the SAMPLER (world.setTerrain), not a trimesh — exact +// heights instead of triangle interpolation, and no Rapier. It returns +// nothing (there is no body/collider pair to hand back). + +import { MAT, type Scene3D, type SceneNode } from "../../../scene3d/client.ts"; +import type { CollisionWorld, TerrainLike } from "../../physics/collision-world.ts"; +import type { WorldBasis } from "../../math/world-basis.ts"; + +/** spec ABGR byte order: (a<<24)|(b<<16)|(g<<8)|r. Local on purpose. */ +function rgbToAbgr(hex: number, alpha = 255): number { + const r = (hex >> 16) & 255; + const g = (hex >> 8) & 255; + const b = hex & 255; + return (((alpha & 255) << 24) | (b << 16) | (g << 8) | r) >>> 0; +} + +/** What createTerrainMesh needs from a sampler (all three samplers qualify). */ +export interface MeshTerrainSampler { + basis?: WorldBasis; + sample( + right: number, + forward: number, + ): { height: number; color?: { r: number; g: number; b: number } | null } | null | undefined; + noise2D?(right: number, forward: number, seedOffset?: number): number; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function defaultTerrainColor(height = 0, colorNoise = 0): { r: number; g: number; b: number } { + const grassMix = clamp01((height + 4) / 12); + const ridgeMix = clamp01((height - 8) / 18); + return { + r: 0.22 + grassMix * 0.1 + ridgeMix * 0.16 + colorNoise, + g: 0.32 + grassMix * 0.18 - ridgeMix * 0.02 + colorNoise * 0.6, + b: 0.16 + grassMix * 0.08 + ridgeMix * 0.09 + colorNoise * 0.35, + }; +} + +export interface CreateTerrainMeshOptions { + scene: Scene3D; + terrainSampler: MeshTerrainSampler; + size?: number; + segments?: number; + /** Accepted for API compatibility; ignored (see header). */ + materialOptions?: Record; +} + +export function createTerrainMesh({ + scene, + terrainSampler, + size = 184, + segments = 220, + materialOptions = {}, +}: CreateTerrainMeshOptions): SceneNode { + void materialOptions; + if (!terrainSampler || typeof terrainSampler.sample !== "function") { + throw new Error("createTerrainMesh: terrainSampler.sample(right, forward) is required"); + } + + const safeSize = Math.max(0.001, size); + const safeSegments = Math.max(1, Math.floor(segments)); + const vertexSide = safeSegments + 1; + const vertexCount = vertexSide * vertexSide; + const heights = new Float32Array(vertexCount); + const colors = new Float32Array(vertexCount * 3); + const halfSize = safeSize * 0.5; + const step = safeSize / safeSegments; + + for (let row = 0; row <= safeSegments; row += 1) { + for (let col = 0; col <= safeSegments; col += 1) { + const i = row * vertexSide + col; + const right = -halfSize + col * step; + const forward = -halfSize + row * step; + const sample = terrainSampler.sample(right, forward) ?? { height: 0 }; + const height = sample.height; + heights[i] = height; + + let colorValue = "color" in sample ? sample.color : null; + if (!colorValue) { + const colorNoise = typeof terrainSampler.noise2D === "function" + ? terrainSampler.noise2D(right * 0.21 + 13, forward * 0.21 - 5, 103) * 0.08 + : 0; + colorValue = defaultTerrainColor(height, colorNoise); + } + + colors[i * 3 + 0] = colorValue.r; + colors[i * 3 + 1] = colorValue.g; + colors[i * 3 + 2] = colorValue.b; + } + } + + const geomId = scene.heightfield(safeSize, safeSize, vertexSide, vertexSide, heights, colors); + const matId = scene.material(rgbToAbgr(0xffffff), MAT.vertexColors); + return scene.mesh(geomId, matId); +} + +/** + * The CollisionWorld replacement for the Rapier terrain trimesh: the sampler + * itself becomes the ground authority (world.setTerrain). Friction and + * restitution have no CollisionWorld analog. + */ +export function registerTerrainCollider(world: CollisionWorld, sampler: TerrainLike): void { + if (!world) { + throw new Error("registerTerrainCollider: a CollisionWorld is required"); + } + if (!sampler || typeof sampler.heightAt !== "function") { + throw new Error("registerTerrainCollider: sampler.heightAt(right, forward) is required"); + } + world.setTerrain(sampler); +} diff --git a/playset/modules/world/environment/terrain-sampler.ts b/playset/modules/world/environment/terrain-sampler.ts new file mode 100644 index 0000000..fb4230c --- /dev/null +++ b/playset/modules/world/environment/terrain-sampler.ts @@ -0,0 +1,434 @@ +// playset/modules/world/environment/terrain-sampler.ts — the procedural +// terrain height/normal/color samplers (natural hills, road-flattened +// terrain, archipelago islands). Pure math; no scene graph. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/TerrainSampler.js. Verbatim semantics. +// NaturalTerrainSampler.colorAt returns a playset/math Color (offsetHSL +// tinting, like the original's THREE.Color); the other two return plain +// {r,g,b} objects exactly as the original. + +import { Color, Vector3 } from "../../../math/index.ts"; +import { clamp, fract, lerp } from "../../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; +import type { PlanarSegment } from "./spawn-area-sampler.ts"; + +export interface RGBTriplet { + r: number; + g: number; + b: number; +} + +export interface NaturalTerrainSamplerOptions { + baseHeight?: number; + undulation?: number; + hillFrequency?: number; + colorHeightThreshold?: number; + normalStep?: number; + basis?: WorldBasis; +} + +export class NaturalTerrainSampler { + baseHeight: number; + undulation: number; + hillFrequency: number; + colorHeightThreshold: number; + normalStep: number; + basis: WorldBasis; + + constructor({ + baseHeight = 0, + undulation = 3.6, + hillFrequency = 1, + colorHeightThreshold = 2.1, + normalStep = 0.2, + basis = DEFAULT_WORLD_BASIS, + }: NaturalTerrainSamplerOptions) { + this.baseHeight = baseHeight; + this.undulation = undulation; + this.hillFrequency = hillFrequency; + this.colorHeightThreshold = colorHeightThreshold; + this.normalStep = normalStep; + this.basis = basis; + } + + heightAt(right: number, forward: number): number { + const baseHeight = this.baseHeight; + const undulation = this.undulation; + const hillFrequency = this.hillFrequency; + const hillA = Math.sin(right * 0.055 * hillFrequency) + * Math.cos(forward * 0.047 * hillFrequency) + * (2.2 / 3.6); + const hillB = Math.sin((right + forward) * 0.022 * hillFrequency) + * (1.4 / 3.6); + return baseHeight + (hillA + hillB) * undulation; + } + + normalAt(right: number, forward: number, step = this.normalStep): Vector3 { + const epsilon = Math.max(0.0001, step); + const rightHigh = this.heightAt(right + epsilon, forward); + const rightLow = this.heightAt(right - epsilon, forward); + const forwardHigh = this.heightAt(right, forward + epsilon); + const forwardLow = this.heightAt(right, forward - epsilon); + + return this.basis.surfaceNormalFromSlopes( + (rightHigh - rightLow) / (2 * epsilon), + (forwardHigh - forwardLow) / (2 * epsilon), + ); + } + + colorAt(right: number, forward: number): Color { + const height = this.heightAt(right, forward); + const color = new Color(height > this.colorHeightThreshold ? 0x8fa55f : 0x639b4f); + const tint = Math.sin(right * 12.9898 + forward * 78.233) * 43758.5453; + const noise = tint - Math.floor(tint); + color.offsetHSL((noise - 0.5) * 0.03, (noise - 0.45) * 0.035, (noise - 0.5) * 0.05); + return color; + } + + sample(right: number, forward: number): { height: number; normal: Vector3; color: Color } { + const height = this.heightAt(right, forward); + return { + height, + normal: this.normalAt(right, forward), + color: this.colorAt(right, forward), + }; + } +} + +interface CachedRoadSegment { + start: { right: number; forward: number }; + end: { right: number; forward: number }; + deltaRight: number; + deltaForward: number; + lengthSq: number; +} + +export interface RoadTerrainSamplerOptions { + seed?: number; + roadHalfWidth?: number; + roadSegments?: PlanarSegment[]; + roadHeight?: number; + roadFlatnessAtHalfWidth?: number; + largeWaveScale?: number; + largeWaveAmp?: number; + midNoiseScale?: number; + midNoiseAmp?: number; + normalStep?: number; + basis?: WorldBasis; +} + +export class RoadTerrainSampler { + seed: number; + roadHalfWidth: number; + roadHeight: number; + roadFlatnessAtHalfWidth: number; + roadSegmentCache: CachedRoadSegment[]; + largeWaveScale: number; + largeWaveAmp: number; + midNoiseScale: number; + midNoiseAmp: number; + normalStep: number; + basis: WorldBasis; + + constructor({ + seed = 2026, + roadHalfWidth = 6, + roadSegments = [], + roadHeight = 0, + roadFlatnessAtHalfWidth = 0.8, + largeWaveScale = 0.05, + largeWaveAmp = 1.45, + midNoiseScale = 0.12, + midNoiseAmp = 1.15, + normalStep = 0.2, + basis = DEFAULT_WORLD_BASIS, + }: RoadTerrainSamplerOptions) { + this.seed = seed; + this.roadHalfWidth = roadHalfWidth; + this.roadHeight = roadHeight; + this.roadFlatnessAtHalfWidth = roadFlatnessAtHalfWidth; + this.roadSegmentCache = []; + + const sourceSegments = Array.isArray(roadSegments) ? roadSegments : []; + for (const segment of sourceSegments) { + const start = segment.start; + const end = segment.end; + const deltaRight = end.right - start.right; + const deltaForward = end.forward - start.forward; + const lengthSq = deltaRight * deltaRight + deltaForward * deltaForward; + if (lengthSq <= 1e-8) continue; + + this.roadSegmentCache.push({ + start: { right: start.right, forward: start.forward }, + end: { right: end.right, forward: end.forward }, + deltaRight, + deltaForward, + lengthSq, + }); + } + + this.largeWaveScale = largeWaveScale; + this.largeWaveAmp = largeWaveAmp; + + this.midNoiseScale = midNoiseScale; + this.midNoiseAmp = midNoiseAmp; + + this.normalStep = normalStep; + this.basis = basis; + } + + hash2D(right: number, forward: number, seedOffset = 0): number { + const seed = this.seed + seedOffset; + return fract(Math.sin(right * 127.1 + forward * 311.7 + seed * 101.3) * 43758.5453123); + } + + noise2D(right: number, forward: number, seedOffset = 0): number { + const rightIndex = Math.floor(right); + const forwardIndex = Math.floor(forward); + const rightFrac = right - rightIndex; + const forwardFrac = forward - forwardIndex; + + const rightBlend = rightFrac * rightFrac * (3 - 2 * rightFrac); + const forwardBlend = forwardFrac * forwardFrac * (3 - 2 * forwardFrac); + + const a = this.hash2D(rightIndex, forwardIndex, seedOffset); + const b = this.hash2D(rightIndex + 1, forwardIndex, seedOffset); + const c = this.hash2D(rightIndex, forwardIndex + 1, seedOffset); + const d = this.hash2D(rightIndex + 1, forwardIndex + 1, seedOffset); + + const rightLow = a + (b - a) * rightBlend; + const rightHigh = c + (d - c) * rightBlend; + return (rightLow + (rightHigh - rightLow) * forwardBlend) * 2 - 1; + } + + distanceToRoad(right: number, forward: number): number { + let nearestSq = Infinity; + for (const segment of this.roadSegmentCache) { + const relativeRight = right - segment.start.right; + const relativeForward = forward - segment.start.forward; + const t = clamp( + (relativeRight * segment.deltaRight + relativeForward * segment.deltaForward) / segment.lengthSq, + 0, + 1, + ); + const nearestRight = segment.start.right + segment.deltaRight * t; + const nearestForward = segment.start.forward + segment.deltaForward * t; + const distRight = right - nearestRight; + const distForward = forward - nearestForward; + nearestSq = Math.min(nearestSq, distRight * distRight + distForward * distForward); + } + return Math.sqrt(nearestSq); + } + + roadFlatnessAt(right: number, forward: number): number { + const distanceRatio = this.distanceToRoad(right, forward) / this.roadHalfWidth; + return this.roadFlatnessAtHalfWidth ** (distanceRatio * distanceRatio); + } + + heightAt(right: number, forward: number): number { + const roadFlatness = this.roadFlatnessAt(right, forward); + const largeWave = + Math.sin(right * this.largeWaveScale) * this.largeWaveAmp + + Math.cos(forward * this.largeWaveScale) * this.largeWaveAmp; + const midNoise = this.noise2D(right * this.midNoiseScale, forward * this.midNoiseScale, 31) * this.midNoiseAmp; + const terrainHeight = largeWave + midNoise; + return lerp(terrainHeight, this.roadHeight, roadFlatness); + } + + normalAt(right: number, forward: number, step = this.normalStep): Vector3 { + const e = Math.max(0.0001, step); + const rightHigh = this.heightAt(right + e, forward); + const rightLow = this.heightAt(right - e, forward); + const forwardHigh = this.heightAt(right, forward + e); + const forwardLow = this.heightAt(right, forward - e); + + return this.basis.surfaceNormalFromSlopes( + (rightHigh - rightLow) / (2 * e), + (forwardHigh - forwardLow) / (2 * e), + ); + } + + colorAt(right: number, forward: number): RGBTriplet { + const roadFlatness = this.roadFlatnessAt(right, forward); + const colorNoise = this.noise2D(right * 0.21 + 13, forward * 0.21 - 5, 103) * 0.08; + return { + r: 0.2 + roadFlatness * 0.25 + colorNoise, + g: 0.3 + roadFlatness * 0.18 + colorNoise * 0.6, + b: 0.15 + roadFlatness * 0.2 + colorNoise * 0.35, + }; + } + + sample(right: number, forward: number): { height: number; normal: Vector3; color: RGBTriplet } { + const height = this.heightAt(right, forward); + return { + height, + normal: this.normalAt(right, forward), + color: this.colorAt(right, forward), + }; + } +} + +export interface ArchipelagoIsland { + right: number; + forward: number; + radiusRight: number; + radiusForward: number; + height: number; +} + +export interface ArchipelagoTerrainSamplerOptions { + seed?: number; + normalStep?: number; + seaLevel?: number; + underwaterFloorDrop?: number; + shorelineBlend?: number; + islands?: ArchipelagoIsland[] | null; + basis?: WorldBasis; +} + +export class ArchipelagoTerrainSampler { + seed: number; + normalStep: number; + seaLevel: number; + underwaterFloorDrop: number; + shorelineBlend: number; + basis: WorldBasis; + islands: ArchipelagoIsland[]; + + constructor({ + seed = 20260424, + normalStep = 1.2, + seaLevel = 0, + underwaterFloorDrop = 18, + shorelineBlend = 10, + islands = null, + basis = DEFAULT_WORLD_BASIS, + }: ArchipelagoTerrainSamplerOptions) { + this.seed = seed; + this.normalStep = normalStep; + this.seaLevel = seaLevel; + this.underwaterFloorDrop = underwaterFloorDrop; + this.shorelineBlend = Math.max(0.001, shorelineBlend); + this.basis = basis; + this.islands = islands ?? [ + { right: -285, forward: -250, radiusRight: 180, radiusForward: 170, height: 64 }, + { right: -110, forward: -70, radiusRight: 240, radiusForward: 225, height: 94 }, + { right: 115, forward: 90, radiusRight: 255, radiusForward: 205, height: 112 }, + { right: 245, forward: -165, radiusRight: 200, radiusForward: 185, height: 82 }, + { right: 15, forward: -280, radiusRight: 200, radiusForward: 150, height: 56 }, + ]; + } + + hash2D(right: number, forward: number, seedOffset = 0): number { + const seed = this.seed + seedOffset; + return fract(Math.sin(right * 127.1 + forward * 311.7 + seed * 0.017) * 43758.5453123); + } + + noise2D(right: number, forward: number, seedOffset = 0): number { + const rightIndex = Math.floor(right); + const forwardIndex = Math.floor(forward); + const rightFrac = right - rightIndex; + const forwardFrac = forward - forwardIndex; + const rightBlend = rightFrac * rightFrac * (3 - 2 * rightFrac); + const forwardBlend = forwardFrac * forwardFrac * (3 - 2 * forwardFrac); + + const a = this.hash2D(rightIndex, forwardIndex, seedOffset); + const b = this.hash2D(rightIndex + 1, forwardIndex, seedOffset); + const c = this.hash2D(rightIndex, forwardIndex + 1, seedOffset); + const d = this.hash2D(rightIndex + 1, forwardIndex + 1, seedOffset); + + const rightLow = lerp(a, b, rightBlend); + const rightHigh = lerp(c, d, rightBlend); + return lerp(rightLow, rightHigh, forwardBlend) * 2 - 1; + } + + fbm(right: number, forward: number, octaves = 4, lacunarity = 2, gain = 0.5, seedOffset = 0): number { + let amplitude = 1; + let frequency = 1; + let sum = 0; + let normalization = 0; + + for (let index = 0; index < octaves; index += 1) { + sum += this.noise2D(right * frequency, forward * frequency, seedOffset + index * 37) * amplitude; + normalization += amplitude; + amplitude *= gain; + frequency *= lacunarity; + } + + return normalization > 0 ? sum / normalization : 0; + } + + islandContribution(right: number, forward: number, island: ArchipelagoIsland): number { + const dRight = (right - island.right) / island.radiusRight; + const dForward = (forward - island.forward) / island.radiusForward; + const falloff = Math.exp(-(dRight * dRight + dForward * dForward) * 2.4); + return island.height * falloff; + } + + rawHeightAt(right: number, forward: number): number { + let landMass = -22; + for (const island of this.islands) { + landMass += this.islandContribution(right, forward, island); + } + + const spineMask = Math.exp(-(((right - 28) * (right - 28)) / 32000 + ((forward + 12) * (forward + 12)) / 92000)); + const spineNoise = this.fbm(right * 0.016, forward * 0.012, 5, 2, 0.55, 91); + landMass += spineMask * (58 + spineNoise * 28); + + const coastalNoise = this.fbm(right * 0.0052, forward * 0.0052, 4, 2, 0.55, 23) * 12; + const ruggedMask = clamp((landMass + 12) / 90, 0, 1); + const ruggedDetail = this.fbm(right * 0.021, forward * 0.021, 4, 2.1, 0.52, 147) * 10; + + return landMass + coastalNoise + ruggedDetail * ruggedMask; + } + + heightAt(right: number, forward: number): number { + const rawHeight = this.rawHeightAt(right, forward); + if (rawHeight >= this.seaLevel) return rawHeight; + + const submergedDepth = this.seaLevel - rawHeight; + const floorDrop = this.underwaterFloorDrop * clamp(submergedDepth / this.shorelineBlend, 0, 1); + return rawHeight - floorDrop; + } + + normalAt(right: number, forward: number, step = this.normalStep): Vector3 { + const epsilon = Math.max(0.2, step); + const rightHigh = this.heightAt(right + epsilon, forward); + const rightLow = this.heightAt(right - epsilon, forward); + const forwardHigh = this.heightAt(right, forward + epsilon); + const forwardLow = this.heightAt(right, forward - epsilon); + return this.basis.surfaceNormalFromSlopes( + (rightHigh - rightLow) / (2 * epsilon), + (forwardHigh - forwardLow) / (2 * epsilon), + ); + } + + colorAt(right: number, forward: number): RGBTriplet { + const height = this.heightAt(right, forward); + const colorNoise = this.fbm(right * 0.03 + 5.2, forward * 0.03 - 1.7, 2, 2, 0.5, 211) * 0.05; + + if (height < 5) { + return { r: 0.64 + colorNoise, g: 0.59 + colorNoise * 0.8, b: 0.37 + colorNoise * 0.5 }; + } + if (height < 26) { + return { r: 0.28 + colorNoise * 0.7, g: 0.43 + colorNoise, b: 0.19 + colorNoise * 0.4 }; + } + if (height < 72) { + return { r: 0.21 + colorNoise * 0.6, g: 0.34 + colorNoise * 0.7, b: 0.24 + colorNoise * 0.4 }; + } + if (height < 118) { + return { r: 0.38 + colorNoise * 0.4, g: 0.37 + colorNoise * 0.35, b: 0.35 + colorNoise * 0.25 }; + } + return { r: 0.66 + colorNoise * 0.2, g: 0.68 + colorNoise * 0.2, b: 0.72 + colorNoise * 0.2 }; + } + + sample(right: number, forward: number): { height: number; normal: Vector3; color: RGBTriplet } { + const height = this.heightAt(right, forward); + return { + height, + normal: this.normalAt(right, forward), + color: this.colorAt(right, forward), + }; + } +} diff --git a/playset/modules/world/environment/world-bounds-collider-factory.ts b/playset/modules/world/environment/world-bounds-collider-factory.ts new file mode 100644 index 0000000..4aa72e3 --- /dev/null +++ b/playset/modules/world/environment/world-bounds-collider-factory.ts @@ -0,0 +1,104 @@ +// playset/modules/world/environment/world-bounds-collider-factory.ts — four +// static wall colliders fencing a rectangular playable area. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/environment/WorldBoundsColliderFactory.js. +// Deliberate changes: the injected Rapier `world`+`rapier` pair becomes a +// CollisionWorld, each wall is one addCuboid (solid), and the return value +// is the collider handles instead of {body, collider} pairs. friction and +// restitution are accepted for API compatibility but have no CollisionWorld +// analog. + +import { + DEFAULT_WORLD_BASIS, + type WorldBasis, +} from "../../math/world-basis.ts"; +import type { CollisionWorld, ColliderHandle } from "../../physics/collision-world.ts"; + +export interface CreateWorldBoundsCollidersOptions { + world: CollisionWorld; + minRight?: number; + maxRight?: number; + minForward?: number; + maxForward?: number; + wallThickness?: number; + wallHeight?: number; + centerUp?: number; + /** Accepted for API compatibility; ignored (see header). */ + friction?: number; + /** Accepted for API compatibility; ignored (see header). */ + restitution?: number; + basis?: WorldBasis; +} + +export function createWorldBoundsColliders({ + world, + minRight = -88, + maxRight = 88, + minForward = -88, + maxForward = 88, + wallThickness = 1.6, + wallHeight = 16, + centerUp = 0, + friction = 1, + restitution = 0, + basis = DEFAULT_WORLD_BASIS, +}: CreateWorldBoundsCollidersOptions): ColliderHandle[] { + void friction; + void restitution; + if (!world) { + throw new Error("World bounds collider factory requires a CollisionWorld"); + } + if (!(maxRight > minRight) || !(maxForward > minForward)) { + throw new Error("createWorldBoundsColliders: min bounds must be smaller than max bounds"); + } + + const worldBasis = basis; + const rotation = worldBasis.threeObjectCanonicalToBasisQuaternion(); + const spanRight = maxRight - minRight; + const spanForward = maxForward - minForward; + const centerRight = (minRight + maxRight) * 0.5; + const centerForward = (minForward + maxForward) * 0.5; + + const walls = [ + { + right: minRight - wallThickness * 0.5, + forward: centerForward, + spanRight: wallThickness, + spanForward: spanForward + wallThickness, + }, + { + right: maxRight + wallThickness * 0.5, + forward: centerForward, + spanRight: wallThickness, + spanForward: spanForward + wallThickness, + }, + { + right: centerRight, + forward: minForward - wallThickness * 0.5, + spanRight: spanRight + wallThickness, + spanForward: wallThickness, + }, + { + right: centerRight, + forward: maxForward + wallThickness * 0.5, + spanRight: spanRight + wallThickness, + spanForward: wallThickness, + }, + ]; + + return walls.map((wall) => { + const position = worldBasis.fromBasisComponents(wall.right, centerUp, wall.forward); + const halfExtents = worldBasis.fromBasisComponents( + wall.spanRight * 0.5, + wallHeight * 0.5, + wall.spanForward * 0.5, + ); + return world.addCuboid({ + position, + halfExtents, + quaternion: rotation, + solid: true, + }); + }); +} diff --git a/playset/modules/world/object/factory/airplane-visual-factory.ts b/playset/modules/world/object/factory/airplane-visual-factory.ts new file mode 100644 index 0000000..68d0008 --- /dev/null +++ b/playset/modules/world/object/factory/airplane-visual-factory.ts @@ -0,0 +1,198 @@ +// playset/modules/world/object/factory/airplane-visual-factory.ts — the +// jet-fighter airframe: fuselage, nose, canopy, wings, tail, engines, jet +// flames, optional debug centroid + target ring. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/factory/AirplaneVisualFactory.js. +// Deliberate changes for the scene3d surface: +// - emissive/metalness/roughness options are accepted for API parity but +// ignored (fixed-function lighting); canopy transparency folds the +// opacity into the material's ABGR alpha. Cast-shadow flags dropped +// (blob decals — world/blob-shadow.ts). +// - part rotations (rotation.x = ±π/2) are node quaternions (geometry ops +// take no transforms). +// - the airframe recenter used Box3.setFromObject (vertex-sampled AABB); +// the sim never tessellates, so the recenter is the analytic-primitive +// AABB center folded to a constant: (0, 0.315, -0.4525) — within <2% of +// a part radius of three's value, canopy on or off (it never touches +// the bounds; the AABB is computed before flames/glow, like the +// original). +// - centroid marker's depthTest:false/renderOrder and node names +// (PlaneCentroidMarker / targetRingName) have no scene3d analog; +// targetRingName is accepted and ignored. + +import { Euler } from "../../../../math/euler.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../../scene3d/client.ts"; +import { JetFlameLocalVisual } from "../../visual-effects/jet-flame.ts"; +import { rgbToAbgr } from "../../color-utils.ts"; + +export interface AirplaneVisualOptions { + scale?: number; + bodyColor?: number; + bodyEmissive?: number; + bodyEmissiveIntensity?: number; + bodyMetalness?: number; + bodyRoughness?: number; + accentColor?: number; + accentEmissive?: number; + accentEmissiveIntensity?: number; + accentMetalness?: number; + accentRoughness?: number; + canopyColor?: number; + canopyEmissive?: number; + canopyEmissiveIntensity?: number; + canopyOpacity?: number; + showCanopy?: boolean; + showJetFlames?: boolean; + showEngineGlow?: boolean; + engineGlowColor?: number; + engineGlowOpacity?: number; + showCentroid?: boolean; + centroidColor?: number; + showTargetRing?: boolean; + targetRingName?: string; + targetRingColor?: number; + targetRingRadius?: number; + targetRingTube?: number; + targetRingOpacity?: number; +} + +export interface AirplaneVisual { + group: SceneNode; + airframe: SceneNode; + jetFlames: JetFlameLocalVisual[]; + targetRing: SceneNode | null; +} + +export function createAirplaneVisual( + scene: Scene3D, + { + scale = 8, + bodyColor = 0xe1ebf5, + bodyEmissive = 0x000000, + bodyEmissiveIntensity = 0, + bodyMetalness = 0.78, + bodyRoughness = 0.28, + accentColor = 0xffa33a, + accentEmissive = 0x5a2200, + accentEmissiveIntensity = 0.26, + accentMetalness = 0.44, + accentRoughness = 0.42, + canopyColor = 0x87cefa, + canopyEmissive = 0x102c4b, + canopyEmissiveIntensity = 0.4, + canopyOpacity = 0.9, + showCanopy = true, + showJetFlames = true, + showEngineGlow = false, + engineGlowColor = 0xffb35b, + engineGlowOpacity = 0.78, + showCentroid = false, + centroidColor = 0xff2bd6, + showTargetRing = false, + targetRingName = "AirplaneTargetRing", + targetRingColor = 0xff775c, + targetRingRadius = 2.15, + targetRingTube = 0.035, + targetRingOpacity = 0.36, + }: AirplaneVisualOptions = {}, +): AirplaneVisual { + // Standard-material knobs with no fixed-function analog (see header). + void bodyEmissive; void bodyEmissiveIntensity; void bodyMetalness; void bodyRoughness; + void accentEmissive; void accentEmissiveIntensity; void accentMetalness; void accentRoughness; + void canopyEmissive; void canopyEmissiveIntensity; void targetRingName; + + const group = scene.node(); + const airframe = scene.node(group); + + const bodyMaterial = scene.material(rgbToAbgr(bodyColor), 0); + const accentMaterial = scene.material(rgbToAbgr(accentColor), 0); + const canopyMaterial = scene.material(rgbToAbgr(canopyColor, canopyOpacity), MAT.transparent); + + const noseward = new Euler(-Math.PI / 2, 0, 0); // cylinder/cone head +Y → -Z + const tailward = new Euler(Math.PI / 2, 0, 0); + + const fuselage = scene.mesh(scene.cylinder(0.28, 0.36, 3.6, 18), bodyMaterial, airframe); + fuselage.quaternion.setFromEuler(noseward); + + const nose = scene.mesh(scene.cone(0.28, 0.96, 18), accentMaterial, airframe); + nose.quaternion.setFromEuler(noseward); + nose.position.z = -2.25; + + if (showCanopy) { + const canopy = scene.mesh(scene.sphere(0.32, 16), canopyMaterial, airframe); + canopy.scale.set(1.05, 0.7, 1.8); + canopy.position.set(0, 0.25, -0.35); + } + + const wing = scene.mesh(scene.box(1.55, 0.05, 0.38), bodyMaterial, airframe); // (3.1, 0.1, 0.76) + wing.position.set(0, -0.02, 0.16); + + const wingTip = scene.mesh(scene.box(1.8, 0.02, 0.1), accentMaterial, airframe); // (3.6, 0.04, 0.2) + wingTip.position.set(0, 0.06, -0.04); + + const tailWing = scene.mesh(scene.box(0.67, 0.04, 0.21), bodyMaterial, airframe); // (1.34, 0.08, 0.42) + tailWing.position.set(0, 0.28, 1.22); + + const tailFin = scene.mesh(scene.box(0.04, 0.43, 0.28), accentMaterial, airframe); // (0.08, 0.86, 0.56) + tailFin.position.set(0, 0.56, 1.18); + + const engineGeometry = scene.cylinder(0.14, 0.19, 1.15, 12); + for (const side of [-1, 1]) { + const engine = scene.mesh(engineGeometry, bodyMaterial, airframe); + engine.quaternion.setFromEuler(tailward); + engine.position.set(side * 0.34, -0.1, 1.25); + } + + // Box3.setFromObject center, analytic (see header): parts span + // x ±1.8, y [-0.36, 0.99], z [-2.73, 1.825] → center (0, 0.315, -0.4525). + airframe.position.set(0, -0.315, 0.4525); + + const jetFlames: JetFlameLocalVisual[] = []; + if (showJetFlames) { + const flameLeft = new JetFlameLocalVisual(scene); + const flameRight = new JetFlameLocalVisual(scene); + flameLeft.group.position.set(-0.34, -0.1, 1.78); + flameRight.group.position.set(0.34, -0.1, 1.78); + airframe.add(flameLeft.group); + airframe.add(flameRight.group); + jetFlames.push(flameLeft, flameRight); + } + + if (showEngineGlow) { + const glowMaterial = scene.material( + rgbToAbgr(engineGlowColor, engineGlowOpacity), + MAT.transparent, + ); + for (const side of [-1, 1]) { + const glow = scene.mesh(scene.sphere(0.12, 10), glowMaterial, airframe); + glow.position.set(side * 0.32, -0.1, 1.82); + } + } + + if (showCentroid) { + scene.mesh( + scene.sphere(0.09, 16), + scene.material(rgbToAbgr(centroidColor), MAT.unlit), + group, + ); + } + + let targetRing: SceneNode | null = null; + if (showTargetRing) { + targetRing = scene.mesh( + scene.torus(targetRingRadius, targetRingTube, 8, 36), + scene.material(rgbToAbgr(targetRingColor, targetRingOpacity), MAT.unlit | MAT.transparent), + group, + ); + } + + group.scale.setScalar(scale); + + return { + group, + airframe, + jetFlames, + targetRing, + }; +} diff --git a/playset/modules/world/object/factory/car-visual-factory.ts b/playset/modules/world/object/factory/car-visual-factory.ts new file mode 100644 index 0000000..c708cb9 --- /dev/null +++ b/playset/modules/world/object/factory/car-visual-factory.ts @@ -0,0 +1,104 @@ +// playset/modules/world/object/factory/car-visual-factory.ts — the box car: +// body + cabin + four steerable/spinning wheels. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/factory/CarVisualFactory.js. Deliberate +// changes for the scene3d surface: +// - metalness and cast/receive-shadow flags dropped (fixed-function +// lighting; shadows are blob decals — world/blob-shadow.ts). +// - The original baked wheelGeometry.rotateZ(π/2) into the geometry so the +// axle runs along X. Geometry ops take no transforms, so each `wheels[i]` +// entry is a bare spin node whose child mesh carries the Z-rotation — +// spinning wheels[i] about local X behaves exactly like the original. +// - ArrowHelper (debug aid) is emulated with an unlit shaft cylinder + +// cone head group pointing down -Z (length 3.6, head 0.8/0.5). + +import { Euler } from "../../../../math/euler.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../../scene3d/client.ts"; +import { rgbToAbgr } from "../../color-utils.ts"; + +export interface CarVisualOptions { + paintColor?: number; + cabinColor?: number; + wheelColor?: number; + arrowColor?: number | null; + wheelOffsets?: readonly (readonly [number, number, number])[]; +} + +export interface CarVisual { + group: SceneNode; + wheels: SceneNode[]; + wheelPivots: SceneNode[]; + forwardArrow: SceneNode | null; +} + +export function createCarVisual( + scene: Scene3D, + { + paintColor = 0xc75238, + cabinColor = 0xf4f7ff, + wheelColor = 0x181c22, + arrowColor = null, + wheelOffsets = [ + [-0.84, 0.26, -1.07], + [0.84, 0.26, -1.07], + [-0.84, 0.26, 1.07], + [0.84, 0.26, 1.07], + ], + }: CarVisualOptions = {}, +): CarVisual { + const group = scene.node(); + + const body = scene.mesh( + scene.box(0.85, 0.29, 1.5), // BoxGeometry(1.7, 0.58, 3.0) + scene.material(rgbToAbgr(paintColor), 0), + group, + ); + body.position.y = 0.42; + + const cabin = scene.mesh( + scene.box(0.62, 0.225, 0.65), // BoxGeometry(1.24, 0.45, 1.3) + scene.material(rgbToAbgr(cabinColor), 0), + group, + ); + cabin.position.set(0, 0.83, -0.1); + + const wheelMaterial = scene.material(rgbToAbgr(wheelColor), 0); + const wheelGeometry = scene.cylinder(0.35, 0.35, 0.32, 16); + const axleQuat = new Euler(0, 0, Math.PI * 0.5); // rotateZ(π/2): axle along X + + const wheels: SceneNode[] = []; + const wheelPivots: SceneNode[] = []; + for (const [x, y, z] of wheelOffsets) { + const wheelPivot = scene.node(group); + wheelPivot.position.set(x, y, z); + + const wheel = scene.node(wheelPivot); // spin node (the returned "mesh") + const axleMesh = scene.mesh(wheelGeometry, wheelMaterial, wheel); + axleMesh.quaternion.setFromEuler(axleQuat); + + wheelPivots.push(wheelPivot); + wheels.push(wheel); + } + + let forwardArrow: SceneNode | null = null; + if (arrowColor != null) { + // ArrowHelper(dir(0,0,-1), origin, length 3.6, color, head 0.8, 0.5) + forwardArrow = scene.node(group); + const arrowMat = scene.material(rgbToAbgr(arrowColor), MAT.unlit); + const along = new Euler(-Math.PI * 0.5, 0, 0); // +Y → -Z + const shaft = scene.mesh(scene.cylinder(0.02, 0.02, 2.8, 8), arrowMat, forwardArrow); + shaft.quaternion.setFromEuler(along); + shaft.position.z = -1.4; + const head = scene.mesh(scene.cone(0.25, 0.8, 8), arrowMat, forwardArrow); + head.quaternion.setFromEuler(along); + head.position.z = -3.2; + } + + return { + group, + wheels, + wheelPivots, + forwardArrow, + }; +} diff --git a/playset/modules/world/object/factory/pickup-visual-factory.ts b/playset/modules/world/object/factory/pickup-visual-factory.ts new file mode 100644 index 0000000..824cdcf --- /dev/null +++ b/playset/modules/world/object/factory/pickup-visual-factory.ts @@ -0,0 +1,108 @@ +// playset/modules/world/object/factory/pickup-visual-factory.ts — ammo / +// health / armor pickup visuals (driven by PickupObject). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/factory/PickupVisualFactory.js. Deliberate +// changes for the scene3d surface: the shared MeshStandardMaterial's +// emissive (color×0.2 at per-part intensity) / metalness 0.25 / roughness +// 0.4 have no fixed-function analog and are dropped — parts keep their base +// colors as plain lit materials. + +import { Euler } from "../../../../math/euler.ts"; +import type { Scene3D, SceneNode } from "../../../../scene3d/client.ts"; +import { rgbToAbgr } from "../../color-utils.ts"; + +export interface PickupVisual { + mesh: SceneNode; + radius: number; +} + +function createMaterial(scene: Scene3D, color: number, emissiveIntensity = 0.2): number { + void emissiveIntensity; // no emissive in scene3d v1 (see header) + return scene.material(rgbToAbgr(color), 0); +} + +export function buildAmmoPickupVisual( + scene: Scene3D, + color: number | null = null, + accentColor: number | null = null, +): PickupVisual { + const group = scene.node(); + scene.mesh( + scene.box(0.6, 0.4, 0.45), // BoxGeometry(1.2, 0.8, 0.9) + createMaterial(scene, color ?? 0x4b7b51), + group, + ); + const belt = scene.mesh( + scene.box(0.54, 0.1, 0.46), // BoxGeometry(1.08, 0.2, 0.92) + createMaterial(scene, accentColor ?? 0xd9e56a, 0.35), + group, + ); + belt.position.y = 0.16; + return { mesh: group, radius: 0.85 }; +} + +export function buildHealthPickupVisual( + scene: Scene3D, + color: number | null = null, + crossColor: number | null = null, +): PickupVisual { + const size = 0.75; + const group = scene.node(); + scene.mesh( + scene.box(size / 2, size / 2, size / 2), + createMaterial(scene, color ?? 0xaa1f24), + group, + ); + const thick = size * 0.2; + const vertical = scene.mesh( + scene.box(thick / 2, (size * 0.75) / 2, (size * 1.01) / 2), + createMaterial(scene, crossColor ?? 0xffffff, 0.5), + group, + ); + const horizontal = scene.mesh( + scene.box((size * 0.75) / 2, thick / 2, (size * 1.01) / 2), + createMaterial(scene, crossColor ?? 0xffffff, 0.5), + group, + ); + vertical.position.z = size * 0.51; + horizontal.position.z = size * 0.51; + return { mesh: group, radius: 0.8 }; +} + +export function buildArmorPickupVisual( + scene: Scene3D, + color: number | null = null, + ringColor: number | null = null, +): PickupVisual { + const group = scene.node(); + scene.mesh( + scene.cylinder(0.28, 0.28, 1.0, 10), + createMaterial(scene, color ?? 0x2d66ff, 0.4), + group, + ); + const ring = scene.mesh( + scene.torus(0.4, 0.05, 12, 24), + createMaterial(scene, ringColor ?? 0x77a3ff, 0.6), + group, + ); + ring.quaternion.setFromEuler(new Euler(Math.PI * 0.5, 0, 0)); + return { mesh: group, radius: 0.82 }; +} + +export interface CreatePickupVisualOptions { + type: string | null | undefined; + color?: number | null; + accentColor?: number | null; + crossColor?: number | null; + ringColor?: number | null; +} + +export function createPickupVisual( + scene: Scene3D, + { type, color = null, accentColor = null, crossColor = null, ringColor = null }: CreatePickupVisualOptions, +): PickupVisual { + if (type === "ammo") return buildAmmoPickupVisual(scene, color, accentColor); + if (type === "health") return buildHealthPickupVisual(scene, color, crossColor); + return buildArmorPickupVisual(scene, color, ringColor); +} diff --git a/playset/modules/world/object/factory/plant-visual-factory.ts b/playset/modules/world/object/factory/plant-visual-factory.ts new file mode 100644 index 0000000..9bbc33e --- /dev/null +++ b/playset/modules/world/object/factory/plant-visual-factory.ts @@ -0,0 +1,221 @@ +// playset/modules/world/object/factory/plant-visual-factory.ts — prng- +// randomized trees (conifer/broadleaf) and grass blades. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/factory/PlantVisualFactory.js. PRNG draw +// order is preserved call-for-call, so a seeded tree is deterministic. +// Deliberate changes for the scene3d surface: +// - material factories take `scene` and return material HANDLES; +// roughness/flatShading dropped (fixed-function lighting; facets are a +// host tessellation-quality decision). createTreeMaterials' options +// object gains an `= {}` default so the documented no-arg call works. +// - conifer cones lose openEnded (no such flag; caps are invisible from +// outside the canopy anyway). +// - broadleaf DodecahedronGeometry clusters become low-segment spheres — +// the faceted stand-in for polyhedron geoms. +// - cast/receive-shadow traverse dropped (blob decals). + +import { Euler } from "../../../../math/euler.ts"; +import { MathUtils } from "../../../../math/math-utils.ts"; +import { Vector3 } from "../../../../math/vector3.ts"; +import type { Scene3D, SceneNode } from "../../../../scene3d/client.ts"; +import { DEFAULT_PRNG, type RandomGenerator } from "../../../math/random-utils.ts"; +import { rgbToAbgr } from "../../color-utils.ts"; + +const UP = new Vector3(0, 1, 0); + +export interface TreeMaterials { + trunk: number; + barkShadow: number; + leaves: number[]; +} + +export interface TreeMaterialsOptions { + trunkColor?: number; + barkShadowColor?: number; + leafColors?: readonly (number | { color: number; roughness?: number })[]; +} + +export function createTreeMaterials( + scene: Scene3D, + { + trunkColor = 0x6d472b, + barkShadowColor = 0x3f281b, + leafColors = [ + { color: 0x245d3a, roughness: 0.86 }, + { color: 0x3f783f, roughness: 0.84 }, + { color: 0x6e8f3a, roughness: 0.86 }, + { color: 0x8fa65a, roughness: 0.88 }, + ], + }: TreeMaterialsOptions = {}, +): TreeMaterials { + return { + trunk: scene.material(rgbToAbgr(trunkColor), 0), + barkShadow: scene.material(rgbToAbgr(barkShadowColor), 0), + leaves: leafColors.map((entry) => { + const color = typeof entry === "number" ? entry : entry.color; + return scene.material(rgbToAbgr(color), 0); + }), + }; +} + +export interface TreeVisualOptions { + height: number; + radius: number; + materials?: TreeMaterials; + prng?: RandomGenerator; +} + +export function createTreeVisual( + scene: Scene3D, + { height, radius, materials, prng = DEFAULT_PRNG }: TreeVisualOptions, +): SceneNode { + if (!prng) throw new Error("createTreeVisual requires a PRNG"); + const mats = materials ?? createTreeMaterials(scene); + + const tree = scene.node(); + const trunk = scene.mesh( + scene.cylinder(radius * 0.58, radius * 1.03, height, 9), + mats.trunk, + tree, + ); + trunk.position.y = height * 0.5; + + const rootFlare = scene.mesh( + scene.cylinder(radius * 1.35, radius * 1.7, Math.max(0.18, radius * 0.52), 9), + mats.barkShadow, + tree, + ); + rootFlare.position.y = Math.max(0.09, radius * 0.26); + + if (prng.random() < 0.52) { + const branchCount = prng.random() < 0.45 ? 2 : 1; + for (let i = 0; i < branchCount; i += 1) { + createBranchStub(scene, tree, height, radius, prng, mats.barkShadow); + } + } + + if (prng.random() < 0.74) { + addConiferCanopy(scene, tree, height, prng, mats.leaves); + } else { + addBroadleafCanopy(scene, tree, height, prng, mats.leaves); + } + + return tree; +} + +export function createGrassMaterial(scene: Scene3D, color = 0xa2b86a): number { + return scene.material(rgbToAbgr(color), 0); +} + +export interface GrassBladeOptions { + material?: number; + prng?: RandomGenerator; +} + +export function createGrassBladeVisual( + scene: Scene3D, + { material, prng = DEFAULT_PRNG }: GrassBladeOptions = {}, +): SceneNode { + const mat = material ?? createGrassMaterial(scene); + const blade = scene.mesh(scene.cone(0.08, prng.uniform(0.45, 1.2), 4), mat); + blade.quaternion.setFromEuler(new Euler(0, prng.uniform(0, Math.PI * 2), 0)); + return blade; +} + +function createBranchStub( + scene: Scene3D, + tree: SceneNode, + height: number, + radius: number, + prng: RandomGenerator, + material: number, +): void { + const length = prng.uniform(0.48, 0.88); + const branch = scene.mesh( + scene.cylinder(radius * 0.13, radius * 0.2, length, 7), + material, + tree, + ); + const angle = prng.uniform(0, Math.PI * 2); + const sideLean = prng.uniform(0.74, 0.98); + const direction = new Vector3( + Math.cos(angle) * sideLean, + prng.uniform(0.22, 0.42), + Math.sin(angle) * sideLean, + ).normalize(); + branch.position.set( + direction.x * length * 0.34, + height * prng.uniform(0.48, 0.68), + direction.z * length * 0.34, + ); + branch.quaternion.setFromUnitVectors(UP, direction); +} + +function addConiferCanopy( + scene: Scene3D, + tree: SceneNode, + height: number, + prng: RandomGenerator, + leafMaterials: number[], +): void { + const baseRadius = prng.uniform(1.85, 3.3) * MathUtils.clamp(height / 7.6, 0.82, 1.18); + const layers = [ + { y: height * 0.68, radius: baseRadius, height: height * prng.uniform(0.44, 0.56) }, + { + y: height * 0.88, + radius: baseRadius * prng.uniform(0.72, 0.84), + height: height * prng.uniform(0.36, 0.48), + }, + { + y: height * 1.06, + radius: baseRadius * prng.uniform(0.48, 0.62), + height: height * prng.uniform(0.28, 0.38), + }, + ]; + + for (const layer of layers) { + const crown = scene.mesh( + scene.cone(layer.radius, layer.height, 9), + prng.choice(leafMaterials), + tree, + ); + crown.position.y = layer.y; + crown.quaternion.setFromEuler(new Euler(0, prng.uniform(0, Math.PI * 2), 0)); + crown.scale.set(prng.uniform(0.88, 1.12), 1, prng.uniform(0.88, 1.12)); + } +} + +function addBroadleafCanopy( + scene: Scene3D, + tree: SceneNode, + height: number, + prng: RandomGenerator, + leafMaterials: number[], +): void { + const crownSize = prng.uniform(1.25, 2.0) * MathUtils.clamp(height / 7.2, 0.85, 1.25); + const clusters = [ + { x: 0, y: height * 0.93, z: 0, scale: 1.25 }, + { x: -crownSize * 0.45, y: height * 0.82, z: crownSize * 0.12, scale: 0.86 }, + { x: crownSize * 0.42, y: height * 0.84, z: -crownSize * 0.18, scale: 0.8 }, + { x: crownSize * 0.06, y: height * 1.08, z: -crownSize * 0.05, scale: 0.72 }, + ]; + + for (const cluster of clusters) { + // DodecahedronGeometry(r, 0) → low-segment sphere stand-in (see header). + const crown = scene.mesh( + scene.sphere(crownSize * cluster.scale, 6), + prng.choice(leafMaterials), + tree, + ); + crown.position.set(cluster.x, cluster.y, cluster.z); + crown.quaternion.setFromEuler( + new Euler(prng.uniform(-0.2, 0.2), prng.uniform(0, Math.PI * 2), prng.uniform(-0.2, 0.2)), + ); + crown.scale.set( + prng.uniform(0.95, 1.18), + prng.uniform(0.78, 1.08), + prng.uniform(0.92, 1.18), + ); + } +} diff --git a/playset/modules/world/object/factory/projectile-visual-factory.ts b/playset/modules/world/object/factory/projectile-visual-factory.ts new file mode 100644 index 0000000..a79ed14 --- /dev/null +++ b/playset/modules/world/object/factory/projectile-visual-factory.ts @@ -0,0 +1,126 @@ +// playset/modules/world/object/factory/projectile-visual-factory.ts — +// bullet tracer + missile visuals (driven by ProjectileObject). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/factory/ProjectileVisualFactory.js. +// Deliberate changes for the scene3d surface: +// - per-frame material.opacity writes become nodeSetTint over a material +// whose alpha is 1: bullet fade = 0.95·(1-age/lifetime) rides in the +// tint alpha (material carries the 0.95); the missile flame's flicker +// REPLACES its opacity, so the flame material alpha is 1 and the tint +// alpha is the flicker itself. +// - depthTest:false / renderOrder / emissive / group names dropped. +// - setCylinderBetween is verbatim (midpoint + scale.y + quaternion from +// (0,1,0) → delta) — scene3d cylinders are +Y-aligned like three's. + +import { Quaternion } from "../../../../math/quaternion.ts"; +import { Vector3 } from "../../../../math/vector3.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../../scene3d/client.ts"; +import { clamp } from "../../../math/scalar-utils.ts"; +import { rgbToAbgr } from "../../color-utils.ts"; + +const LOCAL_CONE_FORWARD = new Vector3(0, 1, 0); +const LOCAL_CYLINDER_FORWARD = new Vector3(0, 1, 0); + +interface PoseTarget { + position: Vector3; + quaternion: Quaternion; + scale: Vector3; +} + +function setCylinderBetween(mesh: PoseTarget, start: Vector3, end: Vector3): void { + const delta = end.clone().sub(start); + const length = Math.max(0.001, delta.length()); + const midpoint = start.clone().addScaledVector(delta, 0.5); + mesh.position.copy(midpoint); + mesh.scale.set(1, length, 1); + mesh.quaternion.setFromUnitVectors(LOCAL_CYLINDER_FORWARD, delta.normalize()); +} + +export interface BulletVisualStepState { + position: Vector3; + ageSeconds: number; + lifetimeSeconds: number; +} + +export interface BulletProjectileVisual { + group: SceneNode; + mesh: SceneNode; + step(state: BulletVisualStepState): void; +} + +export function createBulletProjectileVisual(scene: Scene3D): BulletProjectileVisual { + const group = scene.node(); + + const mesh = scene.mesh( + scene.sphere(2.6, 12), + scene.material(rgbToAbgr(0xfff3a1, 0.95), MAT.unlit | MAT.transparent), + group, + ); + + return { + group, + mesh, + step({ position, ageSeconds, lifetimeSeconds }: BulletVisualStepState): void { + mesh.position.copy(position); + const fade = clamp(1 - ageSeconds / lifetimeSeconds, 0, 1); + mesh.setTint(rgbToAbgr(0xffffff, fade)); // opacity = 0.95 · fade + }, + }; +} + +export interface MissileVisualStepState { + position: Vector3; + direction: Vector3; + ageSeconds: number; +} + +export interface MissileProjectileVisual { + group: SceneNode; + mesh: SceneNode; + flame: SceneNode; + trail: SceneNode; + step(state: MissileVisualStepState): void; +} + +export function createMissileProjectileVisual(scene: Scene3D): MissileProjectileVisual { + const group = scene.node(); + + const trail = scene.mesh( + scene.cylinder(1.35, 0.55, 1, 10), + scene.material(rgbToAbgr(0xffd28a, 0.5), MAT.unlit | MAT.transparent), + group, + ); + const mesh = scene.mesh( + scene.cone(2.8, 13, 16), + scene.material(rgbToAbgr(0xf4f7ff), 0), + group, + ); + const flame = scene.mesh( + scene.sphere(3.6, 14), + scene.material(rgbToAbgr(0xff9f2f, 1), MAT.unlit | MAT.transparent), + group, + ); + flame.setTint(rgbToAbgr(0xffffff, 0.9)); // pre-first-step opacity 0.9 + + return { + group, + mesh, + flame, + trail, + step({ position, direction, ageSeconds }: MissileVisualStepState): void { + mesh.position.copy(position); + mesh.quaternion.setFromUnitVectors(LOCAL_CONE_FORWARD, direction); + flame.position.copy(position).addScaledVector(direction, -7.5); + setCylinderBetween( + trail, + position.clone().addScaledVector(direction, -84), + position.clone().addScaledVector(direction, -9), + ); + + const flicker = 0.78 + Math.sin(ageSeconds * 60) * 0.18; + flame.setTint(rgbToAbgr(0xffffff, flicker)); // opacity := flicker + flame.scale.setScalar(0.9 + flicker * 0.22); + }, + }; +} diff --git a/playset/modules/world/object/factory/rock-visual-factory.ts b/playset/modules/world/object/factory/rock-visual-factory.ts new file mode 100644 index 0000000..92d195a --- /dev/null +++ b/playset/modules/world/object/factory/rock-visual-factory.ts @@ -0,0 +1,99 @@ +// playset/modules/world/object/factory/rock-visual-factory.ts — squashed +// ground rocks and irregular boulders. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/factory/RockVisualFactory.js. Deliberate +// changes for the scene3d surface: +// - DodecahedronGeometry/IcosahedronGeometry become low-segment spheres — +// the faceted stand-in for polyhedron geoms (roughness/flatShading +// dropped; facets are a host tessellation decision). +// - applySeamSafeIrregularity mutated the icosahedron's vertex buffer with +// one prng scale per welded vertex; parametric sphere geoms have no +// guest-side vertex buffer, so the per-vertex draws are SKIPPED — +// createIrregularRockVisual's prng stream position differs from +// GameBlocks after this call (the per-axis scale draws that follow are +// kept, in order). Irregularity is accepted and folded into nothing. +// - cast/receive-shadow flags + mesh names dropped (blob decals). + +import { Euler } from "../../../../math/euler.ts"; +import type { Scene3D, SceneNode } from "../../../../scene3d/client.ts"; +import { DEFAULT_PRNG, type RandomGenerator } from "../../../math/random-utils.ts"; +import { rgbToAbgr } from "../../color-utils.ts"; + +export function createRockMaterial(scene: Scene3D, color = 0x7b827a): number { + return scene.material(rgbToAbgr(color), 0); +} + +export interface GroundRockOptions { + material?: number; + prng?: RandomGenerator; +} + +export function createGroundRockVisual( + scene: Scene3D, + { material, prng = DEFAULT_PRNG }: GroundRockOptions = {}, +): SceneNode { + const mat = material ?? createRockMaterial(scene); + // DodecahedronGeometry(uniform(0.7, 2.0), 0) → faceted sphere stand-in. + const rock = scene.mesh(scene.sphere(prng.uniform(0.7, 2.0), 6), mat); + rock.scale.y = prng.uniform(0.35, 0.8); + rock.quaternion.setFromEuler( + new Euler(prng.random() * Math.PI, prng.random() * Math.PI, prng.random() * Math.PI), + ); + return rock; +} + +export interface IrregularRockOptions { + radius?: number; + color?: number; + detail?: number; + irregularity?: number; + scaleVariance?: number; + roughness?: number; + metalness?: number; + castShadow?: boolean; + receiveShadow?: boolean; + prng?: RandomGenerator; +} + +export interface IrregularRockVisual { + mesh: SceneNode; + radius: number; +} + +export function createIrregularRockVisual( + scene: Scene3D, + { + radius = 1, + color = 0x8e95a3, + detail = 1, + irregularity = 0.16, + scaleVariance = 0.12, + roughness = 0.9, + metalness = 0.05, + castShadow = true, + receiveShadow = true, + prng = DEFAULT_PRNG, + }: IrregularRockOptions = {}, +): IrregularRockVisual { + // No fixed-function / guest-side analogs (see header). + void irregularity; void roughness; void metalness; void castShadow; void receiveShadow; + + const safeRadius = Math.max(0.05, radius); + // IcosahedronGeometry(r, detail) → sphere stand-in; detail maps to a + // matching coarseness (detail 0 ≈ 6 segments, +4 per subdivision level). + const segments = 6 + Math.max(0, Math.floor(detail)) * 4; + const material = scene.material(rgbToAbgr(color), 0); + + const mesh = scene.mesh(scene.sphere(safeRadius, segments), material); + mesh.scale.set( + prng.uniform(1 - scaleVariance, 1 + scaleVariance), + prng.uniform(1 - scaleVariance, 1 + scaleVariance), + prng.uniform(1 - scaleVariance, 1 + scaleVariance), + ); + + return { + mesh, + radius: safeRadius, + }; +} diff --git a/playset/modules/world/object/fps-weapon-view-model.ts b/playset/modules/world/object/fps-weapon-view-model.ts new file mode 100644 index 0000000..4d2f41d --- /dev/null +++ b/playset/modules/world/object/fps-weapon-view-model.ts @@ -0,0 +1,231 @@ +// playset/modules/world/object/fps-weapon-view-model.ts — camera-locked FPS +// weapon viewmodel with offset/sprint/recoil smoothing. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/FpsWeaponViewModel.js. Per-frame step() +// math is verbatim. Deliberate changes for the scene3d surface: +// - constructor options gain `scene: Scene3D` (nodes need an owner). +// - depthTest:false + renderOrder 9999 (always-on-top overlay) have no v1 +// analog: the group renders as ordinary nodes — overlay depth pass +// pending native core. Materials stay unlit (MeshBasicMaterial parity). +// - The barrel's rotation.x is a node quaternion (geometry ops don't take +// transforms). + +import { Euler } from "../../../math/euler.ts"; +import { MathUtils } from "../../../math/math-utils.ts"; +import { Quaternion } from "../../../math/quaternion.ts"; +import { Vector3 } from "../../../math/vector3.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../scene3d/client.ts"; +import { DEFAULT_PRNG } from "../../math/random-utils.ts"; +import { clamp, smoothingAlpha } from "../../math/scalar-utils.ts"; +import { rgbToAbgr } from "../color-utils.ts"; +import { disposeSceneNode } from "../scene-node-utils.ts"; + +function createWeaponLocalMesh(scene: Scene3D): SceneNode { + const group = scene.node(); + const bodyMat = scene.material(rgbToAbgr(0x232a35), MAT.unlit); + const accentMat = scene.material(rgbToAbgr(0x8ea8c6), MAT.unlit); + + scene.mesh(scene.box(0.08, 0.06, 0.21), bodyMat, group); // BoxGeometry(0.16, 0.12, 0.42) + const slide = scene.mesh(scene.box(0.06, 0.03, 0.12), accentMat, group); // (0.12, 0.06, 0.24) + slide.position.set(0, 0.07, 0.05); + + // The mesh is authored in viewmodel-local space, where +Z is backward. + const barrel = scene.mesh(scene.cylinder(0.022, 0.022, 0.28, 12), accentMat, group); + barrel.quaternion.setFromEuler(new Euler(Math.PI * 0.5, 0, 0)); + barrel.position.set(0, -0.015, 0.22); + + return group; +} + +/** Structural camera — Camera3D qualifies, and so does any rig pose. */ +export interface ViewModelCameraLike { + position: Vector3; + quaternion: Quaternion; +} + +export interface FpsWeaponViewModelOptions { + scene: Scene3D; + normalOffset?: Vector3; + scopedOffset?: Vector3; + crouchedOffset?: Vector3; + offsetLag?: number; + sprintYaw?: number; + sprintLag?: number; + recoilRecoveryLag?: number; + recoilKickRecoveryLag?: number; + maxRecoilPitch?: number; + maxRecoilYaw?: number; + prng?: { random(): number }; +} + +export interface FpsWeaponViewModelStepResult { + position: { x: number; y: number; z: number }; + quaternion: { x: number; y: number; z: number; w: number }; + recoil: { pitch: number; yaw: number; kick: number }; + sprintYaw: number; +} + +// Viewmodel is authored in camera-local space: +X right, +Y up, and -Z forward. +export class FpsWeaponViewModel { + readonly group: SceneNode; + readonly normalOffset: Vector3; + readonly scopedOffset: Vector3; + readonly crouchedOffset: Vector3; + readonly offsetLag: number; + readonly sprintYaw: number; + readonly sprintLag: number; + readonly recoilRecoveryLag: number; + readonly recoilKickRecoveryLag: number; + readonly maxRecoilPitch: number; + readonly maxRecoilYaw: number; + readonly prng: { random(): number }; + + readonly state: { + moving: boolean; + sprinting: boolean; + crouching: boolean; + scoping: boolean; + onGround: boolean; + }; + + readonly currentOffset: Vector3; + currentSprintYaw: number; + readonly recoil: { pitch: number; yaw: number; kick: number }; + + private readonly _tmpEuler = new Euler(); + private readonly _tmpQuatBase = new Quaternion(); + private readonly _tmpQuatOffset = new Quaternion(); + private readonly _tmpPos = new Vector3(); + + constructor({ + scene, + normalOffset = new Vector3(0.25, -0.4, -0.25), + scopedOffset = new Vector3(0.0, -0.21, -0.2), + crouchedOffset = new Vector3(0.3, -0.55, -0.35), + offsetLag = 0.10, + sprintYaw = 1.25, + sprintLag = 0.5, + recoilRecoveryLag = 0.08, + recoilKickRecoveryLag = 0.06, + maxRecoilPitch = 0.15, + maxRecoilYaw = 0.03, + prng = DEFAULT_PRNG, + }: FpsWeaponViewModelOptions) { + this.group = createWeaponLocalMesh(scene); + + this.normalOffset = normalOffset.clone(); + this.scopedOffset = scopedOffset.clone(); + this.crouchedOffset = crouchedOffset.clone(); + this.offsetLag = offsetLag; + this.sprintYaw = sprintYaw; + this.sprintLag = sprintLag; + this.recoilRecoveryLag = recoilRecoveryLag; + this.recoilKickRecoveryLag = recoilKickRecoveryLag; + this.maxRecoilPitch = maxRecoilPitch; + this.maxRecoilYaw = maxRecoilYaw; + this.prng = prng; + + this.state = { + moving: false, + sprinting: false, + crouching: false, + scoping: false, + onGround: true, + }; + + this.currentOffset = this.normalOffset.clone(); + this.currentSprintYaw = 0; + this.recoil = { pitch: 0, yaw: 0, kick: 0 }; + } + + setVisible(visible: boolean): void { + this.group.visible = Boolean(visible); + } + + setState( + moving = this.state.moving, + sprinting = this.state.sprinting, + crouching = this.state.crouching, + scoping = this.state.scoping, + onGround = this.state.onGround, + ): void { + this.state.moving = moving; + this.state.sprinting = sprinting; + this.state.crouching = crouching; + this.state.scoping = scoping; + this.state.onGround = onGround; + } + + kick(pitch = 0.03, yawJitter = 0.01, kickback = 0.035): void { + this.recoil.pitch = clamp(this.recoil.pitch + pitch, 0, this.maxRecoilPitch); + const yawDelta = (this.prng.random() - 0.5) * yawJitter * 2; + this.recoil.yaw = clamp(this.recoil.yaw + yawDelta, -this.maxRecoilYaw, this.maxRecoilYaw); + this.recoil.kick += kickback; + } + + private _computeTargetOffset(): Vector3 { + if (this.state.scoping) return this.scopedOffset; + if (this.state.crouching) return this.crouchedOffset; + return this.normalOffset; + } + + private _recoverRecoil(deltaSeconds: number): void { + const recoilLerp = smoothingAlpha(this.recoilRecoveryLag, deltaSeconds); + const kickLerp = smoothingAlpha(this.recoilKickRecoveryLag, deltaSeconds); + this.recoil.pitch = MathUtils.lerp(this.recoil.pitch, 0, recoilLerp); + this.recoil.yaw = MathUtils.lerp(this.recoil.yaw, 0, recoilLerp); + this.recoil.kick = MathUtils.lerp(this.recoil.kick, 0, kickLerp); + } + + step(camera: ViewModelCameraLike, deltaSeconds = 1 / 60): FpsWeaponViewModelStepResult { + const targetOffset = this._computeTargetOffset(); + this.currentOffset.lerp(targetOffset, smoothingAlpha(this.offsetLag, deltaSeconds)); + + const sprintTarget = + this.state.sprinting && this.state.onGround && !this.state.scoping ? this.sprintYaw : 0; + this.currentSprintYaw = MathUtils.lerp( + this.currentSprintYaw, + sprintTarget, + smoothingAlpha(this.sprintLag, deltaSeconds), + ); + + this._recoverRecoil(deltaSeconds); + + const offsetX = this.currentOffset.x; + const offsetY = this.currentOffset.y; + const offsetZ = this.currentOffset.z - this.recoil.kick; + + // Convert the camera-local offset into world space. Local +Z points + // backward for the camera, so forward/back offset uses -offsetZ. + this._tmpPos + .set(offsetX, offsetY, -offsetZ) + .applyQuaternion(camera.quaternion) + .add(camera.position); + + // Recoil and sprint roll are local viewmodel rotations applied after the + // camera orientation, not world-basis rotations. + this._tmpEuler.set(-this.recoil.pitch, this.currentSprintYaw - this.recoil.yaw, 0, "XYZ"); + this._tmpQuatOffset.setFromEuler(this._tmpEuler); + const finalQuat = this._tmpQuatBase.copy(camera.quaternion).multiply(this._tmpQuatOffset); + + this.group.position.copy(this._tmpPos); + this.group.quaternion.copy(finalQuat); + + return { + position: { x: this._tmpPos.x, y: this._tmpPos.y, z: this._tmpPos.z }, + quaternion: { + x: finalQuat.x, + y: finalQuat.y, + z: finalQuat.z, + w: finalQuat.w, + }, + recoil: { ...this.recoil }, + sprintYaw: this.currentSprintYaw, + }; + } + + dispose(): void { + disposeSceneNode(this.group); + } +} diff --git a/playset/modules/world/object/health-bar-view.ts b/playset/modules/world/object/health-bar-view.ts new file mode 100644 index 0000000..054d90e --- /dev/null +++ b/playset/modules/world/object/health-bar-view.ts @@ -0,0 +1,146 @@ +// playset/modules/world/object/health-bar-view.ts — camera-billboarded +// segmented health bar floating above an entity. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/HealthBarView.js. Deliberate changes for +// the scene3d surface: +// - THREE.Sprite layers become unlit transparent thin boxes in a group; +// the original already billboards by copying cameraQuaternion onto the +// group each step, so the boxes face the camera by exactly that write. +// - The fill sprite's left anchor (center=(0,0.5)) has no box analog: +// the fill box is re-centered each step at +// `-fillWidth/2 + scale.x/2` — same left edge, same width. +// - Fill color swaps ride nodeSetTint over a white material (materials are +// shared/cached; the original mutated its own SpriteMaterial color). +// - Coplanar sprites relied on painter's order; the box layers get a tiny +// +Z stagger (0.001 per layer) to survive a depth-tested host. + +import type { Quaternion } from "../../../math/quaternion.ts"; +import { MAT, type Scene3D, type SceneNode } from "../../../scene3d/client.ts"; +import { clamp01 } from "../../math/scalar-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; +import { rgbToAbgr } from "../color-utils.ts"; + +/** Layer separation along local +Z (toward the camera once billboarded). */ +const LAYER_Z = 0.001; + +export interface HealthBarViewOptions { + scene: Scene3D; + upOffset?: number; + width?: number; + height?: number; + fillWidth?: number; + fillHeight?: number; + segmentCount?: number; + backColor?: number; + frameColor?: number; + healthyColor?: number; + warningColor?: number; + dangerColor?: number; + basis?: WorldBasis; +} + +export interface HealthBarStepState { + position: VecLike; + cameraQuaternion: Quaternion; + current: number; + max: number; + visible?: boolean; +} + +export class HealthBarView { + readonly upOffset: number; + readonly basis: WorldBasis; + readonly fillWidth: number; + readonly healthyColor: number; + readonly warningColor: number; + readonly dangerColor: number; + + readonly group: SceneNode; + readonly back: SceneNode; + readonly fill: SceneNode; + readonly frame: SceneNode; + readonly segments: SceneNode[]; + + private readonly scene: Scene3D; + private _fillColor: number; + + constructor({ + scene, + upOffset = 3.15, + width = 2.1, + height = 0.28, + fillWidth = 1.86, + fillHeight = 0.14, + segmentCount = 8, + backColor = 0x101010, + frameColor = 0xf2f2f2, + healthyColor = 0x7dff8a, + warningColor = 0xffd86b, + dangerColor = 0xff6767, + basis = DEFAULT_WORLD_BASIS, + }: HealthBarViewOptions) { + this.scene = scene; + this.upOffset = upOffset; + this.basis = basis; + this.fillWidth = fillWidth; + this.healthyColor = healthyColor; + this.warningColor = warningColor; + this.dangerColor = dangerColor; + + this.group = scene.node(); + this.back = this._createSprite(backColor, 0.92, 0); + this.back.scale.set(width, height, 1); + + // White material + tint carries the threshold color (see header). + this.fill = this._createSprite(0xffffff, 0.95, 1); + this.fill.scale.set(fillWidth, fillHeight, 1); + this.fill.position.x = -fillWidth * 0.5 + fillWidth * 0.5; // left-anchored, full + this._fillColor = healthyColor; + this.fill.setTint(rgbToAbgr(healthyColor)); + + this.frame = this._createSprite(frameColor, 0.12, 3); + this.frame.scale.set(width + 0.08, height + 0.08, 1); + + this.segments = []; + for (let index = 1; index < segmentCount; index += 1) { + const segment = this._createSprite(0x0f1012, 0.78, 2); + segment.scale.set(0.03, height - 0.04, 1); + segment.position.x = -fillWidth * 0.5 + (fillWidth * index) / segmentCount; + this.segments.push(segment); + } + } + + /** A sprite stand-in: unit thin box, scaled like the original sprite. */ + private _createSprite(color: number, opacity: number, layer: number): SceneNode { + const node = this.scene.mesh( + this.scene.box(0.5, 0.5, 0.005), + this.scene.material(rgbToAbgr(color, opacity), MAT.unlit | MAT.transparent), + this.group, + ); + node.position.z = layer * LAYER_Z; + return node; + } + + step({ position, cameraQuaternion, current, max, visible = true }: HealthBarStepState): void { + this.group.visible = visible; + if (!visible) return; + + const ratio = clamp01(current / Math.max(1e-6, max)); + this.group.position.set(position.x ?? 0, position.y ?? 0, position.z ?? 0); + this.basis.addHeight(this.group.position, this.upOffset); + this.group.quaternion.copy(cameraQuaternion); + this.fill.scale.x = Math.max(0.001, this.fillWidth * ratio); + // Same left edge as sprite center=(0,0.5): position is the box center. + this.fill.position.x = -this.fillWidth * 0.5 + this.fill.scale.x * 0.5; + this._setFillColor(ratio); + } + + private _setFillColor(ratio: number): void { + const color = + ratio > 0.6 ? this.healthyColor : ratio > 0.3 ? this.warningColor : this.dangerColor; + if (color === this._fillColor) return; + this._fillColor = color; + this.fill.setTint(rgbToAbgr(color)); + } +} diff --git a/playset/modules/world/object/pickup-object.ts b/playset/modules/world/object/pickup-object.ts new file mode 100644 index 0000000..4f145b5 --- /dev/null +++ b/playset/modules/world/object/pickup-object.ts @@ -0,0 +1,91 @@ +// playset/modules/world/object/pickup-object.ts — a bobbing, spinning pickup +// entity that drives a factory-built visual. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/PickupObject.js. Verbatim semantics; zero +// scene coupling — the visual is driven structurally (position/quaternion/ +// scale mirrors), and three's rotateOnWorldAxis is inlined as its quaternion +// premultiply identity. + +import { Quaternion } from "../../../math/quaternion.ts"; +import type { Vector3 } from "../../../math/vector3.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; +import { disposeSceneNode, type DisposableNode } from "../scene-node-utils.ts"; + +/** What PickupObject drives — SceneNode qualifies (PickupVisualFactory). */ +export interface PickupGroupLike extends DisposableNode { + position: Vector3; + quaternion: Quaternion; + scale: Vector3; +} + +export interface PickupVisualLike { + mesh: PickupGroupLike; + radius: number; +} + +export interface PickupObjectOptions { + id?: string | number | null; + type?: string | null; + pickupVisual: PickupVisualLike; + position: VecLike; + floorUp?: number; + scale?: number; + basis?: WorldBasis; +} + +export class PickupObject { + readonly id: string | number | null; + readonly type: string | null; + readonly basis: WorldBasis; + readonly up: Vector3; + readonly group: PickupGroupLike; + readonly position: Vector3; + readonly radius: number; + phase: number; + baseHeight: number; + + private readonly _spin = new Quaternion(); + + constructor({ + id = null, + type = null, + pickupVisual, + position, + floorUp = 0, + scale = 1, + basis = DEFAULT_WORLD_BASIS, + }: PickupObjectOptions) { + if (!position) throw new Error("PickupObject: position is required"); + + this.id = id; + this.type = type; + this.basis = basis; + this.up = this.basis.upVector(); + + this.group = pickupVisual.mesh; + this.group.position.set(position.x ?? 0, position.y ?? 0, position.z ?? 0); + this.basis.setHeight(this.group.position, floorUp + 0.5); + this.group.scale.setScalar(scale); + + this.position = this.group.position; + this.radius = pickupVisual.radius * scale; + this.phase = 0; + this.baseHeight = this.basis.upComponent(this.group.position); + } + + animate(deltaSeconds: number, bobSpeed = 3.2, bobHeight = 0.12, spinSpeed = 1.8): void { + this.phase += Math.max(0, deltaSeconds) * bobSpeed; + this.basis.setHeight( + this.group.position, + this.baseHeight + Math.sin(this.phase) * bobHeight, + ); + // three rotateOnWorldAxis(axis, angle) ≡ quaternion.premultiply(axis-angle) + this._spin.setFromAxisAngle(this.up, Math.max(0, deltaSeconds) * spinSpeed); + this.group.quaternion.premultiply(this._spin); + } + + dispose(): void { + disposeSceneNode(this.group); + } +} diff --git a/playset/modules/world/object/projectile-object.ts b/playset/modules/world/object/projectile-object.ts new file mode 100644 index 0000000..ea74d36 --- /dev/null +++ b/playset/modules/world/object/projectile-object.ts @@ -0,0 +1,185 @@ +// playset/modules/world/object/projectile-object.ts — linear/homing +// projectile simulation that drives a factory-built visual. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/object/ProjectileObject.js. Verbatim semantics; +// zero scene coupling — the visual is driven structurally through its +// optional step() callback. + +import { clamp } from "../../math/scalar-utils.ts"; +import { toUnitVec3, toVec3 } from "../../math/vector3-utils.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; +import type { Vector3 } from "../../../math/vector3.ts"; +import { disposeSceneNode, type DisposableNode } from "../scene-node-utils.ts"; + +export interface ProjectileTargetLike { + position: VecLike; + destroyed?: boolean; +} + +export interface ProjectileVisualStepState { + position: Vector3; + direction: Vector3; + velocity: Vector3; + ageSeconds: number; + lifetimeSeconds: number; +} + +export interface ProjectileVisualLike { + group: DisposableNode; + step?(state: ProjectileVisualStepState): void; +} + +export interface ProjectileObjectOptions { + visual: ProjectileVisualLike; + position: VecLike; + direction: VecLike | null | undefined; + speed: number; + target?: ProjectileTargetLike | null; + lifetimeSeconds: number; + hitRadius: number; + turnResponse?: number; + basis?: WorldBasis; +} + +export interface ProjectileStepResult { + position: Vector3; + target: ProjectileTargetLike | null; + hittedTarget: ProjectileTargetLike | null; +} + +export class ProjectileObject { + target: ProjectileTargetLike | null; + speed: number; + lifetimeSeconds: number; + hitRadius: number; + turnResponse: number; + active: boolean; + ageSeconds: number; + readonly position: Vector3; + readonly velocity: Vector3; + readonly visual: ProjectileVisualLike; + readonly group: DisposableNode; + + constructor({ + visual, + position, + direction, + speed, + target = null, + lifetimeSeconds, + hitRadius, + turnResponse = 0, + basis = DEFAULT_WORLD_BASIS, + }: ProjectileObjectOptions) { + this.target = target; + this.speed = speed; + this.lifetimeSeconds = lifetimeSeconds; + this.hitRadius = hitRadius; + this.turnResponse = turnResponse; + this.active = true; + this.ageSeconds = 0; + + this.position = toVec3(position); + const launchDirection = toUnitVec3(direction, basis.forwardVector()); + this.velocity = launchDirection.multiplyScalar(this.speed); + + this.visual = visual; + this.group = this.visual.group; + this._syncVisual(); + } + + step(targets: ProjectileTargetLike[] = [], deltaSeconds = 1 / 60): ProjectileStepResult { + if (!this.active) return this._result(); + + this.ageSeconds += deltaSeconds; + + const result = this.target + ? this._stepHomingMotion(targets, deltaSeconds) + : this._stepLinearMotion(targets, deltaSeconds); + + if (this.active && this.ageSeconds >= this.lifetimeSeconds) this.active = false; + return result; + } + + private _stepLinearMotion( + targets: ProjectileTargetLike[], + deltaSeconds: number, + ): ProjectileStepResult { + this.position.addScaledVector(this.velocity, deltaSeconds); + this._syncVisual(); + + const hitTarget = this._findHitTarget(targets); + if (hitTarget) { + this.active = false; + } + + return this._result(null, hitTarget); + } + + private _stepHomingMotion( + targets: ProjectileTargetLike[], + deltaSeconds: number, + ): ProjectileStepResult { + const target = this.target && !this.target.destroyed ? this.target : null; + if (target) { + const desired = toVec3(target.position).sub(this.position); + if (desired.lengthSq() > 1e-6) { + desired.normalize().multiplyScalar(this.speed); + this.velocity.lerp(desired, clamp(deltaSeconds * this.turnResponse, 0, 1)); + } + } + + this.position.addScaledVector(this.velocity, deltaSeconds); + this._syncVisual(); + + const hitTarget = this._findHitTarget(targets, 1.2); + if (hitTarget) { + this.active = false; + } + return this._result(target, hitTarget); + } + + private _findHitTarget( + targets: ProjectileTargetLike[], + radiusScale = 1, + ): ProjectileTargetLike | null { + const hitRadius = this.hitRadius * radiusScale; + for (const target of targets) { + if (target.destroyed) continue; + const targetPosition = toVec3(target.position); + if (this.position.distanceTo(targetPosition) <= hitRadius) return target; + } + return null; + } + + private _direction(): Vector3 { + if (this.velocity.lengthSq() <= 1e-6) return this.velocity.clone(); + return this.velocity.clone().normalize(); + } + + private _syncVisual(): void { + this.visual.step?.({ + position: this.position, + direction: this._direction(), + velocity: this.velocity, + ageSeconds: this.ageSeconds, + lifetimeSeconds: this.lifetimeSeconds, + }); + } + + private _result( + target: ProjectileTargetLike | null = null, + hittedTarget: ProjectileTargetLike | null = null, + ): ProjectileStepResult { + return { + position: this.position, + target, + hittedTarget, + }; + } + + dispose(): void { + disposeSceneNode(this.group); + } +} diff --git a/playset/modules/world/scene-node-utils.ts b/playset/modules/world/scene-node-utils.ts new file mode 100644 index 0000000..62ca0c7 --- /dev/null +++ b/playset/modules/world/scene-node-utils.ts @@ -0,0 +1,22 @@ +// playset/modules/world/scene-node-utils.ts — scene-graph disposal helper. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/Object3DUtils.js. Renamed disposeObject3D → +// disposeSceneNode: three's Object3D became scene3d's SceneNode, and +// node.destroy() already detaches the whole subtree — geometry/material +// lifetime is host-side (geomFree/materialFree), so the original's recursive +// geometry.dispose()/material.dispose() traverse has no guest analog. The +// disposeObject3D name stays exported as an alias for port compatibility. + +/** The one thing disposal needs — SceneNode qualifies, and so does any + * structurally-driven visual group a logic module was handed. */ +export interface DisposableNode { + destroy(): void; +} + +export function disposeSceneNode(node: DisposableNode | null | undefined): void { + node?.destroy(); +} + +/** GameBlocks-name alias (see header). */ +export const disposeObject3D = disposeSceneNode; diff --git a/playset/modules/world/visual-effects/ground-click-indicator.ts b/playset/modules/world/visual-effects/ground-click-indicator.ts new file mode 100644 index 0000000..666284a --- /dev/null +++ b/playset/modules/world/visual-effects/ground-click-indicator.ts @@ -0,0 +1,117 @@ +// playset/modules/world/visual-effects/ground-click-indicator.ts — the +// expanding, fading ring-and-disk decal that marks a move/click command. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/visual-effects/GroundClickIndicator.js. Step math +// (420 ms fade, 0.42→1.4 scale, 0.06→0.1 rise) is verbatim. Deliberate +// changes for the scene3d surface: +// - constructor options gain `scene: Scene3D`. +// - scene3d has no Circle/Ring geometry: the disk is a thin flat cylinder +// and the ring a thin torus (inner 1.0 / outer 1.18 in plan view), both +// already ground-planar (+Y up), so the group keeps an identity +// quaternion instead of the three plane-canonical rotation — visually +// identical in the fixed +Y-up scene3d space. The ring's local-normal +// offset becomes a +up offset. +// - per-frame material.opacity = ratio·baseOpacity becomes nodeSetTint +// with alpha = ratio over materials that carry their baseOpacity; +// `.materials` holds the two material handles. + +import { MAT, type Scene3D, type SceneNode } from "../../../scene3d/client.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../../math/world-basis.ts"; +import { rgbToAbgr } from "../color-utils.ts"; +import { disposeSceneNode } from "../scene-node-utils.ts"; + +export interface GroundClickIndicatorOptions { + scene: Scene3D; + position: VecLike; + durationMs?: number; + color?: number; + accentColor?: number; + startScale?: number; + endScale?: number; + startUpOffset?: number; + endUpOffset?: number; + ringLocalNormalOffset?: number; + basis?: WorldBasis; +} + +export class GroundClickIndicator { + readonly kind = "command"; + readonly basis: WorldBasis; + remainingMs: number; + readonly maxMs: number; + readonly startScale: number; + readonly endScale: number; + readonly startUpOffset: number; + readonly endUpOffset: number; + + readonly group: SceneNode; + readonly disk: SceneNode; + readonly ring: SceneNode; + /** Material handles, disk then ring (see header). */ + readonly materials: number[]; + + constructor({ + scene, + position, + durationMs = 420, + color = 0x76f0c9, + accentColor = 0xbaf8ec, + startScale = 0.42, + endScale = 1.4, + startUpOffset = 0.06, + endUpOffset = 0.1, + ringLocalNormalOffset = 0.01, + basis = DEFAULT_WORLD_BASIS, + }: GroundClickIndicatorOptions) { + this.basis = basis; + this.remainingMs = durationMs; + this.maxMs = durationMs; + this.startScale = startScale; + this.endScale = endScale; + this.startUpOffset = startUpOffset; + this.endUpOffset = endUpOffset; + + const planar = this.basis.toPlanar(position); + this.group = scene.node(); + this.basis.fromBasisComponents(planar.right, startUpOffset, planar.forward, this.group.position); + this.group.scale.setScalar(startScale); + + const decalFlags = MAT.unlit | MAT.transparent | MAT.doubleSided; + const diskMaterial = scene.material(rgbToAbgr(color, 0.28), decalFlags); + // CircleGeometry(1.02, 40) → thin ground-planar cylinder (see header). + this.disk = scene.mesh(scene.cylinder(1.02, 1.02, 0.004, 40), diskMaterial, this.group); + + const ringMaterial = scene.material(rgbToAbgr(accentColor, 0.96), decalFlags); + // RingGeometry(1.0, 1.18, 48) → torus, ring radius 1.09, tube 0.09. + this.ring = scene.mesh(scene.torus(1.09, 0.09, 48, 6), ringMaterial, this.group); + this.ring.scale.y = 0.04; // flatten the tube toward an annulus decal + this.ring.position.y = ringLocalNormalOffset; + + this.materials = [diskMaterial, ringMaterial]; + } + + step(deltaSeconds = 1 / 60): boolean { + this.remainingMs -= deltaSeconds * 1000; + const ratio = Math.max(0, this.remainingMs / this.maxMs); + const progress = 1 - ratio; + + // opacity = ratio · baseOpacity: the base rides in the material alpha, + // the ratio in the tint alpha (tint multiplies). + const fade = rgbToAbgr(0xffffff, ratio); + this.disk.setTint(fade); + this.ring.setTint(fade); + + this.group.scale.setScalar(this.startScale + (this.endScale - this.startScale) * progress); + this.basis.setHeight( + this.group.position, + this.startUpOffset + (this.endUpOffset - this.startUpOffset) * progress, + ); + + return this.remainingMs > 0; + } + + dispose(): void { + disposeSceneNode(this.group); + } +} diff --git a/playset/modules/world/visual-effects/jet-flame.ts b/playset/modules/world/visual-effects/jet-flame.ts new file mode 100644 index 0000000..4e79a08 --- /dev/null +++ b/playset/modules/world/visual-effects/jet-flame.ts @@ -0,0 +1,97 @@ +// playset/modules/world/visual-effects/jet-flame.ts — throttle/boost-driven +// engine exhaust flame (airplane engines). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/visual-effects/JetFlame.js. The original was a +// ShaderMaterial (radial glow, shock diamonds, hash-noise flicker) on an +// open cylinder — none of that survives a fixed-function surface. The +// documented approximation instead: +// - two nested additive unlit cones (bright warm core inside a dimmer +// colored outer sheath), extending +Z from the nozzle like the original +// pre-rotated geometry (translate(0,-1,0) + rotateX(-π/2) baked into +// child-node quaternions/positions — geometry ops take no transforms). +// - step() keeps the EXACT boostFactor smoothing and scale formulas: +// s = (1-boost)·(0.6+throttle·1.2) + boost·2.2, width = 1.1 + +// max(throttle,boost)·0.4. +// - color shifts 0xff7722 → 0x9999ff by boostFactor via nodeSetTint; +// the shader's noise flicker becomes a deterministic ±18% sine on +// timeSeconds (NOT prng) modulating the outer tint's RGB. +// - the PointLight (0xffaa44, throttle/boost-tracked intensity) has no +// scene3d analog and is dropped. +// - constructor gains `scene: Scene3D` (nodes need an owner). + +import { Color, SRGBColorSpace, type RGB } from "../../../math/color.ts"; +import { Euler } from "../../../math/euler.ts"; +import type { Scene3D, SceneNode } from "../../../scene3d/client.ts"; +import { rgbFloatsToAbgr, rgbToAbgr } from "../color-utils.ts"; + +export interface JetFlameStepState { + throttle: number; + isBoosting: boolean; + timeSeconds: number; + deltaSeconds?: number; +} + +export class JetFlameLocalVisual { + readonly group: SceneNode; + /** Scale target — the original flame mesh's analog (cones are children). */ + readonly flame: SceneNode; + boostFactor: number; + readonly cNormal: Color; + readonly cBoost: Color; + + private readonly outer: SceneNode; + private readonly core: SceneNode; + private readonly _tint = new Color(); + private readonly _rgb: RGB = { r: 0, g: 0, b: 0 }; + + constructor(scene: Scene3D) { + this.group = scene.node(); + this.flame = scene.node(this.group); + + // Original geometry: CylinderGeometry(0.15, 0.03, 2, …, openEnded), + // wide end at the nozzle (local origin), tip trailing to +Z 2. + const along = new Euler(Math.PI * 0.5, 0, 0); // +Y → +Z + this.outer = scene.mesh( + scene.cylinder(0.03, 0.15, 2, 12), + scene.additiveMaterial(rgbToAbgr(0xffffff)), + this.flame, + ); + this.outer.quaternion.setFromEuler(along); + this.outer.position.z = 1; + this.outer.setTint(rgbToAbgr(0xff7722)); + + this.core = scene.mesh( + scene.cylinder(0.012, 0.075, 1.3, 10), + scene.additiveMaterial(rgbToAbgr(0xfff2e0)), // coreColor (1, 1, 0.95)-ish + this.flame, + ); + this.core.quaternion.setFromEuler(along); + this.core.position.z = 0.65; + + this.boostFactor = 0; + this.cNormal = new Color(0xff7722); + this.cBoost = new Color(0x9999ff); + } + + step({ throttle, isBoosting, timeSeconds, deltaSeconds = 1 / 60 }: JetFlameStepState): void { + const targetBoost = isBoosting ? 1.0 : 0.0; + const boostSpeed = 5.0; + this.boostFactor += (targetBoost - this.boostFactor) * Math.min(deltaSeconds * boostSpeed, 1.0); + + const s = (1.0 - this.boostFactor) * (0.6 + throttle * 1.2) + this.boostFactor * 2.2; + + const effectiveThrottle = Math.max(throttle, this.boostFactor); + const widthScale = 1.1 + effectiveThrottle * 0.4; + this.flame.scale.set(widthScale, widthScale, s); + + // Shader flicker (1 + 0.18·noise) → deterministic sine on the same + // time·20 phase, modulating the outer sheath's additive brightness. + // Colors lerp in the working (linear) space like three, then pack as + // sRGB bytes — the byte order tint colors are quoted in. + const flicker = 1 + 0.18 * Math.sin(timeSeconds * 20); + this._tint.copy(this.cNormal).lerp(this.cBoost, this.boostFactor); + const rgb = this._tint.getRGB(this._rgb, SRGBColorSpace); + this.outer.setTint(rgbFloatsToAbgr(rgb.r * flicker, rgb.g * flicker, rgb.b * flicker)); + } +} diff --git a/playset/modules/world/visual-effects/vehicle-tire-mark-renderer.ts b/playset/modules/world/visual-effects/vehicle-tire-mark-renderer.ts new file mode 100644 index 0000000..57424f6 --- /dev/null +++ b/playset/modules/world/visual-effects/vehicle-tire-mark-renderer.ts @@ -0,0 +1,239 @@ +// playset/modules/world/visual-effects/vehicle-tire-mark-renderer.ts — +// terrain-snapped tire-mark ribbons behind a vehicle's front/rear axles. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/visual-effects/VehicleTireMarkRenderer.js. +// Segment logic (minDistance/minSpeed gates, reset-on-airborne ribbon +// breaks, ring-buffer eviction, two tracks with per-track color/opacity) is +// verbatim. Deliberate changes for the scene3d surface: +// - the dynamic ring-buffer BufferGeometry (18 floats/segment quad) +// becomes a BeamPool per track: one beam per segment (a→b centerline, +// `width`, per-entry track color) — the host owns quad expansion, so +// the original's direction×up edge math is gone. polygonOffset has no +// analog; the terrain `lift` (0.026) is what keeps marks above ground. +// - pool capacities are fixed at creation: maxSegments is capped at 256 +// per track (pool sanity; the original default 1200 was sized for a +// desktop dynamic buffer). +// - constructor options gain `scene: Scene3D`. `.group` remains as a bare +// node for API parity (pools are not nodes). dispose() empties the +// pools and destroys the group; the pools themselves are freed with the +// scene. + +import { Vector3 } from "../../../math/vector3.ts"; +import { + MAT, + type BeamPool, + type Scene3D, + type SceneNode, +} from "../../../scene3d/client.ts"; +import { BEAM_STRIDE } from "../../../scene3d/ops.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis } from "../../math/world-basis.ts"; +import { rgbToAbgr } from "../color-utils.ts"; +import { disposeSceneNode } from "../scene-node-utils.ts"; + +const MAX_POOL_SEGMENTS = 256; + +export interface TireTerrainSampler { + heightAt(right: number, forward: number): number; +} + +export interface TireMarkVehicleState { + grounded: boolean; + horizontalSpeed: number; + position: Vector3; + bodyFrame: { right: Vector3; forward: Vector3 }; +} + +interface Track { + pool: BeamPool; + color: number; + /** Ring buffer of segment centerlines, 6 floats (ax,ay,az,bx,by,bz) each. */ + segments: number[]; + last: [Vector3 | null, Vector3 | null]; + segmentCount: number; +} + +export interface VehicleTireMarkRendererOptions { + scene: Scene3D; + terrainSampler: TireTerrainSampler; + maxSegments?: number; + minDistance?: number; + width?: number; + lift?: number; + halfTrack?: number; + frontForwardOffset?: number; + rearForwardOffset?: number; + frontColor?: number; + rearColor?: number; + frontOpacity?: number; + rearOpacity?: number; + minSpeed?: number; + basis?: WorldBasis; +} + +export class VehicleTireMarkRenderer { + readonly group: SceneNode; + readonly basis: WorldBasis; + terrainSampler: TireTerrainSampler; + readonly maxSegments: number; + readonly minDistance: number; + readonly width: number; + readonly lift: number; + readonly halfTrack: number; + readonly frontForwardOffset: number; + readonly rearForwardOffset: number; + readonly minSpeed: number; + readonly front: Track; + readonly rear: Track; + + constructor({ + scene, + terrainSampler, + maxSegments = 1200, + minDistance = 0.16, + width = 0.18, + lift = 0.026, + halfTrack = 0.84, + frontForwardOffset = 1.07, + rearForwardOffset = -1.07, + frontColor = 0x161719, + rearColor = 0x8d2119, + frontOpacity = 0.42, + rearOpacity = 0.58, + minSpeed = 0.6, + basis = DEFAULT_WORLD_BASIS, + }: VehicleTireMarkRendererOptions) { + this.group = scene.node(); + this.basis = basis; + this.terrainSampler = terrainSampler; + this.maxSegments = Math.max(1, Math.min(MAX_POOL_SEGMENTS, Math.floor(maxSegments))); + this.minDistance = minDistance; + this.width = width; + this.lift = lift; + this.halfTrack = halfTrack; + this.frontForwardOffset = frontForwardOffset; + this.rearForwardOffset = rearForwardOffset; + this.minSpeed = minSpeed; + this.front = this.createTrack(scene, frontColor, frontOpacity); + this.rear = this.createTrack(scene, rearColor, rearOpacity); + } + + get frontSegments(): number { + return this.front.segmentCount; + } + + get rearSegments(): number { + return this.rear.segmentCount; + } + + get totalSegments(): number { + return this.frontSegments + this.rearSegments; + } + + setTerrainSampler(terrainSampler: TireTerrainSampler): void { + this.terrainSampler = terrainSampler; + } + + private createTrack(scene: Scene3D, color: number, opacity: number): Track { + const mat = scene.material( + rgbToAbgr(0xffffff, opacity), + MAT.unlit | MAT.transparent | MAT.doubleSided, + ); + return { + pool: scene.beamPool(this.maxSegments, mat), + color: rgbToAbgr(color, opacity), + segments: [], + last: [null, null], + segmentCount: 0, + }; + } + + clear(): void { + for (const track of [this.front, this.rear]) { + track.segments.length = 0; + track.last[0] = null; + track.last[1] = null; + track.segmentCount = 0; + this.refresh(track); + } + } + + resetLast(): void { + for (const track of [this.front, this.rear]) { + track.last[0] = null; + track.last[1] = null; + } + } + + step(vehicleState: TireMarkVehicleState): void { + if (!vehicleState.grounded || vehicleState.horizontalSpeed < this.minSpeed) { + this.resetLast(); + return; + } + + this.stepTrack(vehicleState, this.front, this.frontForwardOffset); + this.stepTrack(vehicleState, this.rear, this.rearForwardOffset); + } + + private stepTrack(vehicleState: TireMarkVehicleState, track: Track, forwardOffset: number): void { + let changed = false; + const points = [ + this.tirePoint(vehicleState, forwardOffset, -1), + this.tirePoint(vehicleState, forwardOffset, 1), + ]; + + for (let i = 0; i < points.length; i += 1) { + const last = track.last[i]; + if (last) changed = this.appendSegment(track, last, points[i]) || changed; + track.last[i] = points[i].clone(); + } + if (changed) this.refresh(track); + } + + private tirePoint(vehicleState: TireMarkVehicleState, forwardOffset: number, side: number): Vector3 { + const point = vehicleState.position + .clone() + .addScaledVector(vehicleState.bodyFrame.right, side * this.halfTrack) + .addScaledVector(vehicleState.bodyFrame.forward, forwardOffset); + const planar = this.basis.toPlanar(point); + const up = this.terrainSampler.heightAt(planar.right, planar.forward) + this.lift; + return this.basis.fromBasisComponents(planar.right, up, planar.forward, point); + } + + private appendSegment(track: Track, from: Vector3, to: Vector3): boolean { + if (to.clone().sub(from).length() < this.minDistance) return false; + + track.segments.push(from.x, from.y, from.z, to.x, to.y, to.z); + track.segmentCount += 1; + + while (track.segmentCount > this.maxSegments) { + track.segments.splice(0, 6); // drop the oldest segment + track.segmentCount -= 1; + } + return true; + } + + /** Rewrite the track's pool from its segment ring (pools are + * replace-per-frame; scene.flush() ships the buffer). */ + private refresh(track: Track): void { + const { pool } = track; + for (let i = 0; i < track.segmentCount; i += 1) { + const s = i * 6; + const b = i * BEAM_STRIDE; + pool.buf[b] = track.segments[s]; + pool.buf[b + 1] = track.segments[s + 1]; + pool.buf[b + 2] = track.segments[s + 2]; + pool.buf[b + 3] = track.segments[s + 3]; + pool.buf[b + 4] = track.segments[s + 4]; + pool.buf[b + 5] = track.segments[s + 5]; + pool.buf[b + 6] = this.width; + pool.colors[i] = track.color; + } + pool.count = track.segmentCount; + } + + dispose(): void { + this.clear(); + disposeSceneNode(this.group); + } +} diff --git a/playset/modules/world/visual-effects/weapon-effects-system.ts b/playset/modules/world/visual-effects/weapon-effects-system.ts new file mode 100644 index 0000000..08ab373 --- /dev/null +++ b/playset/modules/world/visual-effects/weapon-effects-system.ts @@ -0,0 +1,342 @@ +// playset/modules/world/visual-effects/weapon-effects-system.ts — pooled +// weapon feedback: additive tracer beams + hit-burst particle sprites. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/world/visual-effects/WeaponEffectsSystem.js. The +// per-slot CPU sim (round-robin allocation, age/ttl, 3 u/s² gravity, +// (1-t) color fade, activeParticles compaction) is verbatim. Deliberate +// changes for the scene3d surface: +// - LineSegments → BeamPool (capacity maxTracers; TRACER_WIDTH world +// units stands in for the 1px line), Points → SpritePool (capacity +// maxParticles, size 0.05). The vertex-color fade becomes per-entry +// pool colors: fade scales the RGB bytes, the material opacity 0.9 +// rides in each entry's alpha byte. +// - pools are replace-per-frame: every live set is rebuilt after each +// spawn/step/clear; scene.flush() (owner's job) ships them. +// - spawnTracer/emitHitBurst return the pool objects (`.tracerLines` / +// `.particlePoints` keep their names). `.group` remains as a bare node +// for API parity; pools are freed with the scene. +// - constructor options gain `scene: Scene3D`, plus `tracerWidth` / +// `particleSize` (world units). The original's LineSegments were 1px +// screen-space and PointsMaterial size was fixed at 0.05 — as world-space +// quads those constants only suit character-scale worlds, so large-scale +// games (flight sims) must widen them. Defaults keep the old constants. + +import { Vector3 } from "../../../math/vector3.ts"; +import type { BeamPool, Scene3D, SceneNode, SpritePool } from "../../../scene3d/client.ts"; +import { BEAM_STRIDE, SPRITE_STRIDE } from "../../../scene3d/ops.ts"; +import { DEFAULT_PRNG } from "../../math/random-utils.ts"; +import { toVec3 } from "../../math/vector3-utils.ts"; +import { rgbFloatsToAbgr, rgbToAbgr } from "../color-utils.ts"; +import { disposeSceneNode } from "../scene-node-utils.ts"; +import type { VecLike } from "../../math/world-basis.ts"; + +/** World-space stand-in for the original 1px additive line (default). */ +const TRACER_WIDTH = 0.05; +/** PointsMaterial size 0.05 (default). */ +const PARTICLE_SIZE = 0.05; +/** LineBasicMaterial/PointsMaterial opacity. */ +const POOL_OPACITY = 0.9; + +interface TracerSlot { + active: boolean; + ageSeconds: number; + ttlSeconds: number; + /** Base color components, 0..1 (the original's THREE.Color slot). */ + r: number; + g: number; + b: number; + fade: number; +} + +export interface WeaponEffectsSystemOptions { + scene: Scene3D; + maxEffects?: number; + prng?: { random(): number }; + /** Tracer beam width in world units (default 0.05 — character scale). */ + tracerWidth?: number; + /** Burst particle sprite size in world units (default 0.05). */ + particleSize?: number; +} + +export class WeaponEffectsSystem { + readonly group: SceneNode; + readonly maxEffects: number; + readonly maxTracers: number; + readonly maxParticles: number; + readonly prng: { random(): number }; + readonly tracerWidth: number; + readonly particleSize: number; + readonly flashes: unknown[] = []; + readonly effects: unknown[] = []; + + readonly tracerLines: BeamPool; + readonly particlePoints: SpritePool; + + private readonly tracerPositions: Float32Array; + private readonly tracers: TracerSlot[] = []; + private nextTracer = 0; + + private readonly particlePositions: Float32Array; + private readonly particleVelocities: Float32Array; + private readonly particleBaseColors: Float32Array; + private readonly particleFades: Float32Array; + private readonly particleAges: Float32Array; + private readonly particleTtls: Float32Array; + private readonly particleActive: Uint8Array; + private readonly activeParticles: number[] = []; + private nextParticle = 0; + + private readonly _tmpOrigin = new Vector3(); + private readonly _tmpForward = new Vector3(); + + constructor({ + scene, + maxEffects = 16, + prng = DEFAULT_PRNG, + tracerWidth = TRACER_WIDTH, + particleSize = PARTICLE_SIZE, + }: WeaponEffectsSystemOptions) { + this.group = scene.node(); + this.maxEffects = Math.max(8, Math.floor(maxEffects)); + this.maxTracers = this.maxEffects; + this.maxParticles = Math.max(128, this.maxEffects * 8); + this.prng = prng; + this.tracerWidth = tracerWidth; + this.particleSize = particleSize; + + const poolMat = scene.additiveMaterial(rgbToAbgr(0xffffff)); + this.tracerLines = scene.beamPool(this.maxTracers, poolMat); + this.particlePoints = scene.spritePool(this.maxParticles, poolMat); + + this.tracerPositions = new Float32Array(this.maxTracers * 6); + for (let i = 0; i < this.maxTracers; i += 1) { + this.tracers.push({ active: false, ageSeconds: 0, ttlSeconds: 0, r: 0, g: 0, b: 0, fade: 0 }); + } + + this.particlePositions = new Float32Array(this.maxParticles * 3); + this.particleVelocities = new Float32Array(this.maxParticles * 3); + this.particleBaseColors = new Float32Array(this.maxParticles * 3); + this.particleFades = new Float32Array(this.maxParticles); + this.particleAges = new Float32Array(this.maxParticles); + this.particleTtls = new Float32Array(this.maxParticles); + this.particleActive = new Uint8Array(this.maxParticles); + } + + spawnTracer( + from: VecLike | null | undefined, + to: VecLike | null | undefined, + color = 0xffe7ad, + ttlSeconds = 0.08, + ): BeamPool | null { + if (!from || !to) return null; + + const index = this.nextTracer; + this.nextTracer = (this.nextTracer + 1) % this.maxTracers; + + const a = toVec3(from); + const b = toVec3(to); + const offset = index * 6; + this.tracerPositions[offset] = a.x; + this.tracerPositions[offset + 1] = a.y; + this.tracerPositions[offset + 2] = a.z; + this.tracerPositions[offset + 3] = b.x; + this.tracerPositions[offset + 4] = b.y; + this.tracerPositions[offset + 5] = b.z; + + const tracer = this.tracers[index]; + tracer.r = ((color >> 16) & 255) / 255; + tracer.g = ((color >> 8) & 255) / 255; + tracer.b = (color & 255) / 255; + tracer.active = true; + tracer.ageSeconds = 0; + tracer.ttlSeconds = Math.max(0.001, ttlSeconds); + tracer.fade = 1; + + this._rebuildTracers(); + return this.tracerLines; + } + + emitHitBurst( + position: VecLike | null | undefined, + direction: VecLike = new Vector3(0, 1, 0), + color = 0xff5533, + count = 10, + speed = 1.5, + spread = 0.8, + lifetimeMs = 300, + ): SpritePool | null { + if (!position) return null; + + const particleCount = Math.max(0, Math.floor(count)); + const ttlSeconds = Math.max(0.02, lifetimeMs / 1000); + this._tmpOrigin.copy(toVec3(position)); + this._tmpForward.copy(toVec3(direction)); + if (this._tmpForward.lengthSq() <= 1e-6) this._tmpForward.set(0, 1, 0); + this._tmpForward.normalize(); + const r = ((color >> 16) & 255) / 255; + const g = ((color >> 8) & 255) / 255; + const b = (color & 255) / 255; + + for (let i = 0; i < particleCount; i += 1) { + const index = this.nextParticle; + const offset = index * 3; + this.nextParticle = (this.nextParticle + 1) % this.maxParticles; + + const vx = ((this.prng.random() - 0.5) * spread + this._tmpForward.x) * speed; + const vy = ((this.prng.random() - 0.5) * spread + this._tmpForward.y) * speed; + const vz = ((this.prng.random() - 0.5) * spread + this._tmpForward.z) * speed; + + this.particlePositions[offset] = this._tmpOrigin.x; + this.particlePositions[offset + 1] = this._tmpOrigin.y; + this.particlePositions[offset + 2] = this._tmpOrigin.z; + this.particleVelocities[offset] = vx; + this.particleVelocities[offset + 1] = vy; + this.particleVelocities[offset + 2] = vz; + this.particleBaseColors[offset] = r; + this.particleBaseColors[offset + 1] = g; + this.particleBaseColors[offset + 2] = b; + this.particleFades[index] = 1; + this.particleAges[index] = 0; + this.particleTtls[index] = ttlSeconds; + if (!this.particleActive[index]) { + this.activeParticles.push(index); + } + this.particleActive[index] = 1; + } + + this._rebuildParticles(); + return this.particlePoints; + } + + step(deltaSeconds = 1 / 60): void { + let tracersChanged = false; + let particlesChanged = false; + + for (let i = 0; i < this.tracers.length; i += 1) { + const tracer = this.tracers[i]; + if (!tracer.active) continue; + + tracer.ageSeconds += deltaSeconds; + const t = Math.min(1, tracer.ageSeconds / tracer.ttlSeconds); + tracer.fade = 1 - t; + tracersChanged = true; + + if (tracer.ageSeconds >= tracer.ttlSeconds) { + tracer.active = false; + tracer.ageSeconds = 0; + tracer.ttlSeconds = 0; + tracer.fade = 0; + } + } + + let writeParticle = 0; + for (let i = 0; i < this.activeParticles.length; i += 1) { + const index = this.activeParticles[i]; + if (!this.particleActive[index]) continue; + + const offset = index * 3; + this.particleAges[index] += deltaSeconds; + const t = Math.min(1, this.particleAges[index] / this.particleTtls[index]); + this.particleFades[index] = 1 - t; + this.particleVelocities[offset + 1] -= 3 * deltaSeconds; // gravity 3 u/s² + this.particlePositions[offset] += this.particleVelocities[offset] * deltaSeconds; + this.particlePositions[offset + 1] += this.particleVelocities[offset + 1] * deltaSeconds; + this.particlePositions[offset + 2] += this.particleVelocities[offset + 2] * deltaSeconds; + particlesChanged = true; + + if (this.particleAges[index] >= this.particleTtls[index]) { + this.particleActive[index] = 0; + this.particleFades[index] = 0; + } else { + this.activeParticles[writeParticle] = index; + writeParticle += 1; + } + } + this.activeParticles.length = writeParticle; + + if (tracersChanged) this._rebuildTracers(); + if (particlesChanged) this._rebuildParticles(); + } + + clear(): void { + for (const tracer of this.tracers) { + tracer.active = false; + tracer.ageSeconds = 0; + tracer.ttlSeconds = 0; + tracer.fade = 0; + } + this.tracerPositions.fill(0); + + this.particlePositions.fill(0); + this.particleVelocities.fill(0); + this.particleBaseColors.fill(0); + this.particleFades.fill(0); + this.particleAges.fill(0); + this.particleTtls.fill(0); + this.particleActive.fill(0); + this.activeParticles.length = 0; + + this._rebuildTracers(); + this._rebuildParticles(); + + this.effects.length = 0; + } + + /** Repack live tracers into the beam pool, slot order (deterministic). */ + private _rebuildTracers(): void { + const pool = this.tracerLines; + let n = 0; + for (let i = 0; i < this.tracers.length; i += 1) { + const tracer = this.tracers[i]; + if (!tracer.active) continue; + const s = i * 6; + const b = n * BEAM_STRIDE; + pool.buf[b] = this.tracerPositions[s]; + pool.buf[b + 1] = this.tracerPositions[s + 1]; + pool.buf[b + 2] = this.tracerPositions[s + 2]; + pool.buf[b + 3] = this.tracerPositions[s + 3]; + pool.buf[b + 4] = this.tracerPositions[s + 4]; + pool.buf[b + 5] = this.tracerPositions[s + 5]; + pool.buf[b + 6] = this.tracerWidth; + pool.colors[n] = rgbFloatsToAbgr( + tracer.r * tracer.fade, + tracer.g * tracer.fade, + tracer.b * tracer.fade, + POOL_OPACITY, + ); + n += 1; + } + pool.count = n; + } + + /** Repack live particles into the sprite pool, activeParticles order. */ + private _rebuildParticles(): void { + const pool = this.particlePoints; + let n = 0; + for (let i = 0; i < this.activeParticles.length; i += 1) { + const index = this.activeParticles[i]; + if (!this.particleActive[index]) continue; + const offset = index * 3; + const b = n * SPRITE_STRIDE; + pool.buf[b] = this.particlePositions[offset]; + pool.buf[b + 1] = this.particlePositions[offset + 1]; + pool.buf[b + 2] = this.particlePositions[offset + 2]; + pool.buf[b + 3] = this.particleSize; + const fade = this.particleFades[index]; + pool.colors[n] = rgbFloatsToAbgr( + this.particleBaseColors[offset] * fade, + this.particleBaseColors[offset + 1] * fade, + this.particleBaseColors[offset + 2] * fade, + POOL_OPACITY, + ); + n += 1; + } + pool.count = n; + } + + dispose(): void { + this.clear(); + disposeSceneNode(this.group); + } +} diff --git a/playset/scene3d/client.ts b/playset/scene3d/client.ts new file mode 100644 index 0000000..7ca9309 --- /dev/null +++ b/playset/scene3d/client.ts @@ -0,0 +1,380 @@ +// playset/scene3d/client.ts — the guest-side retained mirror over Scene3dOps. +// +// Port affordance: a SceneNode carries mutable `position` / `quaternion` / +// `scale` value objects (playset/math, three-compatible), so GameBlocks-style +// model controllers port verbatim: `node.position.copy(p)`, +// `node.quaternion.setFromRotationMatrix(m)`. Nothing crosses the FFI when +// you mutate — `scene.flush()` once per frame diffs every node against its +// last-sent pose and issues ONE batched writePoses op (Law 1: state lives +// guest-side, writes are batched intent). +// +// Degradation: constructed with no ops (host has no 3D), the whole client is +// a pure-state mirror — factories, controllers and flush() all run, nothing +// is emitted. Games stay headless-testable on hosts without a 3D core. + +import { Vector3 } from "../math/vector3.ts"; +import { Quaternion } from "../math/quaternion.ts"; +import { Matrix4 } from "../math/matrix4.ts"; +import { + detectScene3d, + MAT, + POSE_STRIDE, + SPRITE_STRIDE, + BEAM_STRIDE, + type Scene3dOps, +} from "./ops.ts"; + +export { MAT } from "./ops.ts"; + +const NO_TINT = 0xffffffff; + +/** A node in the retained transform hierarchy (three Object3D/Group analog). */ +export class SceneNode { + readonly position = new Vector3(); + readonly quaternion = new Quaternion(); + readonly scale = new Vector3(1, 1, 1); + + /** @internal */ readonly __id: number; + /** @internal */ __last: Float64Array | null = null; // 10 floats; null = never sent + private __visible = true; + private __dead = false; + + constructor( + private readonly scene: Scene3D, + id: number, + public parent: SceneNode | null, + ) { + this.__id = id; + } + + get visible(): boolean { + return this.__visible; + } + + set visible(on: boolean) { + if (this.__visible === on) return; + this.__visible = on; + this.scene.__ops?.nodeSetVisible(this.__id, on ? 1 : 0); + } + + /** Reparent `child` under this node (three `group.add(child)` shape). */ + add(child: SceneNode): this { + if (child.parent === this) return this; + child.parent = this; + this.scene.__ops?.nodeSetParent(child.__id, this.__id); + return this; + } + + /** Attach geometry+material (handles from Scene3D geometry/material helpers). */ + setMesh(geomId: number, matId: number): this { + this.scene.__ops?.meshSet(this.__id, geomId, matId); + return this; + } + + /** Per-instance ABGR tint; call with no args to clear. */ + setTint(color: number = NO_TINT): this { + this.scene.__ops?.nodeSetTint(this.__id, color >>> 0); + return this; + } + + /** Destroy this node and its subtree (mirrors are dropped on flush). */ + destroy(): void { + if (this.__dead) return; + this.__dead = true; + this.scene.__removeNode(this); + this.scene.__ops?.nodeDestroy(this.__id); + } + + /** @internal */ + get __alive(): boolean { + return !this.__dead; + } +} + +/** The scene camera — a pose plus lens; flushed with the node batch. */ +export class Camera3D { + readonly position = new Vector3(0, 0, 10); + readonly quaternion = new Quaternion(); + fovY = (60 * Math.PI) / 180; + znear = 0.1; + zfar = 1000; + /** Viewport aspect for guest-side ray math (PSP screen by default). The + * host renders with the bound node's real rect; this only feeds + * unprojection helpers like rayFromNdc. */ + aspect = 480 / 272; + /** @internal */ __last: Float64Array | null = null; + + private static readonly _m = new Matrix4(); + + /** Aim the camera at a world point (+Y-up look-at, three semantics). */ + lookAt(target: Vector3): this { + Camera3D._m.lookAt(this.position, target, Camera3D._up); + this.quaternion.setFromRotationMatrix(Camera3D._m); + return this; + } + + /** World-space ray direction through an NDC point (crosshair picking — + * the guest-side replacement for Raycaster.setFromCamera). */ + rayFromNdc(ndcX: number, ndcY: number, target = new Vector3()): Vector3 { + const halfTan = Math.tan(this.fovY / 2); + return target + .set(ndcX * halfTan * this.aspect, ndcY * halfTan, -1) + .applyQuaternion(this.quaternion) + .normalize(); + } + + private static readonly _up = new Vector3(0, 1, 0); +} + +/** A fixed-capacity billboard pool. Refill `pos`/`colors` then set `count`. */ +export class SpritePool { + readonly buf: Float32Array; + readonly colors: Uint32Array; + count = 0; + + constructor( + private readonly scene: Scene3D, + /** @internal */ readonly __id: number, + readonly capacity: number, + stride: number, + ) { + this.buf = new Float32Array(capacity * stride); + this.colors = new Uint32Array(capacity); + } + + /** @internal */ + __flush(): void { + const ops = this.scene.__ops; + if (!ops) return; + if (this.__id < 0) return; + // Pools are replace-per-frame by contract; skip only when empty twice. + if (this.count === 0 && this.__wasEmpty) return; + this.__wasEmpty = this.count === 0; + if (this instanceof BeamPool) ops.writeBeams(this.__id, this.buf, this.colors, this.count); + else ops.writeSprites(this.__id, this.buf, this.colors, this.count); + } + + private __wasEmpty = false; +} + +export class BeamPool extends SpritePool {} + +/** + * The retained scene root. One Scene3D per . All handle caches + * (geometry, material) are per-scene-client but host handles are global — + * two Scene3D instances sharing one host share dedup'd geoms transparently. + */ +export class Scene3D { + /** @internal — null when the host has no 3D (pure-mirror mode). */ + readonly __ops: Scene3dOps | null; + /** @internal */ readonly __scene: number; + readonly camera = new Camera3D(); + + private readonly nodes: SceneNode[] = []; + private readonly matCache = new Map(); + private readonly geomCache = new Map(); + private readonly pools: SpritePool[] = []; + private poseBuf = new Float32Array(64 * POSE_STRIDE); + private nextLocalId = 1; // pure-mirror mode id spring + + constructor(injected?: Scene3dOps | null) { + this.__ops = injected === undefined ? detectScene3d() : injected; + this.__scene = this.__ops ? this.__ops.sceneCreate() : 0; + } + + // -- nodes ----------------------------------------------------------------- + + node(parent?: SceneNode): SceneNode { + const pid = parent ? parent.__id : 0; + const id = this.__ops ? this.__ops.nodeCreate(this.__scene, pid) : this.nextLocalId++; + const n = new SceneNode(this, id, parent ?? null); + this.nodes.push(n); + return n; + } + + /** Node with a mesh attached — the `new Mesh(geo, mat)` + `add` shorthand. */ + mesh(geomId: number, matId: number, parent?: SceneNode): SceneNode { + return this.node(parent).setMesh(geomId, matId); + } + + /** @internal */ + __removeNode(n: SceneNode): void { + const i = this.nodes.indexOf(n); + if (i >= 0) this.nodes.splice(i, 1); + } + + // -- geometry (cached by params — factories can re-request freely) ---------- + + box(hx: number, hy: number, hz: number): number { + return this.geom(`b|${hx}|${hy}|${hz}`, (o) => o.geomBox(hx, hy, hz)); + } + sphere(radius: number, segments = 12): number { + return this.geom(`s|${radius}|${segments}`, (o) => o.geomSphere(radius, segments)); + } + cylinder(rTop: number, rBottom: number, height: number, segments = 12): number { + return this.geom(`c|${rTop}|${rBottom}|${height}|${segments}`, (o) => + o.geomCylinder(rTop, rBottom, height, segments), + ); + } + cone(radius: number, height: number, segments = 12): number { + return this.geom(`k|${radius}|${height}|${segments}`, (o) => o.geomCone(radius, height, segments)); + } + plane(w: number, d: number): number { + return this.geom(`p|${w}|${d}`, (o) => o.geomPlane(w, d)); + } + torus(radius: number, tube: number, segments = 16, tubeSegments = 8): number { + return this.geom(`t|${radius}|${tube}|${segments}|${tubeSegments}`, (o) => + o.geomTorus(radius, tube, segments, tubeSegments), + ); + } + /** Uncached (buffers are unique by construction). */ + meshGeom(positions: Float32Array, indices: Uint32Array, colors: Float32Array | null): number { + return this.__ops ? this.__ops.geomMesh(positions, indices, colors) : this.nextLocalId++; + } + heightfield( + w: number, + d: number, + cols: number, + rows: number, + heights: Float32Array, + colors: Float32Array | null, + ): number { + return this.__ops + ? this.__ops.geomHeightfield(w, d, cols, rows, heights, colors) + : this.nextLocalId++; + } + + private geom(key: string, make: (o: Scene3dOps) => number): number { + const hit = this.geomCache.get(key); + if (hit !== undefined) return hit; + const id = this.__ops ? make(this.__ops) : this.nextLocalId++; + this.geomCache.set(key, id); + return id; + } + + // -- materials --------------------------------------------------------------- + + material(color: number, flags = 0): number { + const key = `${color >>> 0}|${flags}`; + const hit = this.matCache.get(key); + if (hit !== undefined) return hit; + const id = this.__ops ? this.__ops.material(color >>> 0, flags) : this.nextLocalId++; + this.matCache.set(key, id); + return id; + } + + // -- environment ---------------------------------------------------------------- + + sun(dir: Vector3, color: number): void { + this.__ops?.sun(this.__scene, dir.x, dir.y, dir.z, color >>> 0); + } + ambient(skyColor: number, groundColor: number): void { + this.__ops?.ambient(this.__scene, skyColor >>> 0, groundColor >>> 0); + } + fog(color: number, near: number, far: number): void { + this.__ops?.fog(this.__scene, color >>> 0, near, far); + } + sky(zenithColor: number, horizonColor: number): void { + this.__ops?.sky(this.__scene, zenithColor >>> 0, horizonColor >>> 0); + } + + // -- pools -------------------------------------------------------------------------- + + spritePool(capacity: number, matId: number): SpritePool { + const id = this.__ops ? this.__ops.spritePool(this.__scene, capacity, matId) : -1; + const p = new SpritePool(this, id, capacity, SPRITE_STRIDE); + this.pools.push(p); + return p; + } + beamPool(capacity: number, matId: number): BeamPool { + const id = this.__ops ? this.__ops.beamPool(this.__scene, capacity, matId) : -1; + const p = new BeamPool(this, id, capacity, BEAM_STRIDE); + this.pools.push(p); + return p; + } + + /** An additive unlit material — the pool default (glows, tracers). */ + additiveMaterial(color: number): number { + return this.material(color, MAT.unlit | MAT.additive); + } + + // -- frame flush ----------------------------------------------------------------------- + + /** + * Push every changed pose (+ camera) and pool in ONE batch. Call once per + * frame after the sim step — playset's game loop does this for you. + */ + flush(): void { + const ops = this.__ops; + if (!ops) return; + let count = 0; + const need = (this.nodes.length + 1) * POSE_STRIDE; + if (this.poseBuf.length < need) { + this.poseBuf = new Float32Array(Math.ceil(need * 1.5)); + } + for (const n of this.nodes) { + if (!n.__alive) continue; + if (!poseDirty(n.__last, n.position, n.quaternion, n.scale)) continue; + n.__last ??= new Float64Array(10); + writePose(n.__last, n.position, n.quaternion, n.scale); + const o = count * POSE_STRIDE; + this.poseBuf[o] = n.__id; + this.poseBuf[o + 1] = n.position.x; + this.poseBuf[o + 2] = n.position.y; + this.poseBuf[o + 3] = n.position.z; + this.poseBuf[o + 4] = n.quaternion.x; + this.poseBuf[o + 5] = n.quaternion.y; + this.poseBuf[o + 6] = n.quaternion.z; + this.poseBuf[o + 7] = n.quaternion.w; + this.poseBuf[o + 8] = n.scale.x; + this.poseBuf[o + 9] = n.scale.y; + this.poseBuf[o + 10] = n.scale.z; + count++; + } + if (count > 0) ops.writePoses(this.poseBuf, count); + const cam = this.camera; + if (poseDirty(cam.__last, cam.position, cam.quaternion, LENS.set(cam.fovY, cam.znear, cam.zfar))) { + cam.__last ??= new Float64Array(10); + writePose(cam.__last, cam.position, cam.quaternion, LENS); + ops.camera( + this.__scene, + cam.position.x, cam.position.y, cam.position.z, + cam.quaternion.x, cam.quaternion.y, cam.quaternion.z, cam.quaternion.w, + cam.fovY, cam.znear, cam.zfar, + ); + } + for (const p of this.pools) p.__flush(); + } + + /** Bind to a laid-out `ui` node (Viewport3D does this for you). */ + bindViewport(uiNodeId: number): void { + this.__ops?.bindViewport(uiNodeId, this.__scene); + } + unbindViewport(uiNodeId: number): void { + this.__ops?.bindViewport(uiNodeId, 0); + } + + destroy(): void { + this.__ops?.sceneDestroy(this.__scene); + this.nodes.length = 0; + this.pools.length = 0; + } +} + +// Scratch Vector3 that carries (fovY, znear, zfar) through the pose differ. +const LENS = new Vector3(); + +function poseDirty(last: Float64Array | null, p: Vector3, q: Quaternion, s: Vector3): boolean { + if (!last) return true; + return ( + last[0] !== p.x || last[1] !== p.y || last[2] !== p.z || + last[3] !== q.x || last[4] !== q.y || last[5] !== q.z || last[6] !== q.w || + last[7] !== s.x || last[8] !== s.y || last[9] !== s.z + ); +} + +function writePose(last: Float64Array, p: Vector3, q: Quaternion, s: Vector3): void { + last[0] = p.x; last[1] = p.y; last[2] = p.z; + last[3] = q.x; last[4] = q.y; last[5] = q.z; last[6] = q.w; + last[7] = s.x; last[8] = s.y; last[9] = s.z; +} diff --git a/playset/scene3d/index.ts b/playset/scene3d/index.ts new file mode 100644 index 0000000..7a2b141 --- /dev/null +++ b/playset/scene3d/index.ts @@ -0,0 +1,14 @@ +// @pocketjs/playset/scene3d — the scene3d surface: contract, client, viewport. + +export { + GEOM_KIND, + MAT, + POSE_STRIDE, + SPRITE_STRIDE, + BEAM_STRIDE, + detectScene3d, + type Scene3dOps, +} from "./ops.ts"; +export { Scene3D, SceneNode, Camera3D, SpritePool, BeamPool } from "./client.ts"; +export { Viewport3D, type Viewport3DProps } from "./viewport.ts"; +export { createScene3dSim } from "./sim.ts"; diff --git a/playset/scene3d/ops.ts b/playset/scene3d/ops.ts new file mode 100644 index 0000000..9757dff --- /dev/null +++ b/playset/scene3d/ops.ts @@ -0,0 +1,204 @@ +// playset/scene3d/ops.ts — the `scene3d` surface contract (RUNTIMES.md §3). +// +// scene3d is a CLOSED PRESENTATION VOCABULARY for real-time 3D mini-games — +// the 3D sibling of the `ui` surface. It is deliberately NOT a universal +// scene graph and NOT Three.js-compatible: every verb here is expressible on +// a fixed-function GPU (PSP GE) as well as on wgpu/Metal, and every verb is +// a WRITE. There are no reads: picking, collision and visibility queries are +// the guest's job (playset ships deterministic TS colliders), so a frame's +// pixels are a pure function of the op stream — no FFI round-trips on hot +// paths, no hidden inputs, and the whole surface replays under host-sim. +// +// Hosts install this namespace as `globalThis.s3` (native) or inject it +// (web/wasm/test), exactly like the `ui` surface in src/host.ts. Hosts +// without 3D support simply omit it — degrades to an empty +// laid-out box, the same graceful-absence contract as