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);
+}