SOURCE / PINNED RELEASE
Made of little things.
Pickle Rustlers
- Release
- 039e51f55e34…
- Author-recorded commit
- 40608586a8a1…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35
Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Pickle Rustlers: rope pickles, peel onions, borrow a tank, beat the sundown.
import * as THREE from 'three';
import { Effects } from './fx.js';
import { Hud } from './hud.js';
import { Input, type InputFrame } from './input.js';
import { CAMPAIGN_DAYS, LEVELS, levelFor, type Level } from './levels.js';
import {
JAR_RADIUS,
buildGuideArrow,
buildHorseAndRider,
buildKing,
buildTargetRing,
buildJar,
buildOnion,
buildPickle,
buildSauce,
buildTank,
palette,
type JarModel,
type OnionModel,
type PickleModel,
type RiderModel,
type TankModel,
} from './models.js';
import type { RunSummary, ScoreShare } from '../share/types.js';
import { sfx, unlockAudio } from './sound.js';
import { ARENA_RADIUS, World, groundHeight } from './world.js';
const MAX_TEARS = 5;
const MAX_ROPES = 3;
const TANK_TIME = 20;
const SAUCE_TIME = 6;
const SHOTGUN_RANGE = 21;
const LASSO_RANGE = 19;
type PickleState = 'free' | 'roped' | 'carried' | 'flying';
interface Pickle {
m: PickleModel;
x: number;
z: number;
vx: number;
vz: number;
heading: number;
golden: boolean;
state: PickleState;
hop: number;
wanderT: number;
wanderA: number;
carrier: Onion | null;
fly: { t: number; from: THREE.Vector3; to: THREE.Vector3 } | null;
launchY: number;
launchVy: number;
}
interface Onion {
m: OnionModel;
x: number;
z: number;
heading: number;
size: number;
carrying: Pickle | null;
thinkT: number;
tx: number;
tz: number;
burnCd: number;
knock: number;
kx: number;
kz: number;
walk: number;
spawnT: number;
grace: number; // fresh onions need a moment before they can grab a pickle
king: boolean;
hp: number; // the King counts layers; regular onions use size
maxHp: number;
summonT: number;
}
interface Shell {
mesh: THREE.Mesh;
pos: THREE.Vector3;
vel: THREE.Vector3;
enemy: boolean;
marker: THREE.Mesh | null;
}
interface EnemyTank {
m: TankModel;
x: number;
z: number;
heading: number;
turret: number;
hp: number;
fireT: number;
hitFlash: number;
}
type Mode = 'title' | 'playing' | 'paused' | 'dayEnd' | 'gameOver' | 'victory';
const KING_SIZE = 4;
const KING_HP = 30;
const FIREWORK_COLORS = [0xff595e, 0xffca3a, 0x8ac926, 0x1982c4, 0xc77dff, 0xff924c];
const angleDiff = (a: number, b: number) => {
let d = a - b;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
};
export interface Progress {
best: number;
wins: number;
}
export interface GameOptions {
canvas: HTMLCanvasElement;
loadProgress: () => Promise<Progress>;
saveProgress: (progress: Progress) => Promise<void>;
/** Optional score-card sharing; absent when the runtime lacks a domain. */
share?: ScoreShare | null;
}
export class Game {
private renderer: THREE.WebGLRenderer;
private scene = new THREE.Scene();
private camera = new THREE.PerspectiveCamera(60, 1, 0.5, 600);
private world: World;
private fx: Effects;
private hud = new Hud();
private input: Input;
private lastTime = performance.now();
private mode: Mode = 'title';
private best = 0;
private wins = 0;
private endless = false;
private victoryRun: RunSummary | null = null;
private composing = false;
shakeEnabled = true;
private reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
// Player
private rider: RiderModel;
private px = 0;
private pz = 18;
private heading = 0;
private speed = 0;
private gait = 0;
private hoofBeat = 0;
private jumpY = 0; // height of the horse above the ground while leaping a rock
private jumpVy = 0;
private tears = 0;
private invuln = 0;
private shells = 2;
private reloadT = 0;
private gunKick = 0;
private sauceT = 0;
private flameT = 0;
private tankT = 0;
private cannonT = 0;
private roped: Pickle[] = [];
private lasso: { mesh: THREE.Mesh; t: number; dur: number; from: THREE.Vector3; to: THREE.Vector3; target: Pickle | null } | null = null;
private lassoCd = 0;
private ropeSegs: THREE.Mesh[][] = [];
// World objects
private jar: JarModel;
private jarBeam: THREE.Mesh;
private pickles: Pickle[] = [];
private onions: Onion[] = [];
private shellsInAir: Shell[] = [];
private sauces: { mesh: THREE.Group; x: number; z: number }[] = [];
private sauceRespawn = 0;
private tank: TankModel;
private tankBeam: THREE.Mesh;
private tankParked = false;
private tankX = 0;
private tankZ = 0;
private tankHeading = 0;
private enemy: EnemyTank | null = null;
private enemyModel: TankModel;
private enemySpawned = false;
private king: Onion | null = null;
private kingSpawned = false;
private kingDefeated = true;
private guideArrow: THREE.Group;
private targetRing: THREE.Mesh;
private fireworkT = 0;
private quotaToastShown = false;
// Day
private day = 1;
private level: Level = LEVELS[0];
private dayLength = LEVELS[0].dayLength;
private timeLeft = LEVELS[0].dayLength;
private quota = LEVELS[0].quota;
private brinedToday = 0;
private brinedTotal = 0;
private brinedRun = 0;
private onionsToday = 0;
private onionsTotal = 0;
private tearsTotal = 0;
private score = 0;
private spawnT = 0;
private winT = 0;
private shake = 0;
private attractT = 0;
private camPos = new THREE.Vector3(0, 30, 60);
private camLook = new THREE.Vector3();
private tmp = new THREE.Vector3();
private tmp2 = new THREE.Vector3();
private ropeStart = new THREE.Vector3();
private ropePrev = new THREE.Vector3();
private ropeCur = new THREE.Vector3();
private portrait = false;
private up = new THREE.Vector3(0, 1, 0);
constructor(private opts: GameOptions) {
const { canvas } = opts;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFShadowMap;
this.world = new World(this.scene, true);
this.fx = new Effects(this.scene);
this.jar = buildJar();
this.scene.add(this.jar.root);
this.jarBeam = this.beam(0x9be564);
this.jarBeam.position.set(0, 0, 0);
this.scene.add(this.jarBeam);
this.rider = buildHorseAndRider();
this.scene.add(this.rider.root);
this.tank = buildTank(false);
this.tank.root.visible = false;
this.scene.add(this.tank.root);
this.tankBeam = this.beam(0xffd27a);
this.tankBeam.visible = false;
this.scene.add(this.tankBeam);
this.enemyModel = buildTank(true);
this.enemyModel.root.visible = false;
this.scene.add(this.enemyModel.root);
this.guideArrow = buildGuideArrow();
this.guideArrow.visible = false;
this.scene.add(this.guideArrow);
this.targetRing = buildTargetRing();
this.targetRing.visible = false;
this.scene.add(this.targetRing);
const ropeGeo = new THREE.CylinderGeometry(0.06, 0.06, 1, 4);
ropeGeo.translate(0, 0.5, 0);
const ropeMat = new THREE.MeshLambertMaterial({ color: palette.rope, flatShading: true });
for (let r = 0; r < MAX_ROPES + 1; r++) {
const segs: THREE.Mesh[] = [];
for (let i = 0; i < 10; i++) {
const seg = new THREE.Mesh(ropeGeo, ropeMat);
seg.visible = false;
this.scene.add(seg);
segs.push(seg);
}
this.ropeSegs.push(segs);
}
this.input = new Input(
document.getElementById('stick')!,
document.getElementById('knob')!,
document.getElementById('shootBtn')!,
document.getElementById('lassoBtn')!,
(kind) => {
unlockAudio();
if (kind === 'touch') this.hud.setTouch(this.mode === 'playing');
},
(action) => {
if (action === 'pause') return this.mode === 'playing' || this.mode === 'paused';
if (action === 'shoot') return this.mode === 'playing' || this.hud.posterOpen;
return this.mode === 'playing';
},
);
window.addEventListener('pointerdown', () => unlockAudio());
document.getElementById('pauseBtn')!.addEventListener('click', () => this.pause());
window.addEventListener('blur', () => this.pause());
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.pause();
});
const resize = () => this.resize();
new ResizeObserver(resize).observe(canvas.parentElement ?? canvas);
resize();
this.populateAttract();
this.hud.showHud(false);
this.showTitle();
opts
.loadProgress()
.then((progress) => {
this.best = progress.best;
this.wins = progress.wins;
if (this.mode === 'title') this.showTitle();
})
.catch(() => undefined);
// Sign-in changes add or remove the share button on an open victory poster.
opts.share?.onChange(() => {
if (this.mode === 'victory' && !this.composing) this.showVictoryPoster();
});
this.renderer.setAnimationLoop(() => this.frame());
}
dispose(): void {
this.renderer.setAnimationLoop(null);
this.input.dispose();
this.renderer.dispose();
}
private showTitle(): void {
this.hud.showPoster('title', { best: this.best, wins: this.wins, days: CAMPAIGN_DAYS }, () => this.startRun());
}
private beam(color: number): THREE.Mesh {
// fog: false keeps the beams visible through the dust storm.
const m = new THREE.Mesh(
new THREE.CylinderGeometry(0.7, 1.4, 60, 6, 1, true),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.25, depthWrite: false, side: THREE.DoubleSide, fog: false }),
);
m.geometry.translate(0, 30, 0);
return m;
}
private resize(): void {
const parent = this.opts.canvas.parentElement ?? this.opts.canvas;
const w = Math.max(1, parent.clientWidth);
const h = Math.max(1, parent.clientHeight);
// Keep fill-rate sane on big or high-density screens.
const dpr = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(2_400_000 / (w * h)));
this.renderer.setPixelRatio(Math.max(0.75, dpr));
this.renderer.setSize(w, h, false);
this.portrait = h > w;
this.camera.aspect = w / h;
this.camera.fov = w / h < 1 ? 72 : 60;
this.camera.updateProjectionMatrix();
}
// ---------- run / day lifecycle ----------
private retireMesh(mesh: THREE.Mesh): void {
this.scene.remove(mesh);
mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) material.dispose();
}
private clearDay(): void {
for (const p of this.pickles) this.scene.remove(p.m.root);
for (const o of this.onions) this.scene.remove(o.m.root);
for (const s of this.shellsInAir) {
this.retireMesh(s.mesh);
if (s.marker) this.retireMesh(s.marker);
}
for (const s of this.sauces) this.scene.remove(s.mesh);
this.pickles = [];
this.onions = [];
this.shellsInAir = [];
this.sauces = [];
this.roped = [];
if (this.lasso) this.retireMesh(this.lasso.mesh);
this.lasso = null;
this.enemy = null;
this.enemyModel.root.visible = false;
this.enemySpawned = false;
this.king = null;
this.kingSpawned = false;
this.targetRing.visible = false;
this.guideArrow.visible = false;
}
private populateAttract(): void {
this.clearDay();
for (let i = 0; i < 9; i++) this.spawnPickle(i === 0);
for (let i = 0; i < 4; i++) this.spawnOnion(2);
this.placeTank();
}
private startRun(): void {
unlockAudio();
this.score = 0;
this.day = 1;
this.tears = 0;
this.endless = false;
this.onionsTotal = 0;
this.tearsTotal = 0;
this.brinedTotal = 0;
this.brinedRun = 0;
for (const f of [...this.jar.floaters.children]) this.jar.floaters.remove(f);
this.startDay();
}
private startDay(): void {
this.clearDay();
this.mode = 'playing';
this.hud.hidePoster();
this.hud.showHud(true);
this.hud.setTouch(this.input.touchSeen || matchMedia('(pointer: coarse)').matches);
this.level = levelFor(this.day);
this.dayLength = this.level.dayLength;
this.timeLeft = this.dayLength;
this.quota = this.level.quota;
this.kingDefeated = !this.level.boss;
this.quotaToastShown = false;
this.world.dustTarget = this.level.dust ? 1 : 0;
this.brinedToday = 0;
this.onionsToday = 0;
this.winT = 0;
this.px = 0;
this.pz = JAR_RADIUS + 20;
this.heading = 0;
this.speed = 0;
this.jumpY = this.jumpVy = 0;
this.shells = 2;
this.reloadT = 0;
this.sauceT = 0;
this.tankT = 0;
this.invuln = 2;
this.spawnT = 4;
this.rider.root.visible = true;
for (let i = 0; i < 10; i++) this.spawnPickle(Math.random() < this.level.goldenChance);
for (let i = 0; i < this.level.startOnions; i++) this.spawnOnion(this.randomOnionSize());
for (let i = 0; i < 2; i++) this.spawnSauce();
this.sauceRespawn = 0;
this.placeTank();
this.input.clear();
const label = this.day <= CAMPAIGN_DAYS && !this.endless ? `DAY ${this.day} OF ${CAMPAIGN_DAYS}` : `DAY ${this.day} \u00b7 ENDLESS`;
this.hud.banner(label, this.level.name, this.level.boss ? this.level.blurb : `${this.level.blurb} Brine ${this.quota} pickles.`);
}
private dayLabel(): string {
return this.day <= CAMPAIGN_DAYS && !this.endless ? `${this.day}/${CAMPAIGN_DAYS}` : String(this.day);
}
private endDay(): void {
const bonus = Math.ceil(this.timeLeft) * 10;
this.score += bonus;
this.onionsTotal += this.onionsToday;
this.exitTank(false);
if (this.day === CAMPAIGN_DAYS && !this.endless) {
this.victory();
return;
}
this.mode = 'dayEnd';
const healed = Math.min(2, this.tears);
this.tears -= healed;
sfx.dayWon();
this.hud.setTouch(false);
const nextDay = this.day + 1;
const next = levelFor(nextDay);
this.hud.showPoster(
'dayEnd',
{
day: this.day,
brined: this.brinedToday,
onions: this.onionsToday,
bonus,
healed,
score: this.score,
nextLabel: nextDay <= CAMPAIGN_DAYS && !this.endless ? `NEXT: DAY ${nextDay} OF ${CAMPAIGN_DAYS}` : `NEXT: DAY ${nextDay}`,
nextName: next.name,
nextBlurb: next.blurb,
},
() => {
this.day++;
this.startDay();
},
);
}
/** Beat the final day: fireworks over the jar, dancing pickles, a reward poster. */
private victory(): void {
this.mode = 'victory';
this.wins++;
const newBest = this.score > this.best;
if (newBest) this.best = this.score;
this.opts.saveProgress({ best: this.best, wins: this.wins }).catch(() => undefined);
sfx.victory();
this.hud.setTouch(false);
this.hud.showHud(false);
this.world.dustTarget = 0;
// Clear the outlaws and line up a pickle conga around the jar.
this.clearDay();
for (let i = 0; i < 14; i++) this.spawnPickle(i % 4 === 0);
this.attractT = 0;
this.fireworkT = 0.3;
this.victoryRun = { score: this.score, brined: this.brinedRun, onions: this.onionsTotal, tears: this.tearsTotal, wins: this.wins, newBest };
this.showVictoryPoster();
}
private showVictoryPoster(): void {
const run = this.victoryRun;
if (!run) return;
this.composing = false;
const alts = [
{
label: 'Keep riding',
run: () => {
this.endless = true;
this.tears = 0;
this.day++;
this.startDay();
},
},
];
if (this.opts.share?.available) alts.push({ label: 'Post score card', run: () => this.openComposer(run) });
this.hud.showPoster('victory', { ...run, best: this.best }, () => this.startRun(), alts);
}
private openComposer(run: RunSummary): void {
const share = this.opts.share;
if (!share) return;
this.composing = true;
const card = share.makeCard(run, this.captureScene(1200, 675));
this.hud.showComposer({
card,
text:
`I tamed the West in Pickle Rustlers! 🥒🤠\n` +
`$${run.score.toLocaleString('en-US')} bounty · ${run.brined} pickles brined · ${run.onions} onions peeled · ${run.tears} tears cried\n` +
`#PickleRustlers`,
post: (png, text, onStatus) => share.post(png, text, onStatus),
back: () => this.showVictoryPoster(),
});
}
/** Render one frame at a fixed size and copy it before the drawing buffer clears. */
private captureScene(w: number, h: number): HTMLCanvasElement {
const out = document.createElement('canvas');
out.width = w;
out.height = h;
this.renderer.setPixelRatio(1);
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.fov = 60;
this.camera.updateProjectionMatrix();
this.renderer.render(this.scene, this.camera);
out.getContext('2d')?.drawImage(this.renderer.domElement, 0, 0, w, h);
this.resize();
return out;
}
private celebrate(dt: number): void {
this.attractT += dt;
const t = this.attractT;
const a = t * 0.3;
this.px = Math.cos(a) * 17;
this.pz = Math.sin(a) * 17;
this.heading = Math.atan2(-Math.sin(a), Math.cos(a));
this.speed = 7;
this.placeRider(dt);
this.pickles.forEach((p, i) => {
const ang = (i / this.pickles.length) * Math.PI * 2 - t * 0.5;
const r = 11 + Math.sin(t * 2 + i) * 0.6;
p.x = Math.cos(ang) * r;
p.z = Math.sin(ang) * r;
const y = groundHeight(p.x, p.z) + Math.abs(Math.sin(t * 6 + i * 0.7)) * 1.4;
p.m.root.position.set(p.x, y, p.z);
p.m.root.rotation.set(0, Math.atan2(p.x, p.z) + Math.sin(t * 3 + i) * 0.6, 0);
p.m.body.rotation.set(0, 0, Math.sin(t * 6 + i) * 0.35);
});
this.fireworkT -= dt;
if (this.fireworkT <= 0) {
this.fireworkT = 0.35 + Math.random() * 0.45;
const pos = this.tmp.set((Math.random() - 0.5) * 36, 18 + Math.random() * 12, (Math.random() - 0.5) * 36);
this.fx.firework(pos, FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)]);
sfx.firework();
}
}
private gameOver(reason: 'tears' | 'sundown'): void {
this.mode = 'gameOver';
this.exitTank(false);
this.targetRing.visible = false;
this.guideArrow.visible = false;
sfx.lose();
this.hud.setTouch(false);
const newBest = this.score > this.best;
if (newBest) {
this.best = this.score;
this.opts.saveProgress({ best: this.best, wins: this.wins }).catch(() => undefined);
}
this.hud.showPoster('gameOver', { day: this.day, score: this.score, best: this.best, wins: this.wins, newBest, reason }, () =>
this.startRun(),
);
}
private pause(): void {
if (this.mode !== 'playing') return;
this.mode = 'paused';
this.input.clear();
this.hud.setTouch(false);
this.hud.showPoster('paused', {}, () => this.resume());
}
private resume(): void {
this.mode = 'playing';
this.hud.hidePoster();
this.hud.setTouch(this.input.touchSeen || matchMedia('(pointer: coarse)').matches);
this.input.clear();
this.lastTime = performance.now();
}
// ---------- spawning ----------
private randomPoint(min: number, max: number, awayFromPlayer = 0): { x: number; z: number } {
for (let tries = 0; tries < 40; tries++) {
const a = Math.random() * Math.PI * 2;
const d = min + Math.random() * (max - min);
const x = Math.cos(a) * d;
const z = Math.sin(a) * d;
if (Math.hypot(x - this.px, z - this.pz) < awayFromPlayer) continue;
if (this.world.colliders.some((c) => Math.hypot(c.x - x, c.z - z) < c.r + 2)) continue;
return { x, z };
}
return { x: min, z: 0 };
}
private spawnPickle(golden: boolean): void {
const m = buildPickle(golden);
const p = this.randomPoint(20, ARENA_RADIUS - 18, 18);
this.scene.add(m.root);
this.pickles.push({
m,
x: p.x,
z: p.z,
vx: 0,
vz: 0,
heading: Math.random() * 6,
golden,
state: 'free',
hop: Math.random() * 6,
wanderT: 0,
wanderA: Math.random() * 6,
carrier: null,
fly: null,
launchY: 0,
launchVy: 0,
});
}
private randomOnionSize(): number {
const [small, medium] = this.level.sizeMix;
const r = Math.random();
return r < small ? 1 : r < small + medium ? 2 : 3;
}
private spawnOnion(size: number, x?: number, z?: number): Onion {
const m = buildOnion();
let ox = x;
let oz = z;
if (ox === undefined || oz === undefined) {
const a = Math.random() * Math.PI * 2;
ox = Math.cos(a) * (ARENA_RADIUS - 2);
oz = Math.sin(a) * (ARENA_RADIUS - 2);
}
this.scene.add(m.root);
const o: Onion = {
m,
x: ox,
z: oz,
heading: Math.atan2(-ox, -oz),
size,
carrying: null,
thinkT: 0,
tx: 0,
tz: 0,
burnCd: 0,
knock: 0,
kx: 0,
kz: 0,
walk: Math.random() * 6,
spawnT: 0.35,
grace: 4,
king: false,
hp: 0,
maxHp: 0,
summonT: 0,
};
this.onions.push(o);
return o;
}
private spawnKing(): void {
const a = Math.atan2(this.pz, this.px) + Math.PI;
const o = this.spawnOnion(KING_SIZE, Math.cos(a) * (ARENA_RADIUS - 6), Math.sin(a) * (ARENA_RADIUS - 6));
this.scene.remove(o.m.root);
o.m = buildKing();
this.scene.add(o.m.root);
o.king = true;
o.hp = o.maxHp = KING_HP;
o.summonT = 10;
o.grace = Infinity;
this.king = o;
this.kingSpawned = true;
sfx.kingRoar();
this.addShake(0.8);
this.hud.toast('THE ONION KING RIDES IN!', 'bad');
}
private summonGuards(o: Onion, count: number): void {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const guard = this.spawnOnion(Math.random() < 0.5 ? 1 : 2, o.x + Math.sin(a) * 4, o.z + Math.cos(a) * 4);
guard.grace = 2;
}
this.hud.toast('The King summons his guard!', 'bad');
}
private killKing(o: Onion): void {
const pos = new THREE.Vector3(o.x, groundHeight(o.x, o.z) + 3, o.z);
this.fx.flash(pos, 16, 0xffffff, 0.6);
this.fx.burst(pos, palette.onion, 40, 16, 0.6, 1.4, 10);
this.fx.burst(pos, palette.onionLight, 30, 14, 0.5, 1.4, 10);
this.fx.burst(pos, palette.gold, 16, 12, 0.4, 1.8, 14);
this.fx.burst(pos, 0x6ec6ff, 20, 10, 0.3, 1.2, 6);
sfx.kingRoar();
sfx.boom();
this.addShake(1.2);
this.score += 1000;
this.kingDefeated = true;
this.king = null;
this.removeOnion(o);
// Without their King, the guards lose their nerve and pop.
for (const guard of [...this.onions]) this.damageOnion(guard, guard.size, 0);
this.hud.toast('THE ONION KING IS PEELED! +1000', 'good');
}
private spawnSauce(): void {
const p = this.randomPoint(18, ARENA_RADIUS - 10, 15);
const mesh = buildSauce();
mesh.position.set(p.x, groundHeight(p.x, p.z), p.z);
this.scene.add(mesh);
this.sauces.push({ mesh, x: p.x, z: p.z });
}
private placeTank(): void {
const p = this.randomPoint(40, 72, 25);
this.tankX = p.x;
this.tankZ = p.z;
this.tankHeading = Math.random() * Math.PI * 2;
this.tankParked = true;
this.tank.root.visible = true;
this.tankBeam.visible = true;
}
private spawnEnemyTank(): void {
const a = Math.atan2(this.pz, this.px) + Math.PI + (Math.random() - 0.5);
const x = Math.cos(a) * (ARENA_RADIUS - 4);
const z = Math.sin(a) * (ARENA_RADIUS - 4);
this.enemy = {
m: this.enemyModel,
x,
z,
heading: Math.atan2(-x, -z),
turret: 0,
hp: 10 + Math.min(this.day, 8) * 2,
fireT: 4,
hitFlash: 0,
};
this.enemyModel.root.visible = true;
this.enemySpawned = true;
this.hud.toast('The onions brought a TANK!', 'bad');
}
// ---------- main loop ----------
private frame(): void {
const now = performance.now();
const dt = Math.min(0.05, (now - this.lastTime) / 1000);
this.lastTime = now;
const input = this.input.read();
if (this.hud.posterOpen && input.confirm) {
this.hud.confirm();
} else if (this.mode === 'playing') {
if (input.pause) this.pause();
else this.step(dt, input);
} else if (this.mode === 'paused' && input.pause) {
this.hud.confirm();
} else if (this.mode === 'title') {
this.attract(dt);
} else if (this.mode === 'victory') {
this.celebrate(dt);
}
const progress =
this.mode === 'title' ? 0.6 : this.mode === 'victory' ? 0.8 : 1 - this.timeLeft / this.dayLength;
this.world.update(dt, progress, this.tmp.set(this.px, 0, this.pz));
if (this.mode !== 'paused') this.fx.update(dt);
this.animateProps(dt);
this.updateCamera(dt);
this.fx.syncInstances();
this.renderer.render(this.scene, this.camera);
if (this.mode !== 'title' && this.mode !== 'victory') {
this.hud.update({
dayLabel: this.dayLabel(),
brined: this.brinedToday,
quota: this.quota,
score: this.score,
timeLeft: this.timeLeft,
dayLength: this.dayLength,
tears: this.tears,
maxTears: MAX_TEARS,
shells: this.shells,
reloading: this.reloadT > 0,
ropes: this.roped.length,
maxRopes: MAX_ROPES,
tankTime: this.tankT,
sauceTime: this.sauceT,
bossHp: this.king ? this.king.hp : 0,
bossMax: KING_HP,
});
}
}
/** Title screen: the horse ambles around the jar while pickles and onions mill about. */
private attract(dt: number): void {
this.attractT += dt;
const a = this.attractT * 0.25;
this.px = Math.cos(a) * 16;
this.pz = Math.sin(a) * 16;
this.heading = Math.atan2(-Math.sin(a), Math.cos(a));
this.speed = 5;
this.placeRider(dt);
for (const p of this.pickles) this.updateFreePickle(p, dt);
for (const o of this.onions) {
o.walk += dt * 6;
o.heading += dt * 0.6;
o.x += Math.sin(o.heading) * dt * 3;
o.z += Math.cos(o.heading) * dt * 3;
this.collide(1, (x, z) => ((o.x = x), (o.z = z)), () => (o.heading += 1), o.x, o.z);
this.placeOnion(o);
}
}
private step(dt: number, input: InputFrame): void {
const quotaMet = this.brinedToday >= this.quota && this.kingDefeated;
if (!quotaMet) this.timeLeft -= dt;
if (this.brinedToday >= this.quota && !this.kingDefeated && !this.quotaToastShown) {
this.quotaToastShown = true;
this.hud.toast('Quota met! Now peel the King!', 'info');
}
if (this.level.boss && !this.kingSpawned && this.timeLeft < this.dayLength - 4) this.spawnKing();
if (this.level.dust) this.blowDust();
if (this.timeLeft <= 0) {
this.timeLeft = 0;
this.gameOver('sundown');
return;
}
this.invuln = Math.max(0, this.invuln - dt);
this.lassoCd = Math.max(0, this.lassoCd - dt);
this.cannonT = Math.max(0, this.cannonT - dt);
this.gunKick = Math.max(0, this.gunKick - dt * 6);
if (this.reloadT > 0) {
this.reloadT -= dt;
if (this.reloadT <= 0) {
this.shells = 2;
sfx.pump();
}
}
if (this.sauceT > 0) {
this.sauceT = Math.max(0, this.sauceT - dt);
this.flameT -= dt;
const inTank = this.tankT > 0;
if (this.flameT <= 0 && inTank) {
this.flameT = 0.04;
const fx = Math.sin(this.heading);
const fz = Math.cos(this.heading);
if (Math.abs(this.speed) > 2) {
for (const side of [1, -1]) {
const bx = this.px - fx * 3.8 + fz * side * 1.8;
const bz = this.pz - fz * 3.8 - fx * side * 1.8;
this.fx.flame(bx, bz, groundHeight(bx, bz));
}
}
// Flamethrower: fire pours out of the cannon barrel, moving or not.
const muzzle = new THREE.Vector3();
this.tank.muzzle.getWorldPosition(muzzle);
this.fx.breath(muzzle, this.tmp2.set(fx * (Math.max(0, this.speed) + 22), 0, fz * (Math.max(0, this.speed) + 22)));
for (const d of [6.5, 9.5, 12.5]) this.fx.flames.push({ x: this.px + fx * d, z: this.pz + fz * d, life: 0.3 });
} else if (this.flameT <= 0 && Math.abs(this.speed) > 2) {
this.flameT = 0.04;
const fx = Math.sin(this.heading);
const fz = Math.cos(this.heading);
const bx = this.px - fx * 1.6;
const bz = this.pz - fz * 1.6;
this.fx.flame(bx, bz, groundHeight(bx, bz));
// The horse breathes fire, too. It scorches onions just ahead.
const hx = this.px + fx * 2.4;
const hz = this.pz + fz * 2.4;
this.fx.breath(
this.tmp.set(hx, groundHeight(hx, hz) + 2.8, hz),
this.tmp2.set(fx * (this.speed + 14), -1.5, fz * (this.speed + 14)),
);
const ax = this.px + fx * 6;
const az = this.pz + fz * 6;
this.fx.flames.push({ x: ax, z: az, life: 0.3 });
}
}
if (this.tankT > 0) {
this.tankT -= dt;
if (this.tankT <= 0) this.exitTank(true);
}
this.movePlayer(dt, input);
if (input.shoot) this.tankT > 0 ? this.fireCannon() : this.fireShotgun();
if (input.lasso) this.throwLasso();
this.updateLasso(dt);
this.updateGuides();
this.updatePickles(dt);
this.updateOnions(dt);
this.updateEnemyTank(dt);
this.updateShells(dt);
this.updatePickups(dt);
this.checkJar();
this.spawnLogic(dt);
if (this.tears >= MAX_TEARS) this.gameOver('tears');
else if (quotaMet) {
// Let the last pickles splash into the jar before the poster.
this.winT += dt;
if (this.winT > 1.3) this.endDay();
}
}
// ---------- player ----------
private movePlayer(dt: number, input: InputFrame): void {
const inTank = this.tankT > 0;
const hot = this.sauceT > 0;
const max = inTank ? (hot ? 20 : 12) : hot ? 28 : 16;
const accel = inTank ? (hot ? 18 : 10) : hot ? 30 : 18;
const turn = inTank ? 1.5 : 2.6 - Math.min(1, Math.abs(this.speed) / 30);
if (input.throttle > 0.05) this.speed = Math.min(max * input.throttle, this.speed + accel * dt);
else if (input.throttle < -0.05) this.speed = Math.max(-6, this.speed - accel * 1.4 * dt);
if (input.throttle <= 0.05 || this.speed > max) {
const drag = (input.throttle < -0.05 ? 0 : 7) * dt;
if (this.speed > 0) this.speed = Math.max(0, this.speed - drag);
else if (this.speed < 0 && input.throttle >= -0.05) this.speed = Math.min(0, this.speed + drag);
}
// Turning works even at a standstill so you can always aim.
const steerDir = this.speed < -0.5 ? -1 : 1;
this.heading -= input.steer * turn * dt * steerDir;
this.px += Math.sin(this.heading) * this.speed * dt;
this.pz += Math.cos(this.heading) * this.speed * dt;
if (!inTank) this.updateJump(dt);
this.collide(
inTank ? 3.2 : 1.3,
(x, z) => {
this.px = x;
this.pz = z;
},
() => (this.speed *= 0.6),
undefined,
undefined,
!inTank && this.airborne(),
);
this.placeRider(dt);
}
private airborne(): boolean {
return this.jumpY > 0 || this.jumpVy > 0;
}
/** Gallop into a small rock and the horse leaps it; land with a thud and a puff of dust. */
private updateJump(dt: number): void {
if (this.airborne()) {
this.jumpVy -= 32 * dt;
this.jumpY += this.jumpVy * dt;
if (this.jumpY <= 0) {
this.jumpY = 0;
this.jumpVy = 0;
sfx.land();
this.addShake(0.15);
const y = groundHeight(this.px, this.pz);
this.fx.burst(this.tmp.set(this.px, y + 0.3, this.pz), 0xd9b382, 12, 5, 0.35, 0.7, 1.5, 4);
}
return;
}
if (this.speed < 4) return;
const fx = Math.sin(this.heading);
const fz = Math.cos(this.heading);
for (const c of this.world.colliders) {
if (c.jump === undefined) continue;
const dx = c.x - this.px;
const dz = c.z - this.pz;
const d = Math.hypot(dx, dz);
// Take off just before touching a rock that lies ahead.
if (d > c.r + 1.3 + 0.6 + this.speed * 0.05 || (dx * fx + dz * fz) / (d || 1) < 0.35) continue;
this.jumpVy = 9 + c.jump * 1.5;
this.jumpY = 0.001;
sfx.jump();
break;
}
}
/** Push a circle out of scenery, the jar and the arena edge. skipSmall lets a leaping horse pass over small rocks. */
private collide(
r: number,
set: (x: number, z: number) => void,
bumped?: () => void,
xIn?: number,
zIn?: number,
skipSmall = false,
): void {
let x = xIn ?? this.px;
let z = zIn ?? this.pz;
let hit = false;
for (const c of this.world.colliders) {
if (skipSmall && c.jump !== undefined) continue;
const dx = x - c.x;
const dz = z - c.z;
const min = c.r + r;
const d2 = dx * dx + dz * dz;
if (d2 < min * min && d2 > 0.0001 ** 2) {
const d = Math.sqrt(d2);
x = c.x + (dx / d) * min;
z = c.z + (dz / d) * min;
hit = true;
}
}
const dj = Math.hypot(x, z);
if (dj < JAR_RADIUS + r && dj > 0.0001) {
x = (x / dj) * (JAR_RADIUS + r);
z = (z / dj) * (JAR_RADIUS + r);
hit = true;
}
if (dj > ARENA_RADIUS) {
x = (x / dj) * ARENA_RADIUS;
z = (z / dj) * ARENA_RADIUS;
hit = true;
}
set(x, z);
if (hit) bumped?.();
}
private placeRider(dt: number): void {
const inTank = this.tankT > 0;
const y = groundHeight(this.px, this.pz);
if (inTank) {
this.tank.root.position.set(this.px, y, this.pz);
this.tank.root.rotation.y = this.heading;
this.tankBeam.visible = false;
for (const w of this.tank.wheels) w.rotation.x += this.speed * dt * 1.5;
this.tank.turret.rotation.y = Math.sin(performance.now() / 900) * 0.04;
this.tank.barrel.position.z = 2.0 - Math.max(0, this.cannonT - 0.9) * 2;
return;
}
const r = this.rider;
r.root.position.set(this.px, y + this.jumpY, this.pz);
r.root.rotation.y = this.heading;
const spd = Math.abs(this.speed);
this.gait += dt * (2 + spd * 0.65);
const amp = Math.min(0.9, spd / 14);
if (this.airborne()) {
// Flying-gallop pose: front legs reach forward, hind legs kick back; nose up, then down.
r.legs[0].rotation.x = r.legs[1].rotation.x = -0.85;
r.legs[2].rotation.x = r.legs[3].rotation.x = 0.8;
r.body.position.y = 0;
r.body.rotation.x = Math.max(-0.3, Math.min(0.3, -this.jumpVy * 0.03));
} else {
r.legs[0].rotation.x = Math.sin(this.gait) * amp;
r.legs[1].rotation.x = Math.sin(this.gait + 0.6) * amp;
r.legs[2].rotation.x = Math.sin(this.gait + Math.PI) * amp;
r.legs[3].rotation.x = Math.sin(this.gait + Math.PI + 0.6) * amp;
r.body.position.y = Math.abs(Math.sin(this.gait)) * amp * 0.35;
r.body.rotation.x = Math.sin(this.gait * 2) * amp * 0.05;
}
r.tail.rotation.x = -0.4 - amp * 0.6 + Math.sin(this.gait * 2) * 0.15;
r.head.rotation.x = Math.sin(this.gait) * amp * 0.15;
r.gun.rotation.x = -this.gunKick * 0.6;
r.flash.visible = this.gunKick > 0.75;
r.rider.rotation.z = Math.sin(this.gait) * amp * 0.05;
// Blink while invulnerable after a hit.
r.root.visible = !(this.invuln > 0 && this.mode === 'playing' && Math.floor(this.invuln * 12) % 2 === 0 && this.tears > 0);
if (this.mode === 'playing' && spd > 3 && !this.airborne()) {
const beat = Math.floor(this.gait / Math.PI);
if (beat !== this.hoofBeat) {
this.hoofBeat = beat;
sfx.hoof();
this.fx.spawn(
this.tmp.set(this.px + (Math.random() - 0.5), y + 0.2, this.pz + (Math.random() - 0.5)),
this.tmp2.set((Math.random() - 0.5) * 2, 1.5 + Math.random(), (Math.random() - 0.5) * 2),
0xd9b382,
0.7,
0.3,
2,
);
}
}
}
private playerHeadPos(out: THREE.Vector3): THREE.Vector3 {
return out.set(this.px, groundHeight(this.px, this.pz) + 3.2 + this.jumpY, this.pz);
}
private ropeAnchor(out: THREE.Vector3): THREE.Vector3 {
if (this.tankT > 0) {
const back = 3.6;
return out.set(this.px - Math.sin(this.heading) * back, groundHeight(this.px, this.pz) + 1.6, this.pz - Math.cos(this.heading) * back);
}
this.rider.ropeAnchor.getWorldPosition(out);
return out;
}
private hurt(amount: number, from: { x: number; z: number }): void {
if (this.invuln > 0) return;
if (this.tankT > 0) {
this.tankT = Math.max(0.5, this.tankT - amount * 3);
this.addShake(0.6);
return;
}
this.tearsTotal += Math.min(amount, MAX_TEARS - this.tears);
this.tears = Math.min(MAX_TEARS, this.tears + amount);
this.invuln = 1.3;
this.hud.hurtFlash();
this.addShake(0.7);
sfx.hurt();
const dx = this.px - from.x;
const dz = this.pz - from.z;
const d = Math.hypot(dx, dz) || 1;
this.px += (dx / d) * 2;
this.pz += (dz / d) * 2;
this.speed *= 0.3;
const head = this.playerHeadPos(this.tmp);
for (let i = 0; i < 10; i++) {
this.fx.spawn(head, this.tmp2.set((Math.random() - 0.5) * 6, 3 + Math.random() * 3, (Math.random() - 0.5) * 6), 0x6ec6ff, 0.9, 0.22);
}
const lines = ['*sniff*', 'Onion fumes!', 'My eyes!', 'Not the layers!', 'Who’s cutting onions?!'];
this.hud.toast(lines[Math.floor(Math.random() * lines.length)], 'bad');
}
private addShake(v: number): void {
if (this.shakeEnabled && !this.reducedMotion) this.shake = Math.min(1.2, this.shake + v);
}
private fireShotgun(): void {
if (this.reloadT > 0) return;
if (this.shells <= 0) {
sfx.empty();
return;
}
this.shells--;
if (this.shells === 0) this.reloadT = 1.0;
this.gunKick = 1;
sfx.shotgun();
this.addShake(0.25);
const muzzle = this.tmp.set(0, 0, 1.3);
this.rider.gun.localToWorld(muzzle);
// Gentle auto-aim toward the best onion in a wide cone.
let aim = this.heading;
let bestScore = Infinity;
for (const o of this.onions) {
const d = Math.hypot(o.x - this.px, o.z - this.pz);
if (d > SHOTGUN_RANGE) continue;
const da = Math.abs(angleDiff(Math.atan2(o.x - this.px, o.z - this.pz), this.heading));
if (da < 0.6 && d + da * 20 < bestScore) {
bestScore = d + da * 20;
aim = Math.atan2(o.x - this.px, o.z - this.pz);
}
}
for (let i = 0; i < 8; i++) {
const a = aim + (Math.random() - 0.5) * 0.45;
this.fx.streak(muzzle, this.tmp2.set(Math.sin(a), (Math.random() - 0.4) * 0.08, Math.cos(a)).normalize(), 70);
}
this.fx.flash(muzzle, 1.5, 0xffd166, 0.12);
for (const o of [...this.onions]) {
const d = Math.hypot(o.x - this.px, o.z - this.pz);
if (d > SHOTGUN_RANGE) continue;
const da = Math.abs(angleDiff(Math.atan2(o.x - this.px, o.z - this.pz), aim));
if (da < 0.28 + Math.atan((1.3 * this.onionScale(o.size)) / Math.max(1, d))) this.damageOnion(o, 1, aim);
}
if (this.enemy) {
const e = this.enemy;
const d = Math.hypot(e.x - this.px, e.z - this.pz);
const da = Math.abs(angleDiff(Math.atan2(e.x - this.px, e.z - this.pz), aim));
if (d < SHOTGUN_RANGE + 3 && da < 0.3 + Math.atan(3.5 / Math.max(1, d))) this.damageEnemyTank(1);
}
}
private fireCannon(): void {
if (this.cannonT > 0) return;
this.cannonT = 1.1;
sfx.cannon();
this.addShake(0.6);
const muzzle = new THREE.Vector3();
this.tank.muzzle.getWorldPosition(muzzle);
this.fx.flash(muzzle, 3, 0xffb703, 0.2);
this.fx.burst(muzzle, 0xbbbbbb, 10, 6, 0.5, 0.8, 2);
const vel = new THREE.Vector3(Math.sin(this.heading) * 55, -0.5, Math.cos(this.heading) * 55);
this.addShell(muzzle, vel, false, null);
}
private addShell(pos: THREE.Vector3, vel: THREE.Vector3, enemy: boolean, target: THREE.Vector3 | null): void {
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(enemy ? 0.5 : 0.35, 0),
new THREE.MeshBasicMaterial({ color: enemy ? 0xc77dff : 0xffe066 }),
);
mesh.position.copy(pos);
this.scene.add(mesh);
let marker: THREE.Mesh | null = null;
if (target) {
marker = new THREE.Mesh(
new THREE.RingGeometry(3.2, 4, 16),
new THREE.MeshBasicMaterial({ color: 0xff3b30, transparent: true, opacity: 0.7, side: THREE.DoubleSide, depthWrite: false }),
);
marker.rotation.x = -Math.PI / 2;
marker.position.set(target.x, groundHeight(target.x, target.z) + 0.15, target.z);
this.scene.add(marker);
}
this.shellsInAir.push({ mesh, pos: pos.clone(), vel, enemy, marker });
}
private enterTank(): void {
this.tankT = TANK_TIME;
this.tankParked = false;
this.px = this.tankX;
this.pz = this.tankZ;
this.heading = this.tankHeading;
this.speed = 0;
this.jumpY = this.jumpVy = 0;
this.rider.root.visible = false;
sfx.mount();
this.hud.toast('You commandeered an M1A1 Abrams!', 'good');
}
private exitTank(announce: boolean): void {
if (this.tankT <= 0 && !this.tank.root.visible) return;
const wasDriving = !this.tankParked && this.tank.root.visible;
this.tankT = 0;
if (!wasDriving) return;
this.tank.root.visible = false;
this.rider.root.visible = true;
const p = this.tmp.set(this.px, groundHeight(this.px, this.pz) + 2, this.pz);
this.fx.burst(p, 0x999999, 24, 10, 0.7, 1.2, 4);
this.fx.flash(p, 6, 0xffffff, 0.3);
if (announce) {
sfx.boom();
this.hud.toast('Out of gas! Back on the horse.', 'info');
}
this.invuln = 1.5;
}
// ---------- lasso & ropes ----------
private throwLasso(): void {
if (this.lasso || this.lassoCd > 0) return;
if (this.roped.length >= MAX_ROPES) {
this.hud.toast('Ropes full. Head to the jar!', 'info');
return;
}
const target = this.lassoTarget();
const from = this.playerHeadPos(new THREE.Vector3());
const to = target
? new THREE.Vector3(target.x, groundHeight(target.x, target.z) + 1.2, target.z)
: new THREE.Vector3(this.px + Math.sin(this.heading) * 12, groundHeight(this.px, this.pz) + 0.3, this.pz + Math.cos(this.heading) * 12);
const mesh = new THREE.Mesh(new THREE.TorusGeometry(0.9, 0.07, 4, 10), new THREE.MeshLambertMaterial({ color: palette.rope, flatShading: true }));
mesh.rotation.x = Math.PI / 2;
this.scene.add(mesh);
const dur = Math.max(0.18, from.distanceTo(to) / 45);
this.lasso = { mesh, t: 0, dur, from, to, target };
this.lassoCd = 0.35;
sfx.lasso();
}
/** The pickle a lasso throw would grab right now: close, in front, stolen ones first. */
private lassoTarget(): Pickle | null {
let target: Pickle | null = null;
let bestScore = Infinity;
for (const p of this.pickles) {
if (p.state !== 'free' && p.state !== 'carried') continue;
const d = Math.hypot(p.x - this.px, p.z - this.pz);
if (d > LASSO_RANGE) continue;
const da = Math.abs(angleDiff(Math.atan2(p.x - this.px, p.z - this.pz), this.heading));
if (da > 1.25 && d > 5) continue;
const s = d + da * 8 - (p.state === 'carried' ? 4 : 0);
if (s < bestScore) {
bestScore = s;
target = p;
}
}
return target;
}
/** Target ring under the next lasso pickle, and an arrow to the jar while towing. */
private updateGuides(): void {
const t = performance.now() / 1000;
const target = !this.lasso && this.roped.length < MAX_ROPES ? this.lassoTarget() : null;
this.targetRing.visible = !!target;
if (target) {
this.targetRing.position.set(target.x, groundHeight(target.x, target.z) + 0.12, target.z);
this.targetRing.scale.setScalar(1 + Math.sin(t * 8) * 0.12);
}
const jarDist = Math.hypot(this.px, this.pz);
this.guideArrow.visible = this.roped.length > 0 && jarDist > 18;
if (this.guideArrow.visible) {
const lift = this.tankT > 0 ? 7 : 5.4;
this.guideArrow.position.set(this.px, groundHeight(this.px, this.pz) + lift + Math.sin(t * 4) * 0.25, this.pz);
this.guideArrow.rotation.y = Math.atan2(-this.px, -this.pz);
}
}
/** Dust storm: grit streaks past on the wind around the rider. */
private blowDust(): void {
for (let i = 0; i < 2; i++) {
const x = this.px + (Math.random() - 0.5) * 40;
const z = this.pz + (Math.random() - 0.5) * 40;
this.fx.spawn(
this.tmp.set(x, groundHeight(x, z) + 0.3 + Math.random() * 4, z),
this.tmp2.set(14 + Math.random() * 6, Math.random() - 0.3, 5 + Math.random() * 3),
Math.random() < 0.5 ? 0xc9a46a : 0xe3c896,
1.3,
0.22,
0,
);
}
}
private updateLasso(dt: number): void {
const l = this.lasso;
if (l) {
l.t += dt;
if (l.target && (l.target.state === 'free' || l.target.state === 'carried')) {
l.to.set(l.target.x, groundHeight(l.target.x, l.target.z) + 1.2, l.target.z);
}
const k = Math.min(1, l.t / l.dur);
const pos = this.tmp.copy(l.from).lerp(l.to, k);
pos.y += Math.sin(k * Math.PI) * 2.5;
l.mesh.position.copy(pos);
l.mesh.rotation.z += dt * 18;
this.drawRope(MAX_ROPES, this.playerHeadPos(this.tmp2), pos, 0.3);
if (k >= 1) {
this.retireMesh(l.mesh);
this.lasso = null;
this.hideRope(MAX_ROPES);
const p = l.target;
if (p && (p.state === 'free' || p.state === 'carried') && this.roped.length < MAX_ROPES) {
if (p.carrier) {
const o = p.carrier;
o.carrying = null;
o.knock = 0.5;
this.hud.toast('Yoinked it back!', 'good');
}
p.carrier = null;
p.state = 'roped';
this.roped.push(p);
sfx.roped();
} else {
sfx.miss();
}
}
}
const anchor = this.ropeAnchor(this.ropeStart);
for (let i = 0; i < MAX_ROPES; i++) {
const p = this.roped[i];
if (!p) {
this.hideRope(i);
continue;
}
this.drawRope(i, anchor, this.tmp.set(p.x, groundHeight(p.x, p.z) + 1.5 + p.launchY, p.z), 0.6);
}
}
private drawRope(index: number, a: THREE.Vector3, b: THREE.Vector3, sag: number): void {
const segs = this.ropeSegs[index];
const n = segs.length;
const dist = a.distanceTo(b);
const prev = this.ropePrev.copy(a);
const cur = this.ropeCur;
for (let i = 0; i < n; i++) {
const t = (i + 1) / n;
cur.copy(a).lerp(b, t);
cur.y -= Math.sin(t * Math.PI) * sag * Math.min(2, dist * 0.12);
const seg = segs[i];
const dir = this.tmp2.copy(cur).sub(prev);
const len = dir.length();
seg.position.copy(prev);
seg.scale.set(1, len, 1);
if (len > 0.0001) seg.quaternion.setFromUnitVectors(this.up, dir.multiplyScalar(1 / len));
seg.visible = true;
prev.copy(cur);
}
}
private hideRope(index: number): void {
for (const s of this.ropeSegs[index]) s.visible = false;
}
// ---------- pickles ----------
private updatePickles(dt: number): void {
const anchor = this.ropeAnchor(this.ropeStart);
for (let i = this.pickles.length - 1; i >= 0; i--) {
const p = this.pickles[i];
if (p.state === 'free') this.updateFreePickle(p, dt);
else if (p.state === 'roped') {
const idx = this.roped.indexOf(p);
const len = 4.5 + idx * 1.6;
const dx = p.x - anchor.x;
const dz = p.z - anchor.z;
const d = Math.hypot(dx, dz) || 1;
if (d > len) {
p.x = anchor.x + (dx / d) * len;
p.z = anchor.z + (dz / d) * len;
}
// Keep towed pickles from stacking on each other.
for (const q of this.roped) {
if (q === p) continue;
const ex = p.x - q.x;
const ez = p.z - q.z;
const e = Math.hypot(ex, ez);
if (e < 1.4 && e > 0.001) {
p.x += (ex / e) * (1.4 - e) * 0.5;
p.z += (ez / e) * (1.4 - e) * 0.5;
}
}
this.collide(0.6, (x, z) => ((p.x = x), (p.z = z)), undefined, p.x, p.z);
p.heading = Math.atan2(anchor.x - p.x, anchor.z - p.z);
p.hop += dt * (4 + Math.abs(this.speed));
this.updateLaunch(p, dt);
const y = groundHeight(p.x, p.z) + p.launchY + Math.abs(Math.sin(p.hop)) * 0.3;
p.m.root.position.set(p.x, y, p.z);
p.m.root.rotation.set(0, p.heading, 0);
p.m.body.rotation.x = Math.min(1.2, 0.3 + Math.abs(this.speed) * 0.05); // leaning into the drag
p.m.body.rotation.z = Math.sin(p.hop * 0.5) * 0.25;
} else if (p.state === 'carried' && p.carrier) {
const o = p.carrier;
const s = this.onionScale(o.size);
p.x = o.x;
p.z = o.z;
p.m.root.position.set(o.x, groundHeight(o.x, o.z) + 2.3 * s + 0.6, o.z);
p.m.root.rotation.set(0, o.heading + Math.PI / 2, 0);
p.m.body.rotation.set(0, 0, Math.PI / 2 + Math.sin(performance.now() / 90) * 0.3);
} else if (p.state === 'flying' && p.fly) {
p.fly.t += dt / 0.7;
const k = Math.min(1, p.fly.t);
const pos = this.tmp.copy(p.fly.from).lerp(p.fly.to, k);
pos.y += Math.sin(k * Math.PI) * 9;
p.m.root.position.copy(pos);
p.m.body.rotation.x += dt * 12;
if (k >= 1) {
this.scene.remove(p.m.root);
this.pickles.splice(i, 1);
this.addFloater(p.golden);
this.fx.burst(p.fly.to, palette.brine, 14, 6, 0.3, 0.8, 6);
}
}
}
}
private updateLaunch(p: Pickle, dt: number): void {
if (p.launchY > 0 || p.launchVy > 0) {
p.launchVy -= 30 * dt;
p.launchY = Math.max(0, p.launchY + p.launchVy * dt);
if (p.launchY === 0) p.launchVy = 0;
}
}
private updateFreePickle(p: Pickle, dt: number): void {
const dx = p.x - this.px;
const dz = p.z - this.pz;
const d = Math.hypot(dx, dz);
let speed = 1.6;
let dir: number;
if (d < 14 && this.mode === 'playing') {
dir = Math.atan2(dx, dz) + Math.sin(performance.now() / 400 + p.wanderA) * 0.6;
speed = p.golden ? 7.5 : 5.5;
} else {
p.wanderT -= dt;
if (p.wanderT <= 0) {
p.wanderT = 2 + Math.random() * 3;
p.wanderA = Math.random() * Math.PI * 2;
}
dir = p.wanderA;
}
// Stay in the valley and out of the jar area.
const r = Math.hypot(p.x, p.z);
if (r > ARENA_RADIUS - 14) dir = Math.atan2(-p.x, -p.z);
else if (r < JAR_RADIUS + 10) dir = Math.atan2(p.x, p.z);
p.heading += angleDiff(dir, p.heading) * Math.min(1, dt * 5);
p.x += Math.sin(p.heading) * speed * dt;
p.z += Math.cos(p.heading) * speed * dt;
this.collide(0.6, (x, z) => ((p.x = x), (p.z = z)), () => (p.wanderA += 1.5), p.x, p.z);
p.hop += dt * (3 + speed * 1.6);
this.updateLaunch(p, dt);
const y = groundHeight(p.x, p.z) + p.launchY + Math.abs(Math.sin(p.hop)) * (0.25 + speed * 0.09);
p.m.root.position.set(p.x, y, p.z);
p.m.root.rotation.set(0, p.heading, 0);
p.m.body.rotation.set(Math.sin(p.hop * 2) * 0.08, 0, Math.sin(p.hop) * 0.12);
}
private addFloater(golden: boolean): void {
if (this.jar.floaters.children.length >= 36) return;
const m = buildPickle(golden);
m.root.scale.setScalar(0.85);
const a = Math.random() * Math.PI * 2;
const r = Math.random() * (JAR_RADIUS - 1.8);
m.root.position.set(Math.cos(a) * r, 0, Math.sin(a) * r);
m.root.rotation.set(Math.random() * 0.6, Math.random() * 6, Math.random() * 0.6);
m.root.userData.phase = Math.random() * 6;
this.jar.floaters.add(m.root);
}
private checkJar(): void {
if (this.roped.length === 0) return;
if (Math.hypot(this.px, this.pz) > JAR_RADIUS + (this.tankT > 0 ? 7 : 5)) return;
let gained = 0;
let golden = 0;
this.roped.forEach((p, i) => {
p.state = 'flying';
const a = Math.random() * Math.PI * 2;
p.fly = {
t: -i * 0.15,
from: p.m.root.position.clone(),
to: new THREE.Vector3(Math.cos(a) * 2.5, this.brineTop() + 1, Math.sin(a) * 2.5),
};
const value = p.golden ? 3 : 1;
gained += value;
if (p.golden) golden++;
this.score += p.golden ? 300 : 100;
sfx.plop(i);
});
this.brinedTotal += this.roped.length;
this.roped = [];
this.brinedToday += gained;
this.brinedRun += gained;
if (golden) this.tears = Math.max(0, this.tears - golden);
const cheers = ['YEEHAW!', 'Brined!', 'Into the jar!', 'Dill-ightful!', 'Pickle party!'];
this.hud.toast(
`${cheers[Math.floor(Math.random() * cheers.length)]} +${gained}${golden ? ' (golden dill heals a tear)' : ''}`,
'good',
);
}
private brineTop(): number {
return 1 + Math.min(8, this.brinedTotal * 0.22);
}
// ---------- onions ----------
private onionScale(size: number): number {
return [0, 0.6, 0.85, 1.15, 2.6][size] ?? 1;
}
private updateOnions(dt: number): void {
const inTank = this.tankT > 0;
for (let i = this.onions.length - 1; i >= 0; i--) {
const o = this.onions[i];
if (!this.onions.includes(o)) continue;
o.spawnT = Math.max(0, o.spawnT - dt);
o.grace = Math.max(0, o.grace - dt);
o.burnCd = Math.max(0, o.burnCd - dt);
o.thinkT -= dt;
const s = this.onionScale(o.size);
if (o.carrying) {
// Run for the hills with the loot.
const r = Math.hypot(o.x, o.z) || 1;
o.tx = (o.x / r) * (ARENA_RADIUS + 5);
o.tz = (o.z / r) * (ARENA_RADIUS + 5);
if (r > ARENA_RADIUS + 2) {
const p = o.carrying;
this.removePickle(p);
this.removeOnion(o);
this.hud.toast('An onion rustled a pickle!', 'bad');
sfx.stolen();
continue;
}
} else if (o.king) {
// The King hunts you personally and calls in guards.
o.tx = this.px;
o.tz = this.pz;
o.summonT -= dt;
if (o.summonT <= 0) {
o.summonT = 11;
this.summonGuards(o, 2);
}
} else if (o.thinkT <= 0) {
o.thinkT = 0.4 + Math.random() * 0.3;
let best = Math.hypot(this.px - o.x, this.pz - o.z) * 1.3;
o.tx = this.px;
o.tz = this.pz;
for (const p of this.pickles) {
if (p.state !== 'free' && p.state !== 'roped') continue;
const d = Math.hypot(p.x - o.x, p.z - o.z) * (p.state === 'roped' ? 0.7 : 1);
if (d < best) {
best = d;
o.tx = p.x;
o.tz = p.z;
}
}
}
const speed = o.king ? 4.2 + this.level.onionSpeed * 0.4 : ([0, 8.5, 6.5, 5][o.size] ?? 5) + this.level.onionSpeed;
const want = Math.atan2(o.tx - o.x, o.tz - o.z);
o.heading += angleDiff(want, o.heading) * Math.min(1, dt * 4);
if (o.knock > 0) {
o.knock -= dt;
o.x += o.kx * dt;
o.z += o.kz * dt;
o.kx *= 0.9;
o.kz *= 0.9;
} else {
const pace = o.carrying ? 0.6 : 1;
o.x += Math.sin(o.heading) * speed * pace * dt;
o.z += Math.cos(o.heading) * speed * pace * dt;
}
o.walk += dt * speed * 1.4;
// Onions jostle each other instead of stacking.
for (const q of this.onions) {
if (q === o) continue;
const dx = o.x - q.x;
const dz = o.z - q.z;
const min = (s + this.onionScale(q.size)) * 0.85;
const d2 = dx * dx + dz * dz;
if (d2 < min * min && d2 > 0.001 ** 2) {
const d = Math.sqrt(d2);
o.x += (dx / d) * (min - d) * 0.5;
o.z += (dz / d) * (min - d) * 0.5;
}
}
if (!o.carrying) this.collide(s * 0.8, (x, z) => ((o.x = x), (o.z = z)), undefined, o.x, o.z);
else {
// Escaping onions may leave the arena; only avoid scenery.
const r = Math.hypot(o.x, o.z);
if (r < ARENA_RADIUS - 2) this.collide(s * 0.8, (x, z) => ((o.x = x), (o.z = z)), undefined, o.x, o.z);
}
this.placeOnion(o);
// Fire trail from hot sauce.
if (o.burnCd <= 0) {
for (const f of this.fx.flames) {
if (Math.hypot(f.x - o.x, f.z - o.z) < 1.2 + s * 0.6) {
o.burnCd = 0.7;
this.damageOnion(o, 1, o.heading + Math.PI);
break;
}
}
if (!this.onions.includes(o)) continue;
}
// Contacts.
const dPlayer = Math.hypot(this.px - o.x, this.pz - o.z);
if (o.king) {
if (dPlayer < (inTank ? 3.4 : 1.5) + s * 0.6) {
const a = Math.atan2(o.x - this.px, o.z - this.pz);
if ((inTank || this.sauceT > 0) && o.burnCd <= 0) {
o.burnCd = 0.8;
this.damageOnion(o, 2, a);
} else if (!inTank && this.sauceT <= 0) this.hurt(2, o);
o.knock = 0.4;
o.kx = Math.sin(a) * 8;
o.kz = Math.cos(a) * 8;
}
continue;
}
if (inTank && dPlayer < 3.4 + s * 0.6) {
this.damageOnion(o, 3, this.heading);
this.hud.toast('SQUISH!', 'good');
continue;
}
if (!inTank && dPlayer < 1.5 + s * 0.6 && o.spawnT <= 0) {
if (this.sauceT > 0) {
this.damageOnion(o, 1, Math.atan2(o.x - this.px, o.z - this.pz));
continue;
}
this.hurt(1, o);
const a = Math.atan2(o.x - this.px, o.z - this.pz);
o.knock = 0.4;
o.kx = Math.sin(a) * 14;
o.kz = Math.cos(a) * 14;
}
if (!o.carrying && o.knock <= 0 && o.grace <= 0) {
for (const p of this.pickles) {
if (p.state !== 'free' && p.state !== 'roped') continue;
if (Math.hypot(p.x - o.x, p.z - o.z) > 1.2 + s * 0.6) continue;
if (p.state === 'roped') {
this.roped.splice(this.roped.indexOf(p), 1);
this.hud.toast('Rope snapped! Onion’s got your pickle!', 'bad');
} else this.hud.toast('Pickle snatched!', 'bad');
sfx.stolen();
p.state = 'carried';
p.carrier = o;
o.carrying = p;
break;
}
}
}
}
private placeOnion(o: Onion): void {
const s = this.onionScale(o.size) * (1 - o.spawnT * 1.5);
o.m.root.position.set(o.x, groundHeight(o.x, o.z), o.z);
o.m.root.rotation.y = o.heading;
o.m.root.scale.setScalar(Math.max(0.1, s));
o.m.body.rotation.z = Math.sin(o.walk) * 0.18;
o.m.body.position.y = Math.abs(Math.sin(o.walk)) * 0.25;
o.m.hat.rotation.z = 0.15 + Math.sin(o.walk * 0.5) * 0.1;
}
private damageOnion(o: Onion, layers: number, fromAngle: number): void {
if (!this.onions.includes(o)) return;
const pos = this.tmp.set(o.x, groundHeight(o.x, o.z) + this.onionScale(o.size), o.z);
if (o.carrying) {
const p = o.carrying;
p.state = 'free';
p.carrier = null;
p.launchVy = 9;
p.launchY = 0.01;
o.carrying = null;
}
if (o.king) {
const before = o.hp;
o.hp = Math.max(0, o.hp - layers);
this.score += 25 * (before - o.hp);
this.onionsToday += before - o.hp;
sfx.peel();
this.fx.burst(pos, palette.onionLight, 14, 10, 0.5, 0.9);
if (o.hp <= 0) this.killKing(o);
else if (Math.floor(before / 10) > Math.floor(o.hp / 10)) {
sfx.kingRoar();
this.summonGuards(o, 3);
}
return;
}
if (layers >= o.size) {
this.score += 25 * o.size;
this.onionsToday += o.size;
this.fx.burst(pos, palette.onion, 16, 9, 0.35, 0.9);
this.fx.burst(pos, palette.onionLight, 10, 7, 0.3, 0.9);
this.fx.burst(pos, 0x6ec6ff, 6, 5, 0.18, 0.7); // onion tears
this.fx.flash(pos, 2.5, 0xffffff, 0.2);
sfx.pop();
this.removeOnion(o);
return;
}
// Peel a layer and split in two.
this.score += 25 * layers;
this.onionsToday += layers;
o.size -= layers;
sfx.peel();
this.fx.burst(pos, palette.onionLight, 12, 8, 0.4, 0.8);
const side = fromAngle + Math.PI / 2;
const twin = this.spawnOnion(o.size, o.x - Math.sin(side) * 0.8, o.z - Math.cos(side) * 0.8);
twin.spawnT = 0;
twin.heading = o.heading;
o.x += Math.sin(side) * 0.8;
o.z += Math.cos(side) * 0.8;
for (const [onion, dir] of [
[o, side],
[twin, side + Math.PI],
] as const) {
onion.knock = 0.35;
onion.kx = Math.sin(dir) * 9 + Math.sin(fromAngle) * 6;
onion.kz = Math.cos(dir) * 9 + Math.cos(fromAngle) * 6;
}
}
private removeOnion(o: Onion): void {
const i = this.onions.indexOf(o);
if (i >= 0) this.onions.splice(i, 1);
this.scene.remove(o.m.root);
}
private removePickle(p: Pickle): void {
const i = this.pickles.indexOf(p);
if (i >= 0) this.pickles.splice(i, 1);
const r = this.roped.indexOf(p);
if (r >= 0) this.roped.splice(r, 1);
this.scene.remove(p.m.root);
}
// ---------- enemy tank ----------
private updateEnemyTank(dt: number): void {
if (!this.enemySpawned && this.level.enemyTank && this.timeLeft < this.dayLength - 25) this.spawnEnemyTank();
const e = this.enemy;
if (!e) return;
const dx = this.px - e.x;
const dz = this.pz - e.z;
const d = Math.hypot(dx, dz);
const toPlayer = Math.atan2(dx, dz);
let drive = 0;
if (d > 30) drive = 5;
else if (d < 16) drive = -3;
e.heading += angleDiff(toPlayer, e.heading) * Math.min(1, dt * 0.7);
e.x += Math.sin(e.heading) * drive * dt;
e.z += Math.cos(e.heading) * drive * dt;
this.collide(3.2, (x, z) => ((e.x = x), (e.z = z)), undefined, e.x, e.z);
// Don't let the two tanks overlap.
const gap = Math.hypot(this.px - e.x, this.pz - e.z);
const min = this.tankT > 0 ? 6.5 : 4;
if (gap < min && gap > 0.01) {
e.x -= ((this.px - e.x) / gap) * (min - gap);
e.z -= ((this.pz - e.z) / gap) * (min - gap);
}
e.turret += angleDiff(toPlayer - e.heading, e.turret) * Math.min(1, dt * 2);
e.fireT -= dt;
if (e.fireT <= 0 && d < 60) {
e.fireT = Math.max(2.4, 4.2 - Math.max(0, this.day - 3) * 0.3);
const muzzle = new THREE.Vector3();
e.m.muzzle.getWorldPosition(muzzle);
// Lob a shell at where the player is heading, with a warning ring.
const lead = Math.min(1.2, d / 30);
const target = new THREE.Vector3(
this.px + Math.sin(this.heading) * this.speed * lead * 0.6,
0,
this.pz + Math.cos(this.heading) * this.speed * lead * 0.6,
);
target.y = groundHeight(target.x, target.z);
const T = 1.1 + d / 40;
const vel = new THREE.Vector3((target.x - muzzle.x) / T, 0, (target.z - muzzle.z) / T);
vel.y = (target.y - muzzle.y + 0.5 * 18 * T * T) / T;
this.addShell(muzzle, vel, true, target);
this.fx.flash(muzzle, 2.5, 0xc77dff, 0.2);
sfx.cannon();
}
e.hitFlash = Math.max(0, e.hitFlash - dt);
e.m.root.position.set(e.x, groundHeight(e.x, e.z), e.z);
e.m.root.rotation.y = e.heading;
e.m.turret.rotation.y = e.turret;
for (const w of e.m.wheels) w.rotation.x += drive * dt * 1.5;
e.m.root.position.y += e.hitFlash > 0 ? Math.sin(e.hitFlash * 60) * 0.1 : 0;
}
private damageEnemyTank(amount: number): void {
const e = this.enemy;
if (!e) return;
e.hp -= amount;
e.hitFlash = 0.25;
const pos = this.tmp.set(e.x, groundHeight(e.x, e.z) + 2.5, e.z);
this.fx.burst(pos, 0xffd166, 6, 8, 0.25, 0.4);
if (e.hp <= 0) {
this.fx.flash(pos, 12, 0xff9f1c, 0.7);
this.fx.burst(pos, palette.enemyTank, 30, 16, 0.8, 1.6, 10);
this.fx.burst(pos, 0x333333, 20, 10, 1, 2, 6);
sfx.boom();
this.addShake(1.2);
this.score += 500;
this.enemy = null;
e.m.root.visible = false;
this.hud.toast('Onion tank DESTROYED! +500', 'good');
}
}
// ---------- shells ----------
private updateShells(dt: number): void {
for (let i = this.shellsInAir.length - 1; i >= 0; i--) {
const s = this.shellsInAir[i];
s.vel.y -= (s.enemy ? 18 : 9) * dt;
s.pos.addScaledVector(s.vel, dt);
s.mesh.position.copy(s.pos);
if (s.marker) {
const mm = s.marker.material as THREE.MeshBasicMaterial;
mm.opacity = 0.4 + Math.abs(Math.sin(performance.now() / 80)) * 0.5;
}
let explode = s.pos.y <= groundHeight(s.pos.x, s.pos.z) + 0.2 || Math.hypot(s.pos.x, s.pos.z) > ARENA_RADIUS + 20;
if (!s.enemy && !explode) {
for (const o of this.onions) {
const near = Math.hypot(o.x - s.pos.x, o.z - s.pos.z) < 1.5 + this.onionScale(o.size);
if (near && s.pos.y < groundHeight(o.x, o.z) + 3.5) explode = true;
}
if (this.enemy && Math.hypot(this.enemy.x - s.pos.x, this.enemy.z - s.pos.z) < 3.5 && s.pos.y < groundHeight(s.pos.x, s.pos.z) + 4)
explode = true;
}
if (!explode) continue;
this.retireMesh(s.mesh);
if (s.marker) this.retireMesh(s.marker);
this.shellsInAir.splice(i, 1);
this.explode(s.pos, s.enemy);
}
}
private explode(pos: THREE.Vector3, enemy: boolean): void {
const radius = 7;
this.fx.flash(pos, radius, enemy ? 0xc77dff : 0xff9f1c, 0.5);
this.fx.burst(pos, 0x7a5a3a, 18, 12, 0.5, 1.2, 8);
this.fx.burst(pos, enemy ? 0xc77dff : 0xffb703, 14, 10, 0.4, 0.8, 4);
sfx.boom();
this.addShake(enemy ? 0.5 : 0.8);
if (enemy) {
if (Math.hypot(this.px - pos.x, this.pz - pos.z) < 4.5) this.hurt(2, pos);
} else {
for (const o of [...this.onions]) {
if (Math.hypot(o.x - pos.x, o.z - pos.z) < radius) this.damageOnion(o, 3, Math.atan2(o.x - pos.x, o.z - pos.z));
}
if (this.enemy && Math.hypot(this.enemy.x - pos.x, this.enemy.z - pos.z) < radius) this.damageEnemyTank(4);
}
// Pickles caught in a blast get launched, unharmed.
for (const p of this.pickles) {
if ((p.state === 'free' || p.state === 'roped') && Math.hypot(p.x - pos.x, p.z - pos.z) < radius) {
p.launchVy = 14;
p.launchY = 0.01;
}
}
}
// ---------- pickups & spawning ----------
private updatePickups(dt: number): void {
if (this.tankParked && this.tankT <= 0) {
const y = groundHeight(this.tankX, this.tankZ);
this.tank.root.position.set(this.tankX, y, this.tankZ);
this.tank.root.rotation.y = this.tankHeading;
this.tankBeam.position.set(this.tankX, y, this.tankZ);
if (Math.hypot(this.px - this.tankX, this.pz - this.tankZ) < 4.8) this.enterTank();
}
const inTank = this.tankT > 0;
for (let i = this.sauces.length - 1; i >= 0; i--) {
const s = this.sauces[i];
if (Math.hypot(this.px - s.x, this.pz - s.z) < (inTank ? 4.8 : 2.6)) {
this.scene.remove(s.mesh);
this.sauces.splice(i, 1);
this.sauceT = SAUCE_TIME;
this.sauceRespawn = 22;
sfx.sauce();
this.fx.flash(this.tmp.set(this.px, groundHeight(this.px, this.pz) + 2, this.pz), inTank ? 7 : 4, 0xff3d00, 0.35);
this.hud.toast(
inTank ? 'HOT SAUCE in the fuel tank! Flamethrower online!' : 'HOT SAUCE! The horse is on fire (it’s fine)',
'good',
);
}
}
if (this.sauces.length < 2 && this.sauceRespawn > 0) {
this.sauceRespawn -= dt;
if (this.sauceRespawn <= 0) this.spawnSauce();
}
}
private spawnLogic(dt: number): void {
this.spawnT -= dt;
const maxOnions = this.level.onionCap;
if (this.spawnT <= 0) {
this.spawnT = this.level.spawnEvery * (0.7 + Math.random() * 0.6);
if (this.onions.length < maxOnions) this.spawnOnion(this.randomOnionSize());
}
// Keep enough pickles around to make quota possible.
let live = 0;
for (const p of this.pickles) if (p.state !== 'flying') live++;
const needed = Math.max(8, this.quota - this.brinedToday + 4);
if (live < needed) this.spawnPickle(Math.random() < this.level.goldenChance);
}
// ---------- presentation ----------
private animateProps(dt: number): void {
const t = performance.now() / 1000;
for (const s of this.sauces) {
s.mesh.rotation.y += dt * 2;
s.mesh.children[0].position.y = 1.2 + Math.sin(t * 3 + s.x) * 0.25;
}
const top = this.brineTop();
this.jar.brine.scale.y += (top - this.jar.brine.scale.y) * Math.min(1, dt * 2);
this.jar.brine.position.y = this.jar.brine.scale.y / 2;
for (const f of this.jar.floaters.children) {
const ph = f.userData.phase as number;
f.position.y = Math.max(0.4, this.jar.brine.scale.y - 1.6 + Math.sin(t * 1.5 + ph) * 0.25 - (ph % 1.5));
f.rotation.y += dt * 0.2;
}
const beamOn = this.mode === 'playing' && this.roped.length > 0;
this.jarBeam.visible = beamOn;
(this.jarBeam.material as THREE.MeshBasicMaterial).opacity = 0.18 + Math.abs(Math.sin(t * 3)) * 0.15;
(this.tankBeam.material as THREE.MeshBasicMaterial).opacity = 0.15 + Math.abs(Math.sin(t * 2)) * 0.12;
this.tankBeam.visible = this.tankParked && this.tank.root.visible;
if (this.tankParked && this.mode === 'title') {
this.tank.root.position.set(this.tankX, groundHeight(this.tankX, this.tankZ), this.tankZ);
this.tank.root.rotation.y = this.tankHeading;
this.tankBeam.position.copy(this.tank.root.position);
}
}
private updateCamera(dt: number): void {
const portrait = this.portrait;
const inTank = this.tankT > 0;
let dist = inTank ? 17 : 12.5;
let height = inTank ? 10 : 6.5;
if (portrait) {
dist *= 1.3;
height *= 1.55;
}
const fx = Math.sin(this.heading);
const fz = Math.cos(this.heading);
const y = groundHeight(this.px, this.pz);
let target: THREE.Vector3;
let look: THREE.Vector3;
if (this.mode === 'title') {
const a = this.attractT * 0.08;
target = this.tmp.set(Math.cos(a) * 42, 20, Math.sin(a) * 42);
look = this.tmp2.set(0, 4, 0);
} else if (this.mode === 'victory') {
const a = this.attractT * 0.12 + 0.8;
target = this.tmp.set(Math.cos(a) * 30, 10, Math.sin(a) * 30);
const offset = this.camera.aspect > 1.3 ? 9 : 0;
look = this.tmp2.set(-Math.sin(a) * offset, 10, Math.cos(a) * offset);
} else {
target = this.tmp.set(this.px - fx * dist, y + height, this.pz - fz * dist);
look = this.tmp2.set(this.px + fx * 5, y + 1.8, this.pz + fz * 5);
}
const orbit = this.mode === 'title' || this.mode === 'victory';
const k = 1 - Math.exp(-dt * (orbit ? 1.5 : 4.5));
this.camPos.lerp(target, k);
this.camLook.lerp(look, k);
// Never look through the jar glass: if the chase camera ends up over the jar, lift it above the rim.
if (Math.hypot(this.camPos.x, this.camPos.z) < JAR_RADIUS + 2) this.camPos.y = Math.max(this.camPos.y, 13);
const minY = groundHeight(this.camPos.x, this.camPos.z) + 2;
if (this.camPos.y < minY) this.camPos.y = minY;
this.camera.position.copy(this.camPos);
if (this.shake > 0) {
this.camera.position.x += (Math.random() - 0.5) * this.shake;
this.camera.position.y += (Math.random() - 0.5) * this.shake;
this.shake = Math.max(0, this.shake - dt * 3);
}
this.camera.lookAt(this.camLook);
}
}
