From 09cd524e885a2bf54721da3a43aa3cd1559e6f69 Mon Sep 17 00:00:00 2001 From: dillonlille <260170482+dillonlille@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:46:34 -0700 Subject: [PATCH] Add simple aerial trick system --- README.md | 31 +++++++++++++++-- index.html | 2 ++ package.json | 2 ++ src/game/bootstrap.ts | 3 ++ src/game/input.ts | 5 ++- src/game/loop.ts | 59 +++++++++++++++++++------------- src/game/player.ts | 14 +++++--- src/game/remotePlayers.ts | 2 +- src/game/terrain.ts | 5 +-- src/game/trickScoring.test.ts | 60 +++++++++++++++++++++++++++++++++ src/game/trickScoring.ts | 62 ++++++++++++++++++++++++++++++++++ src/game/tricks.ts | 63 +++++++++++++++++++++++++++++++++++ src/server.ts | 25 ++++++++------ src/state.ts | 14 +++++++- src/styles.css | 54 ++++++++++++++++++++++++++++++ src/ui/tricks.ts | 51 ++++++++++++++++++++++++++++ 16 files changed, 405 insertions(+), 47 deletions(-) create mode 100644 src/game/trickScoring.test.ts create mode 100644 src/game/trickScoring.ts create mode 100644 src/game/tricks.ts create mode 100644 src/ui/tricks.ts diff --git a/README.md b/README.md index 4b70b50..7ae94df 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,19 @@ A Three.js moon-skating game with procedural chunked terrain (PCG LOD). +Skate across the lunar surface, boost over procedural ridges, and land aerial +spins and Moon Grabs for points. Optional WebSocket multiplayer synchronizes +players in shared rooms. + +## Controls + +- `WASD` / arrow keys — accelerate, brake, and steer +- `Space` — low-gravity ollie +- `Shift` — boost while accelerating +- `Q` / `E` — spin left or right in the air +- `F` — hold a Moon Grab in the air +- Mouse drag / wheel — orbit and zoom the camera + ## Development ```bash @@ -11,6 +24,16 @@ bun run dev Opens a hot-reloading dev server from `index.html`. +Run the automated checks: + +```bash +bun run test +bun run typecheck +``` + +Enable multiplayer with URL parameters such as +`?multiplayer&room=lunar-park&name=Pup123`. + ## Production build ```bash @@ -21,7 +44,7 @@ Outputs minified, code-split bundles to `dist/`: - `index.html` — entry page - `index-*.js` — entry + shared chunks (including Three.js ~525 KB) -- `bootstrap-*.js`, `scene-*.js`, `terrain-*.js`, `player-*.js`, `loop-*.js`, `tuning-*.js`, `speedLines-*.js`, `input-*.js` — lazy-loaded game modules +- Additional `*.js` chunks — lazy-loaded game, networking, trick, and UI modules - `index-*.css` — bundled styles Preview the production build: @@ -38,8 +61,10 @@ src/ styles.css # UI styles config.ts # Game constants state.ts # Shared runtime state - game/ # Scene, terrain, player, loop, input - ui/ # Tuning panel and speed lines + game/ # Scene, terrain, player, loop, input, tricks + net/ # WebSocket client and shared protocol + ui/ # Tuning, speed lines, multiplayer, and trick HUD + server.ts # Bun multiplayer WebSocket server index.html # HTML shell dist/ # Build output (generated) legacy/ # Original saved HTML snapshot diff --git a/index.html b/index.html index f32e5f4..cbc011e 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,8 @@

🌙 Lunar Pup Skater

/ A D Steer
Spacebar Low-Gravity Ollie (Jump)
Shift Boost
+ Q / E Air Spin
+ F Moon Grab
Mouse drag Orbit Camera
Wheel Zoom In / Out diff --git a/package.json b/package.json index f936f61..85c848d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "dev:game": "bun --hot ./index.html", "dev:server": "bun --hot src/server.ts", "build": "bun build ./index.html --outdir=dist --minify --splitting --target=browser", + "test": "bun test", + "typecheck": "bunx tsc --noEmit", "preview": "bunx serve dist" }, "dependencies": { diff --git a/src/game/bootstrap.ts b/src/game/bootstrap.ts index 41372d2..64d8edc 100644 --- a/src/game/bootstrap.ts +++ b/src/game/bootstrap.ts @@ -15,6 +15,7 @@ export async function bootstrap() { { setupCameraControls, startGameLoop }, { setupTuningPanel }, { setupSpeedLines }, + { setupTrickUI }, { bindInput }, { setupMultiplayerUI }, ] = await Promise.all([ @@ -24,6 +25,7 @@ export async function bootstrap() { import('./loop.ts'), import('../ui/tuning.ts'), import('../ui/speedLines.ts'), + import('../ui/tricks.ts'), import('./input.ts'), import('../ui/multiplayer.ts'), ]); @@ -40,6 +42,7 @@ export async function bootstrap() { setupCameraControls(); setSpeedLines(setupSpeedLines()); + setupTrickUI(); setupTuningPanel(); setupMultiplayerUI(); diff --git a/src/game/input.ts b/src/game/input.ts index 226a646..4eef52e 100644 --- a/src/game/input.ts +++ b/src/game/input.ts @@ -1,7 +1,7 @@ import { keys } from '../state.ts'; export function handleKeys(event: KeyboardEvent, isPressed: boolean) { - if (['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space', 'ShiftLeft', 'ShiftRight'].includes(event.code)) { + if (['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE', 'KeyF', 'Space', 'ShiftLeft', 'ShiftRight'].includes(event.code)) { event.preventDefault(); } @@ -14,6 +14,9 @@ export function handleKeys(event: KeyboardEvent, isPressed: boolean) { case 'KeyS': keys.s = isPressed; break; case 'ArrowRight': case 'KeyD': keys.d = isPressed; break; + case 'KeyQ': keys.q = isPressed; break; + case 'KeyE': keys.e = isPressed; break; + case 'KeyF': keys.f = isPressed; break; case 'Space': keys.space = isPressed; break; case 'ShiftLeft': case 'ShiftRight': keys.shift = isPressed; break; diff --git a/src/game/loop.ts b/src/game/loop.ts index befd2c0..00c4054 100644 --- a/src/game/loop.ts +++ b/src/game/loop.ts @@ -22,6 +22,7 @@ import { import { updateSpeedLines } from '../ui/speedLines.ts'; import { updateRemotePlayers } from './remotePlayers.ts'; import { buildLocalSnapshot } from './multiplayer.ts'; +import { finishTrick, startTrick, updateTrick } from './tricks.ts'; export function setupCameraControls() { const canvas = renderer.domElement; @@ -78,9 +79,11 @@ function lerpAngle(a: number, b: number, t: number) { return a + delta * t; } -function updateCamera() { +function updateCamera(dt: number) { + const frameScale = dt * 60; if (!cameraControl.isDragging && Math.abs(physics.speed) > 0.03) { - cameraControl.yaw = lerpAngle(cameraControl.yaw, physics.heading + Math.PI, cameraControl.autoFollowStrength); + const followStrength = 1 - Math.pow(1 - cameraControl.autoFollowStrength, frameScale); + cameraControl.yaw = lerpAngle(cameraControl.yaw, physics.heading + Math.PI, followStrength); } const horizontalDistance = Math.cos(cameraControl.pitch) * cameraControl.distance; @@ -94,7 +97,8 @@ function updateCamera() { scratch.targetCamPos.copy(playerGroup.position).add(scratch.camOffset); - const cameraLerp = keys.shift && keys.w ? 0.16 : 0.10; + const baseCameraLerp = keys.shift && keys.w ? 0.16 : 0.10; + const cameraLerp = 1 - Math.pow(1 - baseCameraLerp, frameScale); camera.position.lerp(scratch.targetCamPos, cameraLerp); scratch.lookTarget.copy(playerGroup.position); @@ -103,62 +107,70 @@ function updateCamera() { const speedRatio = THREE.MathUtils.clamp(Math.abs(physics.speed) / (physics.maxSpeed * physics.boostMultiplier), 0, 1); const targetFov = THREE.MathUtils.lerp(physics.cameraBaseFov, physics.cameraMaxFov, Math.pow(speedRatio, 1.35)); - camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, cameraControl.fovSmoothing); + const fovSmoothing = 1 - Math.pow(1 - cameraControl.fovSmoothing, frameScale); + camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, fovSmoothing); camera.updateProjectionMatrix(); } -function tiltBoardToTerrain() { +function tiltBoardToTerrain(frameScale: number) { if (!physics.isGrounded) return; const speedLean = THREE.MathUtils.clamp(physics.speed / (physics.maxSpeed * physics.boostMultiplier), -1, 1); const turnLean = (keys.a ? 1 : 0) + (keys.d ? -1 : 0); - skateboard.rotation.x = THREE.MathUtils.lerp(skateboard.rotation.x, -speedLean * 0.05, physics.tiltSmoothing); - skateboard.rotation.z = THREE.MathUtils.lerp(skateboard.rotation.z, turnLean * 0.12, physics.tiltSmoothing); + const tiltSmoothing = 1 - Math.pow(1 - physics.tiltSmoothing, frameScale); + skateboard.rotation.x = THREE.MathUtils.lerp(skateboard.rotation.x, -speedLean * 0.05, tiltSmoothing); + skateboard.rotation.z = THREE.MathUtils.lerp(skateboard.rotation.z, turnLean * 0.12, tiltSmoothing); } -function handlePhysics() { - if (keys.a) physics.heading += physics.rotationSpeed; - if (keys.d) physics.heading -= physics.rotationSpeed; +function handlePhysics(dt: number) { + const frameScale = dt * 60; + if (keys.a) physics.heading += physics.rotationSpeed * frameScale; + if (keys.d) physics.heading -= physics.rotationSpeed * frameScale; const isBoosting = keys.shift && keys.w; const currentMaxSpeed = physics.maxSpeed * (isBoosting ? physics.boostMultiplier : 1); const currentAccel = physics.accel * (isBoosting ? physics.boostAccelMultiplier : 1); if (keys.w) { - physics.speed += currentAccel; + physics.speed += currentAccel * frameScale; if (physics.speed > currentMaxSpeed) physics.speed = currentMaxSpeed; } else if (keys.s) { - physics.speed -= physics.accel; + physics.speed -= physics.accel * frameScale; if (physics.speed < -physics.maxSpeed / 2) physics.speed = -physics.maxSpeed / 2; } else { - if (physics.speed > 0) physics.speed = Math.max(0, physics.speed - physics.decel); - if (physics.speed < 0) physics.speed = Math.min(0, physics.speed + physics.decel); + if (physics.speed > 0) physics.speed = Math.max(0, physics.speed - physics.decel * frameScale); + if (physics.speed < 0) physics.speed = Math.min(0, physics.speed + physics.decel * frameScale); } const targetY = getTerrainHeight(playerGroup.position.x, playerGroup.position.z) + groundClearance; if (physics.isGrounded) { const heightDelta = targetY - playerGroup.position.y; - playerGroup.position.y += heightDelta * physics.suspension; + const suspension = 1 - Math.pow(1 - physics.suspension, frameScale); + playerGroup.position.y += heightDelta * suspension; if (Math.abs(heightDelta) > 18) playerGroup.position.y = targetY; physics.velocity.y = 0; if (keys.space) { physics.velocity.y = physics.jumpForce; physics.isGrounded = false; + physics.airTime = 0; + startTrick(); } } else { - physics.velocity.y -= physics.gravity; - playerGroup.position.y += physics.velocity.y; + physics.airTime += dt; + physics.velocity.y -= physics.gravity * frameScale; + playerGroup.position.y += physics.velocity.y * frameScale; if (playerGroup.position.y <= targetY) { playerGroup.position.y = targetY; physics.isGrounded = true; + finishTrick(); } } scratch.forwardVector.set(Math.sin(physics.heading), 0, Math.cos(physics.heading)); - playerGroup.position.addScaledVector(scratch.forwardVector, physics.speed); + playerGroup.position.addScaledVector(scratch.forwardVector, physics.speed * frameScale); updateTerrainChunks(); - alignPlayerToTerrain(); - tiltBoardToTerrain(); + alignPlayerToTerrain(frameScale); + tiltBoardToTerrain(frameScale); const speedRatio = THREE.MathUtils.clamp(Math.abs(physics.speed) / (physics.maxSpeed * physics.boostMultiplier), 0, 1); updateSpeedLines(speedRatio, isBoosting); @@ -177,8 +189,9 @@ export function startGameLoop() { const dt = Math.min((now - lastFrame) / 1000, 0.05); lastFrame = now; - handlePhysics(); - updateCamera(); + updateTrick(dt); + handlePhysics(dt); + updateCamera(dt); updateRemotePlayers(dt); if (multiplayerClient?.isConnected) { @@ -198,7 +211,7 @@ export function startGameLoop() { tail.rotation.z = Math.sin(time) * 0.4; } for (let i = 1; i < skateboard.children.length; i++) { - skateboard.children[i]!.rotation.x += physics.speed * 2; + skateboard.children[i]!.rotation.x += physics.speed * 2 * dt * 60; } } renderer.render(scene, camera); diff --git a/src/game/player.ts b/src/game/player.ts index cad6f84..2e8311a 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -3,6 +3,7 @@ import { scene, dog, setPlayerGroup, + setTrickRoot, setSkateboard, setDog, setTail, @@ -16,7 +17,7 @@ export interface VoxelDogParts { tail: THREE.Mesh; } -export function createVoxelDog(dogColor = PLAYER_COLORS[0], deckColor = 0xff5555): VoxelDogParts { +export function createVoxelDog(dogColor: number = PLAYER_COLORS[0], deckColor = 0xff5555): VoxelDogParts { const group = new THREE.Group(); const skateboard = new THREE.Group(); @@ -30,7 +31,7 @@ export function createVoxelDog(dogColor = PLAYER_COLORS[0], deckColor = 0xff5555 const wheelGeom = new THREE.CylinderGeometry(0.25, 0.25, 0.3, 8); wheelGeom.rotateZ(Math.PI / 2); const wheelMat = new THREE.MeshStandardMaterial({ color: 0xeeeeee, roughness: 0.2 }); - const wheelPositions = [ + const wheelPositions: Array<[number, number, number]> = [ [-0.7, 0.2, 1.2], [0.7, 0.2, 1.2], [-0.7, 0.2, -1.2], [0.7, 0.2, -1.2], ]; @@ -120,10 +121,13 @@ export function tintLocalDog(color: number) { } export function createPlayer() { - const { group, skateboard, dog: dogGroup, tail: tailMesh } = createVoxelDog(); - scene.add(group); + const playerGroup = new THREE.Group(); + const { group: trickRoot, skateboard, dog: dogGroup, tail: tailMesh } = createVoxelDog(); + playerGroup.add(trickRoot); + scene.add(playerGroup); - setPlayerGroup(group); + setPlayerGroup(playerGroup); + setTrickRoot(trickRoot); setSkateboard(skateboard); setDog(dogGroup); setTail(tailMesh); diff --git a/src/game/remotePlayers.ts b/src/game/remotePlayers.ts index c5d1933..da96b23 100644 --- a/src/game/remotePlayers.ts +++ b/src/game/remotePlayers.ts @@ -90,7 +90,7 @@ export function updateRemotePlayers(dt: number) { const time = Date.now() * 0.015; parts.tail.rotation.z = Math.sin(time + current.x) * 0.4; for (let i = 1; i < parts.skateboard.children.length; i++) { - parts.skateboard.children[i]!.rotation.x += current.speed * 2; + parts.skateboard.children[i]!.rotation.x += current.speed * 2 * dt * 60; } } } diff --git a/src/game/terrain.ts b/src/game/terrain.ts index 8a84073..9b9543b 100644 --- a/src/game/terrain.ts +++ b/src/game/terrain.ts @@ -216,7 +216,7 @@ export function getTerrainNormal(x: number, z: number) { return scratch.terrainNormal; } -export function alignPlayerToTerrain() { +export function alignPlayerToTerrain(frameScale = 1) { const normal = getTerrainNormal(playerGroup.position.x, playerGroup.position.z); scratch.baseForward.set(Math.sin(physics.heading), 0, Math.cos(physics.heading)); scratch.slopeForward.copy(scratch.baseForward).addScaledVector(normal, -scratch.baseForward.dot(normal)).normalize(); @@ -225,5 +225,6 @@ export function alignPlayerToTerrain() { scratch.playerMatrix.makeBasis(scratch.slopeRight, normal, scratch.slopeForward); scratch.targetPlayerQuat.setFromRotationMatrix(scratch.playerMatrix); - playerGroup.quaternion.slerp(scratch.targetPlayerQuat, physics.tiltSmoothing); + const tiltSmoothing = 1 - Math.pow(1 - physics.tiltSmoothing, frameScale); + playerGroup.quaternion.slerp(scratch.targetPlayerQuat, tiltSmoothing); } diff --git a/src/game/trickScoring.test.ts b/src/game/trickScoring.test.ts new file mode 100644 index 0000000..609db8e --- /dev/null +++ b/src/game/trickScoring.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import { FULL_TURN, MIN_GRAB_TIME, scoreTrick } from './trickScoring.ts'; + +describe('scoreTrick', () => { + test('scores a clean 360', () => { + expect(scoreTrick(FULL_TURN, 0)).toMatchObject({ + status: 'scored', + name: '360°', + points: 300, + spinCount: 1, + }); + }); + + test('adds points for additional complete rotations', () => { + expect(scoreTrick(FULL_TURN * 2, 0)).toMatchObject({ + status: 'scored', + name: '720°', + points: 700, + spinCount: 2, + }); + }); + + test('scores spins in either direction', () => { + expect(scoreTrick(-FULL_TURN, 0)).toMatchObject({ + status: 'scored', + name: '360°', + points: 300, + }); + }); + + test('scores a Moon Grab without a spin', () => { + expect(scoreTrick(0, MIN_GRAB_TIME)).toMatchObject({ + status: 'scored', + name: 'Moon Grab', + points: 150, + }); + }); + + test('applies the combination multiplier', () => { + expect(scoreTrick(FULL_TURN, MIN_GRAB_TIME)).toMatchObject({ + status: 'scored', + name: '360° Moon Grab', + points: 675, + }); + }); + + test('rejects an incomplete spin', () => { + expect(scoreTrick(FULL_TURN * 0.75, MIN_GRAB_TIME)).toMatchObject({ + status: 'sketchy', + points: 0, + }); + }); + + test('ignores an ordinary jump', () => { + expect(scoreTrick(0, 0)).toMatchObject({ + status: 'none', + points: 0, + }); + }); +}); diff --git a/src/game/trickScoring.ts b/src/game/trickScoring.ts new file mode 100644 index 0000000..23bc7d0 --- /dev/null +++ b/src/game/trickScoring.ts @@ -0,0 +1,62 @@ +export const FULL_TURN = Math.PI * 2; +export const CLEAN_LANDING_TOLERANCE = Math.PI / 6; +export const MIN_GRAB_TIME = 0.35; +export const MIN_SPIN_ATTEMPT = Math.PI / 4; + +export interface TrickScore { + status: 'scored' | 'sketchy' | 'none'; + name: string; + points: number; + spinDegrees: number; + spinCount: number; + hasGrab: boolean; +} + +export function scoreTrick(rotation: number, grabTime: number): TrickScore { + const absoluteRotation = Math.abs(rotation); + const spinDegrees = Math.round(absoluteRotation * 180 / Math.PI); + const spinCount = Math.round(absoluteRotation / FULL_TURN); + const attemptedSpin = absoluteRotation >= MIN_SPIN_ATTEMPT; + const cleanSpin = spinCount >= 1 + && Math.abs(absoluteRotation - spinCount * FULL_TURN) <= CLEAN_LANDING_TOLERANCE; + const hasGrab = grabTime >= MIN_GRAB_TIME; + + if (attemptedSpin && !cleanSpin) { + return { + status: 'sketchy', + name: 'Sketchy landing', + points: 0, + spinDegrees, + spinCount: 0, + hasGrab, + }; + } + + if (!cleanSpin && !hasGrab) { + return { + status: 'none', + name: '', + points: 0, + spinDegrees, + spinCount: 0, + hasGrab: false, + }; + } + + const spinPoints = cleanSpin ? 300 + (spinCount - 1) * 400 : 0; + const grabPoints = hasGrab ? 150 : 0; + const combinationMultiplier = cleanSpin && hasGrab ? 1.5 : 1; + const points = Math.round((spinPoints + grabPoints) * combinationMultiplier); + const name = [cleanSpin ? `${spinCount * 360}°` : '', hasGrab ? 'Moon Grab' : ''] + .filter(Boolean) + .join(' '); + + return { + status: 'scored', + name, + points, + spinDegrees, + spinCount: cleanSpin ? spinCount : 0, + hasGrab, + }; +} diff --git a/src/game/tricks.ts b/src/game/tricks.ts new file mode 100644 index 0000000..9960819 --- /dev/null +++ b/src/game/tricks.ts @@ -0,0 +1,63 @@ +import { dog, keys, trickRoot } from '../state.ts'; +import { showTrickResult, updateCurrentTrick, updateTrickScore } from '../ui/tricks.ts'; +import { scoreTrick } from './trickScoring.ts'; + +const SPIN_SPEED = Math.PI * 2 * 1.2; +const DOG_REST_Y = 0.15; +const DOG_GRAB_Y = -0.08; + +const trickState = { + active: false, + rotation: 0, + grabTime: 0, + grabbing: false, + totalScore: 0, +}; + +export function startTrick() { + trickState.active = true; + trickState.rotation = 0; + trickState.grabTime = 0; + trickState.grabbing = false; +} + +export function updateTrick(dt: number) { + if (trickState.active) { + const spinDirection = Number(keys.q) - Number(keys.e); + trickState.rotation += spinDirection * SPIN_SPEED * dt; + trickState.grabbing = keys.f; + if (trickState.grabbing) trickState.grabTime += dt; + + trickRoot.rotation.y = trickState.rotation; + updateCurrentTrick(trickState.rotation, trickState.grabbing); + } else { + const settle = 1 - Math.exp(-16 * dt); + trickRoot.rotation.y += (0 - trickRoot.rotation.y) * settle; + updateCurrentTrick(0, false); + } + + const crouch = 1 - Math.exp(-14 * dt); + const targetDogY = trickState.active && trickState.grabbing ? DOG_GRAB_Y : DOG_REST_Y; + const targetDogTilt = trickState.active && trickState.grabbing ? -0.22 : 0; + dog.position.y += (targetDogY - dog.position.y) * crouch; + dog.rotation.x += (targetDogTilt - dog.rotation.x) * crouch; +} + +export function finishTrick() { + if (!trickState.active) return; + + const result = scoreTrick(trickState.rotation, trickState.grabTime); + if (result.status === 'scored') { + trickState.totalScore += result.points; + updateTrickScore(trickState.totalScore); + } + showTrickResult(result); + + trickState.active = false; + trickState.grabbing = false; + trickRoot.rotation.y = normalizeAngle(trickRoot.rotation.y); +} + +function normalizeAngle(angle: number) { + return Math.atan2(Math.sin(angle), Math.cos(angle)); +} diff --git a/src/server.ts b/src/server.ts index 538dde9..fde0b2b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,7 +14,7 @@ interface PlayerConnection { name: string; color: number; room: string; - ws: ServerWebSocket; + ws?: ServerWebSocket; state: Omit; } @@ -54,13 +54,23 @@ function defaultState(): Omit { }; } +function pendingConnection(): PlayerConnection { + return { + id: '', + name: '', + color: PLAYER_COLORS[0], + room: '', + state: defaultState(), + }; +} + function send(ws: ServerWebSocket, msg: ServerMessage) { ws.send(JSON.stringify(msg)); } function broadcast(room: Room, msg: ServerMessage, exceptId?: string) { for (const [id, conn] of room.players) { - if (id !== exceptId) send(conn.ws, msg); + if (id !== exceptId && conn.ws) send(conn.ws, msg); } } @@ -119,21 +129,14 @@ const port = Number(process.env.PORT) || DEFAULT_WS_PORT; const server = Bun.serve({ port, fetch(req, server) { - if (server.upgrade(req)) return undefined; + if (server.upgrade(req, { data: pendingConnection() })) return undefined; return new Response('Lunar Pup multiplayer WebSocket server', { headers: { 'Content-Type': 'text/plain' }, }); }, websocket: { open(ws) { - ws.data = { - id: '', - name: '', - color: PLAYER_COLORS[0], - room: '', - ws, - state: defaultState(), - }; + ws.data.ws = ws; }, message(ws, message) { const parsed = parseClientMessage(message); diff --git a/src/state.ts b/src/state.ts index 1b34e0e..c5e4e64 100644 --- a/src/state.ts +++ b/src/state.ts @@ -2,7 +2,17 @@ import * as THREE from 'three'; import type { Mesh, Group, PerspectiveCamera, Scene, WebGLRenderer } from 'three'; import type { MultiplayerClient } from './net/client.ts'; -export const keys = { w: false, a: false, s: false, d: false, space: false, shift: false }; +export const keys = { + w: false, + a: false, + s: false, + d: false, + q: false, + e: false, + f: false, + space: false, + shift: false, +}; export const physics = { speed: 0, @@ -43,6 +53,7 @@ export let scene: Scene; export let camera: PerspectiveCamera; export let renderer: WebGLRenderer; export let playerGroup: Group; +export let trickRoot: Group; export let skateboard: Group; export let dog: Group; export let tail: Mesh; @@ -76,6 +87,7 @@ export function setScene(s: Scene) { scene = s; } export function setCamera(c: PerspectiveCamera) { camera = c; } export function setRenderer(r: WebGLRenderer) { renderer = r; } export function setPlayerGroup(g: Group) { playerGroup = g; } +export function setTrickRoot(g: Group) { trickRoot = g; } export function setSkateboard(g: Group) { skateboard = g; } export function setDog(g: Group) { dog = g; } export function setTail(m: Mesh) { tail = m; } diff --git a/src/styles.css b/src/styles.css index 23c3edf..9d62817 100644 --- a/src/styles.css +++ b/src/styles.css @@ -51,6 +51,60 @@ h1 { border-radius: 5px; border: 1px solid rgba(255,255,255,0.1); } +#trick-hud { + position: absolute; + left: 50%; + bottom: 24px; + min-width: 280px; + color: #fff; + text-align: center; + font-family: monospace; + pointer-events: none; + transform: translateX(-50%); + text-shadow: 0 3px 10px rgba(0,0,0,0.9); +} +#trick-score { + color: #a0c4ff; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.08em; +} +#trick-current { + min-height: 24px; + margin-top: 6px; + color: #ffc6ff; + font-size: 20px; + font-weight: 700; + opacity: 0; + transform: translateY(5px); + transition: opacity 100ms ease, transform 100ms ease; +} +#trick-current.visible { + opacity: 1; + transform: translateY(0); +} +#trick-result { + position: absolute; + left: 50%; + bottom: 64px; + width: 420px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-size: 30px; + font-weight: 800; + opacity: 0; + transform: translate(-50%, 16px) scale(0.9); +} +#trick-result.landed { color: #80ff72; } +#trick-result.sketchy { color: #ff6b6b; } +#trick-result.visible { + animation: trick-result-pop 1.4s ease-out forwards; +} +@keyframes trick-result-pop { + 0% { opacity: 0; transform: translate(-50%, 16px) scale(0.9); } + 16% { opacity: 1; transform: translate(-50%, 0) scale(1.08); } + 72% { opacity: 1; transform: translate(-50%, 0) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -12px) scale(1); } +} #tuning-panel { position: absolute; top: 20px; diff --git a/src/ui/tricks.ts b/src/ui/tricks.ts new file mode 100644 index 0000000..49e700c --- /dev/null +++ b/src/ui/tricks.ts @@ -0,0 +1,51 @@ +import type { TrickScore } from '../game/trickScoring.ts'; + +let scoreEl: HTMLDivElement | null = null; +let currentEl: HTMLDivElement | null = null; +let resultEl: HTMLDivElement | null = null; +let resultTimer: ReturnType | null = null; + +export function setupTrickUI() { + const panel = document.createElement('div'); + panel.id = 'trick-hud'; + panel.innerHTML = ` +
SCORE 0
+
+
+ `; + document.body.appendChild(panel); + + scoreEl = panel.querySelector('#trick-score'); + currentEl = panel.querySelector('#trick-current'); + resultEl = panel.querySelector('#trick-result'); +} + +export function updateTrickScore(totalScore: number) { + if (scoreEl) scoreEl.textContent = `SCORE ${totalScore.toLocaleString()}`; +} + +export function updateCurrentTrick(rotation: number, grabbing: boolean) { + if (!currentEl) return; + + const degrees = Math.round(Math.abs(rotation) * 180 / Math.PI); + const labels = [degrees >= 10 ? `SPIN ${degrees}°` : '', grabbing ? 'MOON GRAB' : ''] + .filter(Boolean); + currentEl.textContent = labels.join(' · '); + currentEl.classList.toggle('visible', labels.length > 0); +} + +export function showTrickResult(result: TrickScore) { + if (!resultEl || result.status === 'none') return; + + if (resultTimer) clearTimeout(resultTimer); + resultEl.textContent = result.status === 'scored' + ? `${result.name} +${result.points}` + : result.name; + resultEl.className = result.status === 'scored' ? 'landed' : 'sketchy'; + void resultEl.offsetWidth; + resultEl.classList.add('visible'); + + resultTimer = setTimeout(() => { + resultEl?.classList.remove('visible'); + }, 1400); +}