Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/smokes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
- group: ride-2
smokes: achievements quality layout geese physics no-media controller-overlay clip-share
- group: modes
smokes: slingshot slingshot-aim tourist host-room grandma props
smokes: slingshot slingshot-aim tourist host-room grandma props time-warp
- group: controllers
smokes: disconnect local-coop versus wardrobe
- group: controllers-2
Expand Down
2 changes: 2 additions & 0 deletions docs/test-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ CI: `pr-preview.yml` runs PF-01, PF-06 (UI) and the lobby smokes on every PR.
| SR-27 | Every end screen (game over, victory, tutorial complete, demo end, partner left, versus results) is the result-screen shell: a headline kit sheet, one action row in the shared order, the primary first and focused (#459) | A | `smoke:flows`, `smoke:versus`, `smoke:solo-extras`, `smoke:tutorial`, `smoke:layout` (fits at phone, desktop, TV; screenshots) |
| SR-28 | First-time SOLO shows the tutorial intro before the countdown: START begins the tutorial (SKIP TUTORIAL on screen, Y / △ on a pad), SKIP / B / Esc skip to the level list (#459) | A | `smoke:tutorial`, `smoke:solo-extras` |
| SR-29 | Pause menu (#459): Start / Esc / the quick menu's PAUSE mid-ride opens it and the ride (physics + race clock) stops; B / Start / Esc / RESUME continue; SETTINGS opens Options over it and B returns to the menu; View opens Help over it and B returns; RESTART rides again; END RIDE returns to the lobby; in versus P1's Start freezes both teams | A | `smoke:flows`, `smoke:versus`, `test/unit/race-manager` |
| SR-30 | Time Warp bike (#611): the chooser's last entry; locked it is a dark silhouette with "n of N" over it (N = non-calendar achievements less 5) and the ride stays on the default bike; unlocked it shows in colour with its clock and flames running and is the pick; a solo ride loads `time_warp_tandem.glb` with both geese seated, the TimeWarpFX loop playing and the tandem's size; Show Riders off rides the plain frame; a paint preset rides the goose tandem again | A | `smoke:time-warp`, `test/unit/special-bikes` |
| SR-30h | SR-30 looks right: the silhouette reads as "something to earn", the flames are blue with orange pops now and then, the clock spins fast, the geese sit as on the tandem | M | — |

## 3. Ride feel (manual only)

Expand Down
10 changes: 8 additions & 2 deletions js/bike-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import { CrankMotion } from './crank-motion.js';
import { fallLean } from './crash-tumble.js';
import { shakeAt } from './crash-wobble.js';
import { batchSplit, hasParts } from './physics/bike-parts.js';
import { isRigTwin } from './special-bikes.js';

// #611 · the goose tandem's length (claude-3d bike_rig_goosefit26.json length_m): a special bike on the same rig
// (the Time Warp, whose rockets and flames make it longer) is sized as this, so its geese stay the same size.
const GOOSE_TANDEM_LENGTH = 2.466;
const underFx = (o) => { for (let p = o; p; p = p.parent) if (/^Flame[BO]_/.test(p.name || '')) return true; return false; };
import { rebatch } from './bike-batch.js';
import { dracoDecoderPath } from './draco-path.js';

Expand Down Expand Up @@ -367,7 +373,7 @@ export class BikeModel {
const preBox = new THREE.Box3().setFromObject(model, isGoose);
const preSize = preBox.getSize(new THREE.Vector3());
const maxDim = Math.max(preSize.x, preSize.y, preSize.z);
const scale = targetLength / maxDim;
const scale = isGoose && isRigTwin(modelPath) ? targetLength / GOOSE_TANDEM_LENGTH : targetLength / maxDim;
model.scale.setScalar(scale);

// Face the riders model down the road. Applied before the bounds scan so
Expand All @@ -384,7 +390,7 @@ export class BikeModel {
const v = new THREE.Vector3();

model.traverse((child) => {
if (child.isMesh && child.geometry && child.geometry.attributes.position) {
if (child.isMesh && child.geometry && child.geometry.attributes.position && !underFx(child)) { // (not the flames)
const pos = child.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
Expand Down
24 changes: 19 additions & 5 deletions js/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { colourwaysFor } from './goose-colourways.js';
import { PARTNER_LEFT_REASON } from './network-manager.js';
import { bikeModelPathFor, isRigTwin, isSpecialBike } from './special-bikes.js';
import { isMobile, isTV, isLowPower, isAndroid, isIOS, EVT_COUNTDOWN, EVT_START, EVT_RESET, EVT_RESET_QUICK, EVT_GAMEOVER, EVT_CHECKPOINT, EVT_FINISH, EVT_RETURN_ROOM, MSG_PROFILE, TUNE, BALANCE_DEFAULTS, GUEST_NAME, BIKE_MODEL_PATH, CHOOSER_MODEL_PATH, getShowRiders, getRidersLight, getShowSolo, getGooseOutfit, getGooseColourway, setGooseColourway, getSeatLooks, getP1Look, getShowFps, getPhysicsFx, DAILY_BOARD_ENABLED, applyDifficulty, applySteeringFeel, snapshotTuningBase } from './config.js';
import { TUNING_BASE, setTuningBase, migrateSavedTuning, TUNING_SAVE_BASE_FLAG, tuningBaseFromSave, loadSteeringFeelPref, saveSteeringFeelPref, GYRO_MIN_SENSITIVITY, observedFullLeanDegrees } from './config.js'; // B2: un-feeled calibration base
import { RaceManager, FIRST_SEGMENT_BONUS_S } from './race-manager.js';
Expand Down Expand Up @@ -1189,7 +1190,7 @@ class Game {
this._restoreProceduralWorld(); // #400: never on a leftover tiles world
this._hideTouristGoal();
this._leaveSlingMode();
this.bike.applyPreset(this.lobby.selectedPreset);
this._applyBikeChoice();
this._lobbyBtn.textContent = 'LOBBY';

// Load saved tuning on every solo start
Expand All @@ -1215,14 +1216,25 @@ class Game {
this._setupStartHandler();
}

/**
* #611 · the chooser's pick for this ride: a paint preset on the goose tandem, or a special bike
* (its own GLB on the same rig, js/special-bikes.js) swapped in. Swapping is a no-op when it is
* already loaded; a locked pick never reaches here (the chooser falls back to the default).
*/
_applyBikeChoice() {
const key = this.lobby && this.lobby.selectedPresetKey;
if (!this.isTourist) this.bike.swapModel(bikeModelPathFor(this._showRiders, key), !this.versusRigs);
this.bike.applyPreset(isSpecialBike(key) ? null : this.lobby.selectedPreset);
}

_onMultiplayerReady(net, mode) {
this.mode = mode;
this.net = net;
this._partnerLook = null; // #548: set when the partner's profile carries their look
this._partnerLookKey = '';
this._partnerTouristReady = null; // #400: this ride's ready barrier starts empty
this._lobbyBtn.textContent = 'ROOM';
this.bike.applyPreset(this.lobby.selectedPreset);
this._applyBikeChoice();

// The lobby's InputManager may already hold motion permission (granted via
// a user gesture in the lobby). Our game InputManager is a separate
Expand Down Expand Up @@ -1639,7 +1651,7 @@ class Game {
this._localP2Type = sourceType;
this.balanceCtrlP2 = new BalanceController(inputP2);
this._lobbyBtn.textContent = 'LOBBY';
this.bike.applyPreset(this.lobby.selectedPreset);
this._applyBikeChoice();
this._loadSavedTuning();

// Shared pedal controller: P1 taps as 'captain', P2 taps as 'stoker'.
Expand Down Expand Up @@ -1739,7 +1751,9 @@ class Game {
scene: this.scene,
world: this.world,
lateralOffset: i === 0 ? -0.8 : 0.8,
bikeCloneSource: this.bike, // boot bike's GLB is loaded — clone, don't re-fetch
// boot bike's GLB is loaded — clone, don't re-fetch; a special bike (#611) has no team paint, so
// versus loads the goose tandem fresh instead
bikeCloneSource: isRigTwin(this.bike._modelPath) ? null : this.bike,
});
rig.bike.applyPreset(presets[rig.color.presetKey] || null);
rig.bike.setGooseLook(teamLooks(t));
Expand Down Expand Up @@ -6152,7 +6166,7 @@ class Game {
// smoke:quality check audits every drawn material after a mid-ride switch).

this.bike.swapModel(
on ? BIKE_MODEL_PATH : CHOOSER_MODEL_PATH,
bikeModelPathFor(on, this.lobby && this.lobby.selectedPresetKey), // #611: a special bike stays itself
!this.versusRigs // live versus clones share the old model's geometry
);

Expand Down
4 changes: 4 additions & 0 deletions js/goose-rider-clips.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const UPPER = new Set(['Spine', 'Spine1', 'Spine2', 'Neck', 'Neck1', 'Neck2', 'N
const LOWER_RE = /^(Left|Right)(UpLeg|Leg|Foot|ToeBase)$|^Cloth_/;
const CRANK_RE = /^(Crank|Pedal)_/; // the bike's Pedal clip: cranks + pedals (+ their PedalPin sockets ride along)
const WHEEL_RE = /^Wheel_/;
const FX_RE = /^(Clock|Flame)/; // #611 · the Time Warp bike's TimeWarpFX clip: clock hands + rocket flames

/** Bone (node) name a three.js track targets: "LeftLeg.quaternion" -> "LeftLeg". */
export function trackBone(trackName) {
Expand All @@ -46,11 +47,14 @@ export function isLowerBone(name) { return LOWER_RE.test(name); }
/** The bike's crank / pedal bones (its Pedal clip) and wheel bones (its WheelSpin clip). */
export function isCrankBone(name) { return CRANK_RE.test(name); }
export function isWheelBone(name) { return WHEEL_RE.test(name); }
/** A special bike's effect bones (the Time Warp's clock hands and flames: its TimeWarpFX clip). */
export function isFxBone(name) { return FX_RE.test(name); }

/** Which bones a clip may drive, by its name; null = not a clip of the goose tandem. */
export function boneFilterFor(clipName) {
if (clipName === 'Pedal') return isCrankBone;
if (clipName === 'WheelSpin') return isWheelBone;
if (clipName === 'TimeWarpFX') return isFxBone;
if (/^Pedal_/.test(clipName)) return isLowerBone;
if (/^(Ride|Lean_L|Lean_R)_/.test(clipName)) return isUpperBone;
return null;
Expand Down
11 changes: 10 additions & 1 deletion js/goose-riders.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export function disposeTree(root) {
});
}

/** Seconds on the wall clock (the effect loop runs whether or not the bike moves). */
function fxClock() {
return (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
}

function cutClip(clip) {
const keep = boneFilterFor(clip.name);
const c = clip.clone();
Expand All @@ -59,6 +64,8 @@ export class GooseRiders {
// on one mixer average out (the cranks turned half as far as the geese's legs)
this.pedal = byName('Pedal') ? scrub(this.bikeMixer, cutClip(byName('Pedal'))) : null;
this.wheels = byName('WheelSpin') ? scrub(this.bikeMixer, cutClip(byName('WheelSpin'))) : null;
// #611 · a special bike's own loop (the Time Warp: clock hands + rocket flames), on the wall clock
this.fx = byName('TimeWarpFX') ? scrub(this.bikeMixer, cutClip(byName('TimeWarpFX'))) : null;
this.wheelCircumference = 0; // world units; set by BikeModel once the model is scaled
this.riderClips = {}; // seat -> { pedal, ride, leanL, leanR } (already cut to their bones)
this.riders = {}; // seat -> { root, mixer, pedal, ride, leanL, leanR }
Expand All @@ -69,7 +76,8 @@ export class GooseRiders {

/** Measure the wheel in world units (call after the model is scaled and placed). */
measureWheel() {
const tyre = this.model.getObjectByName('Cylinder008_cycle_0') || this.model.getObjectByName('tyre_rear_low_cycle_0');
const tyre = this.model.getObjectByName('Cylinder008_cycle_0') || this.model.getObjectByName('tyre_rear_low_cycle_0')
|| this.model.getObjectByName('TW_Wheel_R'); // #611 · the Time Warp's rear wheel (one mesh, tyre outermost)
if (!tyre) return;
this.model.updateMatrixWorld(true);
const size = new THREE.Box3().setFromObject(tyre, true).getSize(new THREE.Vector3());
Expand Down Expand Up @@ -204,6 +212,7 @@ export class GooseRiders {
if (this.wheels && this.wheelCircumference > 0) {
this.wheels.time = turnTime(2 * Math.PI * this._dist / this.wheelCircumference, this.wheels.getClip().duration);
}
if (this.fx) this.fx.time = turnTime(2 * Math.PI * fxClock() / this.fx.getClip().duration, this.fx.getClip().duration);
this.bikeMixer.update(0);
for (const [seat, r] of Object.entries(this.riders)) {
if (r.ragdoll) continue; // #484: a crash's rag doll owns this goose's bones (physics/crash-scene.js)
Expand Down
125 changes: 123 additions & 2 deletions js/lobby.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@

import * as THREE from 'three';
import { GOOSE_OUTFITS } from './goose-outfits.js';
import { lockedOutfits, outfitLockHint } from './rewards.js';
import { lockedOutfits, outfitLockHint, timeWarpProgress } from './rewards.js';
import { SPECIAL_BIKE_KEYS, isSpecialBike, TIME_WARP_BIKE_MODEL_PATH } from './special-bikes.js';
import { boneFilterFor, trackBone } from './goose-rider-clips.js';
import { PARTNER_LEFT_REASON } from './network-manager.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { InputManager, isSteamTwinPad } from './input-manager.js';
Expand Down Expand Up @@ -114,6 +116,7 @@ const BIKE_NAMES = {
bike_christmas: 'Christmas Cruiser',
bike_newyears: 'New Year Rider',
bike_birthday: 'Birthday Express',
time_warp: 'Time Warp', // #611 · a special bike (its own model, js/special-bikes.js)
};

const BIKE_COLORS = {
Expand All @@ -127,6 +130,7 @@ const BIKE_COLORS = {
bike_christmas: '#2eff46',
bike_newyears: '#fbff00',
bike_birthday: '#66ff00',
time_warp: '#f0b860',
};

// Map bike preset keys to their per-bike Grandma's House achievement IDs
Expand Down Expand Up @@ -8182,7 +8186,7 @@ export class Lobby {
} catch (e) {
console.warn('Failed to load holiday bike presets:', e);
}
this._presetKeys = ['default', ...Object.keys(this._presetData)];
this._presetKeys = ['default', ...Object.keys(this._presetData), ...SPECIAL_BIKE_KEYS];

// Setup mini 3D preview
const canvas = document.getElementById('bike-preview-canvas');
Expand Down Expand Up @@ -8326,9 +8330,120 @@ export class Lobby {
}, { passive: true });
}

/** Earned achievement ids (the AchievementManager when the lobby has it, else its store). */
_earnedAchievementIds() {
if (this._achievements) return this._achievements.getEarnedIds();
try {
const raw = localStorage.getItem('tandemonium_achievements');
return raw ? JSON.parse(raw).map(a => a.id) : [];
} catch { return []; }
}

/**
* #611 · the special bike's own model in the chooser, loaded the first time it is shown: sized like
* the tandem preview, its TimeWarpFX loop on a mixer, materials kept so a locked silhouette can be undone.
*/
_loadSpecialPreview(onReady) {
if (this._specialPreview) { if (this._specialPreview.model) onReady(); return; }
this._specialPreview = { model: null };
new GLTFLoader().load(TIME_WARP_BIKE_MODEL_PATH, (gltf) => {
const model = gltf.scene;
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
model.scale.setScalar(3.0 / Math.max(size.x, size.y, size.z));
model.updateMatrixWorld(true);
const b2 = new THREE.Box3().setFromObject(model);
const c = b2.getCenter(new THREE.Vector3());
model.position.set(-c.x, -b2.min.y, -c.z);
const mats = [];
model.traverse((o) => {
if (!o.isMesh) return;
o.material = o.material.clone();
o.castShadow = true;
mats.push([o.material, o.material.clone()]);
});
const mixer = new THREE.AnimationMixer(model);
const clip = gltf.animations.find(a => a.name === 'TimeWarpFX');
let fx = null;
if (clip) {
const cut = clip.clone();
const keep = boneFilterFor('TimeWarpFX');
cut.tracks = cut.tracks.filter(t => keep(trackBone(t.name)));
fx = mixer.clipAction(cut);
fx.play();
fx.paused = true;
}
model.visible = false;
if (this._previewPivot) this._previewPivot.add(model);
Object.assign(this._specialPreview, { model, mats, mixer, fx, dark: false });
onReady();
}, undefined, (err) => console.warn('Time Warp preview failed to load:', err));
}

/** Dark silhouette (locked) or its own colours (unlocked). */
_setSpecialDark(dark) {
const sp = this._specialPreview;
if (!sp || !sp.model || sp.dark === dark) return;
sp.dark = dark;
for (const [m, orig] of sp.mats) {
if (dark) {
m.color.set(0x050505);
if (m.emissive) m.emissive.set(0x000000);
m.metalness = 0; m.roughness = 1;
} else {
m.copy(orig);
}
m.needsUpdate = true;
}
if (dark && sp.fx) { sp.fx.time = 0; sp.mixer.update(0); } // (the orange pops tucked away)
}

/** #611 · a special bike in the chooser: its own model; locked = dark silhouette + "n of N" over it. */
_applySpecialToPreview(key, nameEl) {
const prog = timeWarpProgress(this._earnedAchievementIds());
const locked = !prog.unlocked;
if (this._previewModel) this._previewModel.visible = false;
const show = () => {
if (this._presetKeys[this._presetIndex] !== key) return; // moved on while it loaded
this._specialPreview.model.visible = true;
this._setSpecialDark(locked);
};
this._loadSpecialPreview(show);
let hintEl = document.getElementById('bike-hint');
if (!hintEl) {
hintEl = document.createElement('div');
hintEl.id = 'bike-hint';
hintEl.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.5);margin-top:2px;text-align:center;';
nameEl.parentNode.insertBefore(hintEl, nameEl.nextSibling);
}
this.selectedPreset = null;
if (locked) {
this.selectedPresetKey = 'default';
nameEl.style.color = 'rgba(255,255,255,0.3)';
nameEl.textContent = '\uD83D\uDD12 ' + BIKE_NAMES[key];
hintEl.innerHTML = `<span class="bike-count" style="font-size:18px;font-weight:700;color:rgba(255,255,255,0.85)">${prog.have} of ${prog.need}</span>`
+ '<br><span style="color:rgba(255,255,255,0.55)">achievements</span>';
hintEl.title = `${BIKE_NAMES[key]}: any ${prog.need} of the ${prog.total} achievements that don't need real days`;
hintEl.dataset.special = key;
hintEl.style.display = '';
return;
}
this.selectedPresetKey = key;
nameEl.style.color = BIKE_COLORS[key];
nameEl.textContent = '\u23F3 ' + BIKE_NAMES[key];
hintEl.style.display = 'none';
hintEl.title = '';
delete hintEl.dataset.special;
analytics.trackEvent('bike_color_change', { preset_key: key });
}

_applyPresetToPreview() {
const key = this._presetKeys[this._presetIndex];
const nameEl = document.getElementById('bike-name');
if (isSpecialBike(key)) { this._applySpecialToPreview(key, nameEl); return; }
if (this._previewModel) this._previewModel.visible = true;
if (this._specialPreview && this._specialPreview.model) this._specialPreview.model.visible = false;
{ const h = document.getElementById('bike-hint'); if (h) delete h.dataset.special; }
const locked = HOLIDAY_BIKES[key] && !this._isHolidayUnlocked(key);
nameEl.style.color = locked ? 'rgba(255,255,255,0.3)' : (BIKE_COLORS[key] || 'rgba(68, 255, 102, 0.7)');

Expand Down Expand Up @@ -8458,6 +8573,12 @@ export class Lobby {
if (this._previewPivot) {
this._previewPivot.rotation.y += 0.008 * step;
}
// #611 · the Time Warp's clock and flames run in the chooser too (not while it's a locked silhouette)
const sp = this._specialPreview;
if (sp && sp.model && sp.model.visible && sp.fx && !sp.dark) {
sp.fx.time = (performance.now() / 1000) % sp.fx.getClip().duration;
sp.mixer.update(0);
}
this._previewRenderer.render(this._previewScene, this._previewCamera);
};
animate();
Expand Down
Loading
Loading