Spaces:
Running
Running
| // 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; | |
| } | |
| } | |
| } | |
| } | |