Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ <h1>🌙 Lunar Pup Skater</h1>
<span class="key">◄</span> <span class="key">►</span> / <span class="key">A</span> <span class="key">D</span> Steer<br>
<span class="key">Spacebar</span> Low-Gravity Ollie (Jump)<br>
<span class="key">Shift</span> Boost<br>
<span class="key">Q</span> / <span class="key">E</span> Air Spin<br>
<span class="key">F</span> Moon Grab<br>
<span class="key">Mouse drag</span> Orbit Camera<br>
<span class="key">Wheel</span> Zoom In / Out
</div>
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/game/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export async function bootstrap() {
{ setupCameraControls, startGameLoop },
{ setupTuningPanel },
{ setupSpeedLines },
{ setupTrickUI },
{ bindInput },
{ setupMultiplayerUI },
] = await Promise.all([
Expand 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'),
]);
Expand All @@ -40,6 +42,7 @@ export async function bootstrap() {

setupCameraControls();
setSpeedLines(setupSpeedLines());
setupTrickUI();
setupTuningPanel();
setupMultiplayerUI();

Expand Down
5 changes: 4 additions & 1 deletion src/game/input.ts
Original file line number Diff line number Diff line change
@@ -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();
}

Expand All @@ -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;
Expand Down
59 changes: 36 additions & 23 deletions src/game/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
14 changes: 9 additions & 5 deletions src/game/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
scene,
dog,
setPlayerGroup,
setTrickRoot,
setSkateboard,
setDog,
setTail,
Expand All @@ -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();
Expand All @@ -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],
];
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/game/remotePlayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/game/terrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
60 changes: 60 additions & 0 deletions src/game/trickScoring.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading