// AABB character controller. Player is an axis-aligned box; world parts are // axis-aligned boxes. Movement resolved per-axis with auto-step. import * as THREE from 'three'; import { SOLID_TYPES } from './world.js'; export const PLAYER = { hw: 0.45, hh: 1.5 }; // half-width, half-height const STEP_HEIGHT = 0.6; export class CharacterController { constructor(world, config) { this.world = world; this.config = config; this.pos = new THREE.Vector3(0, 6, 0); this.vel = new THREE.Vector3(); this.grounded = false; this.groundPart = null; this.groundPrev = new THREE.Vector3(); this.inWater = false; this.onSpeedPad = false; this.touching = []; // special parts overlapping this frame } teleport(v) { this.pos.copy(v); this.vel.set(0, 0, 0); this.groundPart = null; } // collision candidates near the player _solids() { const out = []; const c = new THREE.Vector3(); for (const part of this.world.parts.values()) { if (!SOLID_TYPES.has(part.type)) continue; this.world.partCenter(part, c); if (Math.abs(c.x - this.pos.x) > part.sx / 2 + 8) continue; if (Math.abs(c.y - this.pos.y) > part.sy / 2 + 8) continue; if (Math.abs(c.z - this.pos.z) > part.sz / 2 + 8) continue; out.push({ part, minX: c.x - part.sx / 2, maxX: c.x + part.sx / 2, minY: c.y - part.sy / 2, maxY: c.y + part.sy / 2, minZ: c.z - part.sz / 2, maxZ: c.z + part.sz / 2, }); } return out; } _overlaps(b, px, py, pz, pad = 0) { return px + PLAYER.hw + pad > b.minX && px - PLAYER.hw - pad < b.maxX && py + PLAYER.hh + pad > b.minY && py - PLAYER.hh - pad < b.maxY && pz + PLAYER.hw + pad > b.minZ && pz - PLAYER.hw - pad < b.maxZ; } /** * @param dt seconds * @param input { moveX, moveZ (camera-space -1..1), jump, yaw (camera yaw) } */ step(dt, input) { dt = Math.min(dt, 0.05); const cfg = this.config; const speedMul = this.onSpeedPad ? 1.8 : 1; const walk = cfg.walkSpeed * speedMul; // camera-relative move direction const sin = Math.sin(input.yaw), cos = Math.cos(input.yaw); let dx = input.moveX * cos - input.moveZ * sin; let dz = -input.moveX * sin - input.moveZ * cos; const len = Math.hypot(dx, dz); if (len > 1) { dx /= len; dz /= len; } // mover carry: ride platforms if (this.groundPart && this.groundPart.type === 'mover') { const c = this.world.partCenter(this.groundPart); const delta = c.clone().sub(this.groundPrev); this.pos.add(delta); this.groundPrev.copy(c); } if (this.inWater) { this.vel.y += -cfg.gravity * 0.12 * dt; this.vel.y *= 0.92; if (input.jump) this.vel.y = Math.min(this.vel.y + cfg.gravity * 0.5 * dt + 1.2, 8); this.vel.x = dx * walk * 0.65; this.vel.z = dz * walk * 0.65; } else { this.vel.y -= cfg.gravity * dt; this.vel.x = dx * walk; this.vel.z = dz * walk; if (input.jump && this.grounded) { this.vel.y = cfg.jumpPower; this.grounded = false; if (this.onJump) this.onJump(); } } if (this.vel.y < -80) this.vel.y = -80; const solids = this._solids(); this.grounded = false; const prevGround = this.groundPart; this.groundPart = null; // --- Y axis --- this.pos.y += this.vel.y * dt; for (const b of solids) { if (!this._overlaps(b, this.pos.x, this.pos.y, this.pos.z)) continue; if (this.vel.y <= 0 && this.pos.y > (b.minY + b.maxY) / 2) { this.pos.y = b.maxY + PLAYER.hh; this.vel.y = 0; this.grounded = true; this.groundPart = b.part; } else if (this.vel.y > 0) { this.pos.y = b.minY - PLAYER.hh; this.vel.y = 0; } } // --- X then Z with auto-step --- this._moveAxis(solids, 'x', this.vel.x * dt); this._moveAxis(solids, 'z', this.vel.z * dt); if (this.groundPart && this.groundPart !== prevGround && this.groundPart.type === 'mover') { this.world.partCenter(this.groundPart, this.groundPrev); } else if (this.groundPart && this.groundPart.type === 'mover' && this.groundPart === prevGround) { // groundPrev already tracked } // --- special-part touch detection --- this.touching.length = 0; this.inWater = false; this.onSpeedPad = false; const c = new THREE.Vector3(); for (const part of this.world.parts.values()) { if (SOLID_TYPES.has(part.type) && part.type !== 'kill' && part.type !== 'bounce' && part.type !== 'speed' && part.type !== 'tele' && part.type !== 'checkpoint' && part.type !== 'goal') continue; this.world.partCenter(part, c); const pad = part.type === 'coin' ? 0.6 : 0.15; const b = { minX: c.x - part.sx / 2, maxX: c.x + part.sx / 2, minY: c.y - part.sy / 2, maxY: c.y + part.sy / 2, minZ: c.z - part.sz / 2, maxZ: c.z + part.sz / 2, }; if (!this._overlaps(b, this.pos.x, this.pos.y, this.pos.z, pad)) continue; if (part.type === 'water') { this.inWater = true; continue; } if (part.type === 'speed') { this.onSpeedPad = true; continue; } if (part.type === 'deco' || part.type === 'block' || part.type === 'spawn' || part.type === 'mover') continue; this.touching.push(part); } // standing directly on pads also counts if (this.groundPart && ['bounce', 'speed', 'tele', 'checkpoint', 'goal', 'kill'].includes(this.groundPart.type)) { if (this.groundPart.type === 'speed') this.onSpeedPad = true; else if (!this.touching.includes(this.groundPart)) this.touching.push(this.groundPart); } } _moveAxis(solids, axis, delta) { if (delta === 0) return; this.pos[axis] += delta; for (const b of solids) { if (!this._overlaps(b, this.pos.x, this.pos.y, this.pos.z)) continue; // try auto-step: small ledges walk up const stepY = b.maxY + PLAYER.hh; if (this.grounded && stepY - this.pos.y <= STEP_HEIGHT && stepY - this.pos.y > 0) { let clear = true; for (const o of solids) { if (o === b) continue; if (this._overlaps(o, this.pos.x, stepY, this.pos.z)) { clear = false; break; } } if (clear) { this.pos.y = stepY + 0.001; continue; } } const hw = PLAYER.hw; if (axis === 'x') { this.pos.x = delta > 0 ? b.minX - hw : b.maxX + hw; } else { this.pos.z = delta > 0 ? b.minZ - hw : b.maxZ + hw; } } } }