Add level editor and retune physics/camera for bouncier, floatier driving

- New browser level editor (editor.html + src/editor/): assemble levels
  from snap-together section generators (flat/inclines/hills/ditch/jump)
  with a live canvas preview, metadata form, and export to ready-to-drop
  levelNN.js source; example export registered as level04.js.
- Add parallax background sprites (bg_far/mid/near).
- Bus retune: softer, bouncier suspension (stiffness 0.45->0.35, damping
  0.08->0.035, travel 12->30, restitution 0.3->0.45) with wheel rest
  point raised 22->34 for a bouncier stance; drive force cut ~60%
  (0.011->0.0045) for a much longer runway and lower top speed.
- Gravity 1 -> 0.675 for ~1.5x hang time off jumps; constraintIterations
  2 -> 10 so the soft wishbone springs converge within one step.
- Bus: clamp each wheel to the underside of its wishbone anchor line on
  every matter afterupdate, fixing wheels punching through to the
  mirrored solution and sticking high on the chassis after hard impacts;
  add no-op destroy() for the world listener.
- CameraRig: speed-driven horizontal lookahead (bus framed ~10% from the
  left edge at rest, out to 1/3 of the screen at speed) via followOffset;
  PlayScene scales the g-force grace period to 5.5s for the new gravity
  and calls the new update()/destroy() hooks.
- Extend level01's terrain runway by 30x.
This commit is contained in:
Brian Fertig 2026-08-19 20:07:24 -06:00
parent ffe29468d6
commit 4973b22f7c
16 changed files with 1062 additions and 23 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

191
editor.html Normal file
View File

@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Monsterplex Level Editor</title>
<link rel="icon" href="assets/favicon.png">
<style>
html, body {
margin: 0;
padding: 0;
background: #0b0e14;
color: #e8eef7;
font-family: monospace;
}
body {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 22px;
color: #ffffff;
margin: 0 0 4px;
}
p.subtitle {
color: #9aa7bb;
margin: 0 0 20px;
font-size: 13px;
}
section {
background: #131826;
border: 1px solid #232c40;
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 16px;
}
h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9aa7bb;
margin: 0 0 10px;
}
#palette {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.palette-btn {
background: #2255aa;
color: #ffffff;
border: 1px solid #1a1f29;
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 13px;
cursor: pointer;
}
.palette-btn:hover {
background: #2f6ac0;
}
#track {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 40px;
margin-bottom: 10px;
}
.track-item {
background: #1a2236;
border: 1px solid #2c374f;
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
color: #c8d3e0;
}
.track-item.selected {
background: #2255aa;
border-color: #4a8fe0;
color: #ffffff;
}
.track-item.locked {
opacity: 0.7;
}
#track-controls button, #export-panel button {
background: #2255aa;
color: #fff;
border: 1px solid #1a1f29;
border-radius: 4px;
padding: 6px 12px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
margin-right: 6px;
}
#track-controls button:disabled, #export-panel button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
#metadata-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
#metadata-form label {
display: flex;
flex-direction: column;
font-size: 12px;
color: #9aa7bb;
gap: 4px;
}
#metadata-form input, #metadata-form textarea {
background: #0b0e14;
border: 1px solid #2c374f;
color: #e8eef7;
border-radius: 4px;
padding: 6px 8px;
font-family: monospace;
font-size: 13px;
}
#preview-scroll {
overflow-x: auto;
border: 1px solid #232c40;
border-radius: 4px;
background: #0b0e14;
}
#preview-canvas {
display: block;
}
#export-textarea {
width: 100%;
box-sizing: border-box;
height: 220px;
background: #0b0e14;
color: #c8d3e0;
border: 1px solid #2c374f;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
padding: 8px;
margin-bottom: 10px;
resize: vertical;
}
#empty-state {
color: #9aa7bb;
font-size: 13px;
padding: 20px;
text-align: center;
}
</style>
</head>
<body>
<h1>Monsterplex Level Editor</h1>
<p class="subtitle">Build a level from snap-together sections, then export it as a level file for src/data/levels/.</p>
<section>
<h2>Level Details</h2>
<div id="metadata-form"></div>
</section>
<section>
<h2>Sections</h2>
<div id="palette"></div>
</section>
<section>
<h2>Sequence</h2>
<div id="track"></div>
<div id="track-controls">
<button id="move-left-btn">&#9664; Move Left</button>
<button id="move-right-btn">Move Right &#9654;</button>
<button id="remove-btn">&#10005; Remove</button>
</div>
</section>
<section>
<h2>Preview</h2>
<div id="preview-scroll"><canvas id="preview-canvas"></canvas></div>
</section>
<section>
<h2>Export</h2>
<textarea id="export-textarea" readonly spellcheck="false"></textarea>
<button id="download-btn">Download levelNN.js</button>
</section>
<script type="module" src="./src/editor/main.js"></script>
</body>
</html>

View File

@ -25,11 +25,12 @@ export const BUS = {
wheelRadius: 17 * WORLD_SCALE,
rearWheelOffsetX: -35 * WORLD_SCALE,
frontWheelOffsetX: 64 * WORLD_SCALE,
// Where the wheel's CENTER rests at equilibrium - this is the measured
// wheel-well position itself, not an attachment point the wheel then
// hangs further below (that was the placeholder-art version's model,
// before there was real art dictating exactly where the wheel belongs).
wheelRestOffsetY: 22 * WORLD_SCALE,
// Where the wheel's CENTER rests at equilibrium. Raised from the
// wheel-well-measured 22 to 34 to lift the chassis and drop the tires
// further down for a bouncier stance - this now visibly departs from
// bus_chassis.png's painted wheel wells (a gap opens above the tire),
// a deliberate look/feel tradeoff rather than a measurement.
wheelRestOffsetY: 34 * WORLD_SCALE,
// Horizontal spread between the two suspension links each wheel hangs
// from (a "wishbone") - a single point-to-point constraint can't resist
// sideways drift at all (it's radially symmetric), so each wheel is held
@ -54,16 +55,16 @@ export const BUS = {
// and was the main reason the bus felt sluggish rather than Trials-fast.
wheelFrictionAir: 0.008,
// Some bounce on landing, not just suspension absorbing everything.
wheelRestitution: 0.3,
wheelRestitution: 0.45,
// Softer and less damped than before - more travel, and lets it actually
// oscillate (bounce) instead of settling immediately - "more shocks."
suspensionStiffness: 0.45,
suspensionDamping: 0.08,
suspensionStiffness: 0.35,
suspensionDamping: 0.035,
// How far the wheel can move up/down from wheelRestOffsetY. This will
// visibly pop the wheel outside its wheel well on a hard landing - that's
// the tradeoff for real suspension travel/bounce instead of a stiff ride.
suspensionTravel: 12 * WORLD_SCALE,
suspensionTravel: 30 * WORLD_SCALE,
// Angular velocities/torque steps are rotational (radians), not linear
// pixel distances, so they're left unscaled - wheel radius already scaled,
@ -85,7 +86,11 @@ export const BUS = {
// (Bus.js computes force = chassis mass * this) rather than a raw force,
// so it stays correct if chassisDensity/size ever changes - tune these
// to change top speed feel.
driveAcceleration: 0.011,
// Cut by ~60% (0.011 -> 0.0045) for a much longer runway to top speed.
// Note this also lowers the top speed itself, not just the time to reach
// it - top speed is where this force balances chassisFrictionAir drag, so
// a smaller force settles at a lower equilibrium speed too.
driveAcceleration: 0.0045,
brakeAcceleration: 0.007,
// Bumped up to stay responsive at the new higher speed - sluggish lean
@ -120,8 +125,26 @@ export const KID = {
export const CAMERA = {
lerpX: 0.1,
lerpY: 0.1,
deadzoneWidth: 220 * WORLD_SCALE,
// Horizontal position is speed-driven (see CameraRig.update - it steers
// via followOffset.x) rather than a dead zone, so this is kept tiny
// rather than removed, just to dodge exact-equality edge cases in
// Phaser's deadzone math. Vertical still uses a real dead zone below.
deadzoneWidth: 2 * WORLD_SCALE,
deadzoneHeight: 140 * WORLD_SCALE,
// Where the bus sits on screen, as a fraction of screen width from the
// left edge - primary tuning knobs, not physically derived, tune by
// playtesting. restScreenFraction leaves enough room for the chassis
// (half its width is ~0.09 of the screen) not to clip off the left edge
// while still reading as "almost touching."
restScreenFraction: 0.1,
maxSpeedScreenFraction: 1 / 3,
// Horizontal chassis speed (px/s) at which framing reaches
// maxSpeedScreenFraction; clamped beyond it. There's no hard speed cap in
// the drive physics (chassisFrictionAir just balances driveAcceleration
// out asymptotically), so this is a starting estimate - tune by
// playtesting alongside the fractions above.
lookaheadMaxSpeed: 110 * WORLD_SCALE,
};
export const TERRAIN_DEPTH = 400 * WORLD_SCALE;

View File

@ -1,8 +1,9 @@
import level01 from './level01.js';
import level02 from './level02.js';
import level03 from './level03.js';
import level04 from './level04.js';
export const LEVELS = [level01, level02, level03];
export const LEVELS = [level01, level02, level03, level04];
export function getLevelById(id) {
return LEVELS.find((level) => level.id === id);

View File

@ -1,7 +1,7 @@
import { generateWave } from './terrainHelpers.js';
import { WORLD_SCALE } from '../../config.js';
const END_X = 3600 * WORLD_SCALE;
const END_X = 3600 * 30 * WORLD_SCALE;
const BASE_Y = 420 * WORLD_SCALE;
const points = generateWave(-200 * WORLD_SCALE, END_X, 80 * WORLD_SCALE, BASE_Y, 30 * WORLD_SCALE, 260 * WORLD_SCALE);

157
src/data/levels/level04.js Normal file
View File

@ -0,0 +1,157 @@
// Generated by editor.html - review before dropping into src/data/levels/.
export default {
id: "levelCustom",
name: "New Level",
description: "",
kidsAboard: 3,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 838 },
{ x: 1140, y: 834 },
{ x: 1210, y: 830 },
{ x: 1280, y: 822 },
{ x: 1350, y: 812 },
{ x: 1420, y: 802 },
{ x: 1490, y: 790 },
{ x: 1560, y: 776 },
{ x: 1630, y: 764 },
{ x: 1700, y: 750 },
{ x: 1770, y: 736 },
{ x: 1840, y: 724 },
{ x: 1910, y: 710 },
{ x: 1980, y: 698 },
{ x: 2050, y: 688 },
{ x: 2120, y: 678 },
{ x: 2190, y: 670 },
{ x: 2260, y: 666 },
{ x: 2330, y: 662 },
{ x: 2400, y: 660 },
{ x: 2470, y: 660 },
{ x: 2540, y: 660 },
{ x: 2610, y: 660 },
{ x: 2680, y: 660 },
{ x: 2750, y: 660 },
{ x: 2820, y: 660 },
{ x: 2890, y: 660 },
{ x: 2960, y: 660 },
{ x: 3030, y: 660 },
{ x: 3100, y: 660 },
{ x: 3170, y: 660 },
{ x: 3240, y: 660 },
{ x: 3310, y: 660 },
{ x: 3380, y: 660 },
{ x: 3450, y: 660 },
{ x: 3520, y: 660 },
{ x: 3590, y: 660 },
{ x: 3660, y: 660 },
{ x: 3730, y: 660 },
{ x: 3800, y: 660 },
{ x: 3870, y: 676 },
{ x: 3940, y: 692 },
{ x: 4010, y: 708 },
{ x: 4080, y: 724 },
{ x: 4150, y: 740 },
{ x: 4220, y: 756 },
{ x: 4290, y: 772 },
{ x: 4360, y: 788 },
{ x: 4430, y: 804 },
{ x: 4500, y: 820 },
{ x: 4570, y: 836 },
{ x: 4640, y: 852 },
{ x: 4710, y: 868 },
{ x: 4780, y: 884 },
{ x: 4850, y: 900 },
{ x: 4920, y: 916 },
{ x: 4990, y: 932 },
{ x: 5060, y: 948 },
{ x: 5130, y: 964 },
{ x: 5200, y: 980 },
{ x: 5270, y: 996 },
{ x: 5340, y: 1012 },
{ x: 5410, y: 1028 },
{ x: 5480, y: 1044 },
{ x: 5550, y: 1060 },
{ x: 5620, y: 1076 },
{ x: 5690, y: 1092 },
{ x: 5760, y: 1108 },
{ x: 5830, y: 1124 },
{ x: 5900, y: 1140 },
{ x: 5970, y: 1156 },
{ x: 6040, y: 1172 },
{ x: 6110, y: 1188 },
{ x: 6180, y: 1204 },
{ x: 6250, y: 1220 },
{ x: 6320, y: 1236 },
{ x: 6390, y: 1252 },
{ x: 6460, y: 1268 },
{ x: 6530, y: 1284 },
{ x: 6600, y: 1300 },
{ x: 6670, y: 1298 },
{ x: 6740, y: 1294 },
{ x: 6810, y: 1290 },
{ x: 6880, y: 1282 },
{ x: 6950, y: 1272 },
{ x: 7020, y: 1262 },
{ x: 7090, y: 1250 },
{ x: 7160, y: 1236 },
{ x: 7230, y: 1224 },
{ x: 7300, y: 1210 },
{ x: 7370, y: 1196 },
{ x: 7440, y: 1184 },
{ x: 7510, y: 1170 },
{ x: 7580, y: 1158 },
{ x: 7650, y: 1148 },
{ x: 7720, y: 1138 },
{ x: 7790, y: 1130 },
{ x: 7860, y: 1126 },
{ x: 7930, y: 1122 },
{ x: 8000, y: 1120 },
{ x: 8070, y: 1122 },
{ x: 8140, y: 1126 },
{ x: 8210, y: 1130 },
{ x: 8280, y: 1138 },
{ x: 8350, y: 1148 },
{ x: 8420, y: 1158 },
{ x: 8490, y: 1170 },
{ x: 8560, y: 1184 },
{ x: 8630, y: 1196 },
{ x: 8700, y: 1210 },
{ x: 8770, y: 1224 },
{ x: 8840, y: 1236 },
{ x: 8910, y: 1250 },
{ x: 8980, y: 1262 },
{ x: 9050, y: 1272 },
{ x: 9120, y: 1282 },
{ x: 9190, y: 1290 },
{ x: 9260, y: 1294 },
{ x: 9330, y: 1298 },
{ x: 9400, y: 1300 },
] },
],
obstacles: [],
goal: { x: 9160, y: 1060, width: 280, height: 640 },
cameraBounds: { x: -600, y: 0, width: 10800, height: 2000 },
};

41
src/editor/exportLevel.js Normal file
View File

@ -0,0 +1,41 @@
// Serializes a level data object (as produced by levelBuilder.js) into the
// literal `export default {...}` JS module source text the game expects to
// find in src/data/levels/levelNN.js. Numbers are emitted as plain scaled
// literals rather than `N * WORLD_SCALE` expressions - levelBuilder.js has
// already fully resolved them, and level03.js already sets precedent for a
// level file built from computed literal points rather than hand-picked
// base-unit expressions.
function pointsLiteral(points, indent) {
const lines = points.map((p) => `${indent} { x: ${p.x}, y: ${p.y} },`);
return `[\n${lines.join('\n')}\n${indent}]`;
}
function terrainLiteral(terrain) {
const segments = terrain.map((segment) => ` { points: ${pointsLiteral(segment.points, ' ')} },`);
return `[\n${segments.join('\n')}\n ]`;
}
function rectLiteral(rect) {
return `{ x: ${rect.x}, y: ${rect.y}, width: ${rect.width}, height: ${rect.height} }`;
}
export function exportLevelToSource(levelData) {
const lines = [
'// Generated by editor.html - review before dropping into src/data/levels/.',
'export default {',
` id: ${JSON.stringify(levelData.id)},`,
` name: ${JSON.stringify(levelData.name)},`,
` description: ${JSON.stringify(levelData.description)},`,
` kidsAboard: ${levelData.kidsAboard},`,
` startPosition: { x: ${levelData.startPosition.x}, y: ${levelData.startPosition.y} },`,
` startAngle: ${levelData.startAngle},`,
` terrain: ${terrainLiteral(levelData.terrain)},`,
' obstacles: [],',
` goal: ${rectLiteral(levelData.goal)},`,
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
'};',
'',
];
return lines.join('\n');
}

View File

@ -0,0 +1,97 @@
import { generateSection, SECTION_WIDTH } from './sections.js';
import { WORLD_SCALE } from '../config.js';
// Base-unit constants mirroring the values every hand-written level file
// (level01/02/03.js) already uses, so editor-built levels look/feel
// consistent with the shipped ones.
const TERRAIN_LEAD_IN = 200;
const INITIAL_GROUND_Y = 420;
const BUS_SPAWN_X = 100;
const BUS_SPAWN_DROP = 70;
const GOAL_WIDTH = 140;
const GOAL_HEIGHT = 320;
const GOAL_END_MARGIN = 120;
const GOAL_HEIGHT_OFFSET = 120;
const CAMERA_LEFT_MARGIN = 300;
const CAMERA_RIGHT_MARGIN = 700;
const CAMERA_HEIGHT = 1000;
function scalePoint(p) {
return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) };
}
// Appends scaled points onto an accumulator array, skipping a leading point
// that exactly duplicates the accumulator's current last point (happens at
// every block boundary, since each block's first sample is the previous
// block's last x/y by construction).
function appendPoints(accumulator, points) {
for (const p of points) {
const scaled = scalePoint(p);
const last = accumulator[accumulator.length - 1];
if (last && last.x === scaled.x && last.y === scaled.y) continue;
accumulator.push(scaled);
}
}
// sectionTypeIds: ordered array of ids from sections.js's SECTION_TYPES.
// metadata: { id, name, description, kidsAboard }.
// Returns a full level data object matching level01.js's shape, or null if
// sectionTypeIds is empty.
export function buildLevelData(sectionTypeIds, metadata) {
if (!sectionTypeIds || sectionTypeIds.length === 0) return null;
let baseX = -TERRAIN_LEAD_IN;
let baseY = INITIAL_GROUND_Y;
const terrainSegments = [];
let currentPoints = [];
for (const typeId of sectionTypeIds) {
const result = generateSection(typeId, baseX, baseY, SECTION_WIDTH);
if (result.type === 'jump') {
appendPoints(currentPoints, result.takeoffPoints);
terrainSegments.push({ points: currentPoints });
currentPoints = [];
appendPoints(currentPoints, result.landingPoints);
} else {
appendPoints(currentPoints, result.points);
}
baseY = result.endY;
baseX += SECTION_WIDTH;
}
if (currentPoints.length > 0) {
terrainSegments.push({ points: currentPoints });
}
const endX = Math.round(baseX * WORLD_SCALE);
const groundYAtEnd = Math.round(baseY * WORLD_SCALE);
return {
id: metadata.id,
name: metadata.name,
description: metadata.description,
kidsAboard: metadata.kidsAboard,
startPosition: {
x: Math.round(BUS_SPAWN_X * WORLD_SCALE),
y: Math.round((INITIAL_GROUND_Y - BUS_SPAWN_DROP) * WORLD_SCALE),
},
startAngle: 0,
terrain: terrainSegments,
obstacles: [],
goal: {
x: endX - Math.round(GOAL_END_MARGIN * WORLD_SCALE),
y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE),
width: Math.round(GOAL_WIDTH * WORLD_SCALE),
height: Math.round(GOAL_HEIGHT * WORLD_SCALE),
},
cameraBounds: {
x: -Math.round(CAMERA_LEFT_MARGIN * WORLD_SCALE),
y: 0,
width: endX + Math.round(CAMERA_RIGHT_MARGIN * WORLD_SCALE),
height: Math.round(CAMERA_HEIGHT * WORLD_SCALE),
},
};
}

295
src/editor/main.js Normal file
View File

@ -0,0 +1,295 @@
import { SECTION_TYPES } from './sections.js';
import { buildLevelData } from './levelBuilder.js';
import { exportLevelToSource } from './exportLevel.js';
import { BUS } from '../config.js';
// The leading section is always 'flat' and locked at index 0, so a jump
// section can never end up as the very first thing the bus drives onto -
// see the "Safety default" note in the level editor plan.
const state = {
metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 },
sequence: ['flat'],
selectedIndex: null,
};
const paletteEl = document.getElementById('palette');
const trackEl = document.getElementById('track');
const moveLeftBtn = document.getElementById('move-left-btn');
const moveRightBtn = document.getElementById('move-right-btn');
const removeBtn = document.getElementById('remove-btn');
const metadataFormEl = document.getElementById('metadata-form');
const previewScrollEl = document.getElementById('preview-scroll');
const previewCanvas = document.getElementById('preview-canvas');
const exportTextarea = document.getElementById('export-textarea');
const downloadBtn = document.getElementById('download-btn');
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId;
function buildPalette() {
paletteEl.innerHTML = '';
for (const { id, label } of SECTION_TYPES) {
const btn = document.createElement('button');
btn.className = 'palette-btn';
btn.textContent = label;
btn.addEventListener('click', () => {
state.sequence.push(id);
render();
});
paletteEl.appendChild(btn);
}
}
function buildMetadataForm() {
metadataFormEl.innerHTML = '';
const makeField = (labelText, inputEl) => {
const label = document.createElement('label');
label.textContent = labelText;
label.appendChild(inputEl);
metadataFormEl.appendChild(label);
return inputEl;
};
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.value = state.metadata.id;
idInput.addEventListener('input', () => {
state.metadata.id = idInput.value;
render();
});
makeField('Level ID', idInput);
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = state.metadata.name;
nameInput.addEventListener('input', () => {
state.metadata.name = nameInput.value;
render();
});
makeField('Name', nameInput);
const descInput = document.createElement('input');
descInput.type = 'text';
descInput.value = state.metadata.description;
descInput.addEventListener('input', () => {
state.metadata.description = descInput.value;
render();
});
makeField('Description', descInput);
const kidsInput = document.createElement('input');
kidsInput.type = 'number';
kidsInput.min = '1';
kidsInput.max = String(BUS.maxSeats);
kidsInput.value = String(state.metadata.kidsAboard);
kidsInput.addEventListener('input', () => {
const n = clamp(parseInt(kidsInput.value, 10) || 1, 1, BUS.maxSeats);
state.metadata.kidsAboard = n;
render();
});
makeField(`Kids Aboard (max ${BUS.maxSeats})`, kidsInput);
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function buildTrack() {
trackEl.innerHTML = '';
state.sequence.forEach((typeId, index) => {
const item = document.createElement('div');
item.className = 'track-item';
if (index === state.selectedIndex) item.classList.add('selected');
if (index === 0) item.classList.add('locked');
item.textContent = `${index + 1}. ${labelFor(typeId)}${index === 0 ? ' (locked)' : ''}`;
item.addEventListener('click', () => {
state.selectedIndex = index;
render();
});
trackEl.appendChild(item);
});
}
function updateTrackControls() {
const i = state.selectedIndex;
const locked = i === 0;
moveLeftBtn.disabled = i === null || i <= 1;
moveRightBtn.disabled = i === null || locked || i === state.sequence.length - 1;
removeBtn.disabled = i === null || locked;
}
moveLeftBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i <= 1) return;
[state.sequence[i - 1], state.sequence[i]] = [state.sequence[i], state.sequence[i - 1]];
state.selectedIndex = i - 1;
render();
});
moveRightBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i === 0 || i === state.sequence.length - 1) return;
[state.sequence[i + 1], state.sequence[i]] = [state.sequence[i], state.sequence[i + 1]];
state.selectedIndex = i + 1;
render();
});
removeBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i === 0) return;
state.sequence.splice(i, 1);
state.selectedIndex = null;
render();
});
// --- Preview -----------------------------------------------------------
const PADDING = 40;
const H_PX_PER_UNIT = 0.12;
const CANVAS_HEIGHT = 460;
function computeBounds(levelData) {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const segment of levelData.terrain) {
for (const p of segment.points) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
}
minX = Math.min(minX, levelData.startPosition.x);
maxX = Math.max(maxX, levelData.goal.x + levelData.goal.width / 2);
minY = Math.min(minY, levelData.startPosition.y - 100, levelData.goal.y - levelData.goal.height / 2);
maxY = Math.max(maxY, levelData.goal.y + levelData.goal.height / 2);
return { minX, maxX, minY, maxY };
}
function drawPreview(levelData) {
const { minX, maxX, minY, maxY } = computeBounds(levelData);
const contentWidth = Math.max(1, maxX - minX);
const contentHeight = Math.max(1, maxY - minY);
const width = Math.max(600, Math.round(contentWidth * H_PX_PER_UNIT) + PADDING * 2);
const height = CANVAS_HEIGHT;
previewCanvas.width = width;
previewCanvas.height = height;
const vScale = (height - PADDING * 2) / contentHeight;
const screenX = (worldX) => (worldX - minX) * H_PX_PER_UNIT + PADDING;
const screenY = (worldY) => (worldY - minY) * vScale + PADDING;
const ctx = previewCanvas.getContext('2d');
ctx.fillStyle = '#0b0e14';
ctx.fillRect(0, 0, width, height);
levelData.terrain.forEach((segment, i) => {
const pts = segment.points;
ctx.beginPath();
ctx.moveTo(screenX(pts[0].x), height);
for (const p of pts) ctx.lineTo(screenX(p.x), screenY(p.y));
ctx.lineTo(screenX(pts[pts.length - 1].x), height);
ctx.closePath();
ctx.fillStyle = '#3c8f3c';
ctx.fill();
ctx.beginPath();
ctx.moveTo(screenX(pts[0].x), screenY(pts[0].y));
for (const p of pts) ctx.lineTo(screenX(p.x), screenY(p.y));
ctx.strokeStyle = '#2c6e2c';
ctx.lineWidth = 3;
ctx.stroke();
if (i < levelData.terrain.length - 1) {
const nextPts = levelData.terrain[i + 1].points;
const gx1 = screenX(pts[pts.length - 1].x);
const gx2 = screenX(nextPts[0].x);
const mid = (gx1 + gx2) / 2;
ctx.save();
ctx.setLineDash([6, 6]);
ctx.strokeStyle = '#f2c14e';
ctx.beginPath();
ctx.moveTo(mid, PADDING);
ctx.lineTo(mid, height - PADDING);
ctx.stroke();
ctx.restore();
ctx.fillStyle = '#f2c14e';
ctx.font = '12px monospace';
ctx.fillText('GAP', mid - 14, PADDING - 8);
}
});
const sx = screenX(levelData.startPosition.x);
const sy = screenY(levelData.startPosition.y);
ctx.fillStyle = '#ffe066';
ctx.beginPath();
ctx.arc(sx, sy, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1f29';
ctx.font = 'bold 12px monospace';
ctx.fillText('S', sx - 4, sy + 4);
const g = levelData.goal;
const gx = screenX(g.x);
const gyTop = screenY(g.y - g.height / 2);
const gyBottom = screenY(g.y + g.height / 2);
const gWidthPx = g.width * H_PX_PER_UNIT;
ctx.fillStyle = 'rgba(242, 193, 78, 0.25)';
ctx.fillRect(gx - gWidthPx / 2, gyTop, gWidthPx, gyBottom - gyTop);
ctx.strokeStyle = '#f2c14e';
ctx.lineWidth = 2;
ctx.strokeRect(gx - gWidthPx / 2, gyTop, gWidthPx, gyBottom - gyTop);
ctx.fillStyle = '#f2c14e';
ctx.font = '12px monospace';
ctx.fillText('GOAL', gx - 16, gyTop - 6);
}
// --- Export --------------------------------------------------------------
let currentLevelData = null;
downloadBtn.addEventListener('click', () => {
if (!currentLevelData) return;
const source = exportLevelToSource(currentLevelData);
const blob = new Blob([source], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentLevelData.id || 'levelCustom'}.js`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
// --- Render loop -----------------------------------------------------------
function render() {
buildTrack();
updateTrackControls();
currentLevelData = buildLevelData(state.sequence, state.metadata);
if (!currentLevelData) {
previewScrollEl.innerHTML = '<div id="empty-state">Add a section to see the preview.</div>';
exportTextarea.value = '';
downloadBtn.disabled = true;
return;
}
if (!previewScrollEl.contains(previewCanvas)) {
previewScrollEl.innerHTML = '';
previewScrollEl.appendChild(previewCanvas);
}
drawPreview(currentLevelData);
exportTextarea.value = exportLevelToSource(currentLevelData);
downloadBtn.disabled = false;
}
buildPalette();
buildMetadataForm();
render();

134
src/editor/sections.js Normal file
View File

@ -0,0 +1,134 @@
// Pure geometry - zero imports, no WORLD_SCALE, no DOM. Every generator works
// in "base design units" (the same pre-WORLD_SCALE space level files author
// in) so this module is trivially usable from plain Node for testing and
// stays decoupled from both Phaser and the editor's DOM code.
export const SECTION_WIDTH = 700;
export const POINT_STEP = 35;
export const SECTION_TYPES = [
{ id: 'flat', label: 'Flat' },
{ id: 'smoothIncline', label: 'Smooth Incline' },
{ id: 'sharpIncline', label: 'Sharp Incline' },
{ id: 'smoothDecline', label: 'Smooth Decline' },
{ id: 'sharpDecline', label: 'Sharp Decline' },
{ id: 'rollingHills', label: 'Rolling Hills' },
{ id: 'ditch', label: 'Ditch' },
{ id: 'jump', label: 'Jump' },
];
const RISE_SMOOTH = 90;
const RISE_SHARP = 160;
const AMP_HILLS = 45;
const DEPTH_DITCH = 130;
const JUMP_TAKEOFF_FRACTION = 0.45;
const JUMP_GAP_FRACTION = 0.25;
// JUMP_LANDING_FRACTION is implicit: 1 - JUMP_TAKEOFF_FRACTION - JUMP_GAP_FRACTION
const JUMP_TAKEOFF_RISE = 140;
const JUMP_LANDING_DROP = 60;
const JUMP_LIP_LEN = POINT_STEP;
function smoothstep(t) {
const c = Math.max(0, Math.min(1, t));
return c * c * (3 - 2 * c);
}
// Samples x from startX to startX+width every POINT_STEP (inclusive of both
// ends), calling shape(t) for the y-offset at each sample, t in [0,1].
function sampleBlock(startX, startY, width, shape) {
const points = [];
for (let x = startX; x <= startX + width; x += POINT_STEP) {
const t = (x - startX) / width;
points.push({ x, y: Math.round(startY + shape(t)) });
}
// Guard against floating-point step accumulation leaving the last sample
// short of startX+width.
const last = points[points.length - 1];
if (last.x < startX + width - 1) {
points.push({ x: startX + width, y: Math.round(startY + shape(1)) });
}
return points;
}
function flat(startX, startY, width) {
const points = sampleBlock(startX, startY, width, () => 0);
return { type: 'simple', points, endY: startY };
}
function smoothIncline(startX, startY, width) {
const points = sampleBlock(startX, startY, width, (t) => -RISE_SMOOTH * smoothstep(t));
return { type: 'simple', points, endY: startY - RISE_SMOOTH };
}
function sharpIncline(startX, startY, width) {
const points = sampleBlock(startX, startY, width, (t) => -RISE_SHARP * t);
return { type: 'simple', points, endY: startY - RISE_SHARP };
}
function smoothDecline(startX, startY, width) {
const points = sampleBlock(startX, startY, width, (t) => RISE_SMOOTH * smoothstep(t));
return { type: 'simple', points, endY: startY + RISE_SMOOTH };
}
function sharpDecline(startX, startY, width) {
const points = sampleBlock(startX, startY, width, (t) => RISE_SHARP * t);
return { type: 'simple', points, endY: startY + RISE_SHARP };
}
function rollingHills(startX, startY, width) {
const points = sampleBlock(startX, startY, width, (t) => AMP_HILLS * Math.sin(2 * Math.PI * t));
return { type: 'simple', points, endY: startY };
}
function ditch(startX, startY, width) {
const points = sampleBlock(startX, startY, width, (t) => DEPTH_DITCH * Math.sin(Math.PI * t));
return { type: 'simple', points, endY: startY };
}
// The one type that produces two separate point runs (a real terrain gap in
// between), mirroring how level02.js builds its jump: a "runway" segment
// ending at a raised lip, then a real x-gap, then a "landing" segment
// starting lower (simulating the fall across the gap).
function jump(startX, startY, width) {
const w1 = width * JUMP_TAKEOFF_FRACTION;
const w2 = width * JUMP_GAP_FRACTION;
const w3 = width - w1 - w2;
const rampLen = w1 - JUMP_LIP_LEN;
const lipY = startY - JUMP_TAKEOFF_RISE;
const takeoffPoints = [];
for (let x = startX; x <= startX + w1; x += POINT_STEP) {
const localX = x - startX;
const y = localX <= rampLen ? startY - JUMP_TAKEOFF_RISE * (localX / rampLen) : lipY;
takeoffPoints.push({ x, y: Math.round(y) });
}
const takeoffLast = takeoffPoints[takeoffPoints.length - 1];
if (takeoffLast.x < startX + w1 - 1) {
takeoffPoints.push({ x: startX + w1, y: Math.round(lipY) });
}
const landingStartX = startX + w1 + w2;
const landingStartY = lipY + JUMP_LANDING_DROP;
const endY = startY;
const landingPoints = sampleBlock(landingStartX, landingStartY, w3, (t) => (endY - landingStartY) * smoothstep(t));
return { type: 'jump', takeoffPoints, landingPoints, endY };
}
const GENERATORS = {
flat,
smoothIncline,
sharpIncline,
smoothDecline,
sharpDecline,
rollingHills,
ditch,
jump,
};
export function generateSection(typeId, startX, startY, width = SECTION_WIDTH) {
const generator = GENERATORS[typeId];
if (!generator) throw new Error(`Unknown section type: ${typeId}`);
return generator(startX, startY, width);
}

View File

@ -45,6 +45,65 @@ export default class Bus {
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
this.seatOffsets = this._buildSeatOffsets();
// The wishbone above only constrains each wheel's DISTANCE from its two
// anchors - it has no notion of which side of them the wheel is on. Two
// positions satisfy "equidistant from both anchors": the normal one
// below the anchor line, and one mirrored above it. A hard enough impact
// can carry the wheel across that line within a single physics step, and
// the spring converges on the mirrored (wrong) solution just as happily
// - the wheel visibly "punches through" to sit high on the chassis and
// stays there, since it's now a stable equilibrium. This is a hard stop
// enforced every tick to make crossing impossible, since no amount of
// spring tuning or solver iteration can fix an ambiguity the constraint
// itself can't see.
this._anchorY = anchorY;
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
_onAfterUpdate() {
this._clampWheelToWishbone(this.wheelRear);
this._clampWheelToWishbone(this.wheelFront);
}
// Keeps a wheel from ever crossing above its wishbone's anchor line (both
// anchors share the same anchorY, so the line is horizontal in the
// chassis's own rotated frame).
_clampWheelToWishbone(wheel) {
const chassis = this.chassis;
const cos = Math.cos(chassis.rotation);
const sin = Math.sin(chassis.rotation);
const dx = wheel.x - chassis.x;
const dy = wheel.y - chassis.y;
// Un-rotate the wheel's offset into the chassis's own frame, where the
// anchor line is simply y = this._anchorY.
const localX = dx * cos + dy * sin;
const localY = -dx * sin + dy * cos;
// Small margin below the true anchor line so the clamp settles instead
// of the spring and the clamp fighting exactly at the boundary.
const limit = this._anchorY + BUS.axleSpread;
if (localY >= limit) return;
const worldDX = localX * cos - limit * sin;
const worldDY = localX * sin + limit * cos;
wheel.setPosition(chassis.x + worldDX, chassis.y + worldDY);
// Kill only the velocity component still driving it through the line
// (in the chassis's frame), so it doesn't immediately re-punch next
// tick - like a real suspension bump stop absorbing the hit rather than
// bouncing off it. Sideways/rolling relative motion is left untouched.
const chassisBody = chassis.body;
const relVX = wheel.body.velocity.x - chassisBody.velocity.x;
const relVY = wheel.body.velocity.y - chassisBody.velocity.y;
const localVX = relVX * cos + relVY * sin;
const localVY = -relVX * sin + relVY * cos;
if (localVY < 0) {
const newRelVX = localVX * cos;
const newRelVY = localVX * sin;
wheel.setVelocity(chassisBody.velocity.x + newRelVX, chassisBody.velocity.y + newRelVY);
}
}
// Drive/brake apply a direct force to the chassis (see applyIntent) on top
@ -173,4 +232,11 @@ export default class Bus {
const seat = this.seatOffsets[seatIndex];
return { x: this.chassis.x + seat.x, y: this.chassis.y + seat.y };
}
destroy() {
// Deliberately empty, matching GForceMonitor.destroy(): Matter World's
// own shutdown already removes every listener registered on it
// (including _onAfterUpdate above), and by the time a scene's
// 'shutdown' handler runs, this.scene.matter.world may already be null.
}
}

View File

@ -23,7 +23,22 @@ window.__PHASER_GAME__ = new Phaser.Game({
// independent) - has to scale with WORLD_SCALE or a 2x-bigger world
// falls proportionally slower (everything free-falls for longer to
// cover the now-doubled pixel distances).
gravity: { x: 0, y: 1 * WORLD_SCALE },
// Dropped from the original 1 - pulled back from an initial 0.35 (a
// lot more airtime) to 0.675, roughly the midpoint, since 0.35 read as
// too floaty. For a given launch speed off a ramp, time-to-apex is
// v/g, so this is still ~1.5x more hang time than the original.
// Floatier overall (slower falling everywhere, not just off jumps) is
// the tradeoff - primary tuning knob for "how floaty," not physically
// derived.
gravity: { x: 0, y: 0.675 * WORLD_SCALE },
// Default is 2. The wheel wishbones are soft spring constraints with a
// short travel range - on a hard landing the low default let a wheel's
// per-tick correction overshoot past its anchor and settle mirrored on
// the wrong side (visually "jumping" higher on the chassis and
// sticking there). More iterations converge the spring solve closer to
// the true equilibrium each tick, which keeps the wheel from punching
// through in the first place.
constraintIterations: 10,
debug: DEBUG,
},
},

View File

@ -33,11 +33,14 @@ export default class PlayScene extends Phaser.Scene {
this.inputController = new InputController(this);
this.gForceMonitor = new GForceMonitor(this, this.bus.chassis.body);
// Measured via headless testing: the spawn-to-ground drop settles by
// ~4.5s with the current spawn height/bus mass. Re-measure this if
// startPosition, WORLD_SCALE, or the bus's mass/suspension change again -
// too short and the landing itself trips the g-force ejection check.
this.gForceMonitor.startGracePeriod(5000);
// Measured via headless testing at the original gravity (1): the
// spawn-to-ground drop settled by ~4.5s. Free-fall time scales as
// 1/sqrt(g), and gravity is now 0.675 (see main.js), so this is scaled
// up by ~1.2x (sqrt(1/0.675)) to ~5.5s rather than re-measured -
// re-measure for real if landings still trip the g-force ejection check
// right at level start. Re-measure this if startPosition, WORLD_SCALE,
// gravity, or the bus's mass/suspension change again.
this.gForceMonitor.startGracePeriod(5500);
this.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds);
@ -58,6 +61,7 @@ export default class PlayScene extends Phaser.Scene {
const intent = this.inputController.getIntent();
this.bus.applyIntent(intent);
this.kidManager.update(time);
this.cameraRig.update();
if (DEBUG && this.debugText) {
const g = this.gForceMonitor.lastGForce || 0;
@ -164,5 +168,6 @@ export default class PlayScene extends Phaser.Scene {
// redundant, not just now-unsafe.
if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy();
if (this.bus) this.bus.destroy();
}
}

View File

@ -1,15 +1,29 @@
import Phaser from 'phaser';
import { CAMERA } from '../config.js';
export default class CameraRig {
constructor(scene, target, bounds) {
this.scene = scene;
const cam = scene.cameras.main;
this.target = target;
this.cam = scene.cameras.main;
if (bounds) {
cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
this.cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
}
cam.setDeadzone(CAMERA.deadzoneWidth, CAMERA.deadzoneHeight);
cam.startFollow(target, true, CAMERA.lerpX, CAMERA.lerpY);
this.cam.setDeadzone(CAMERA.deadzoneWidth, CAMERA.deadzoneHeight);
this.cam.startFollow(target, true, CAMERA.lerpX, CAMERA.lerpY);
}
// Leans the bus toward the left edge of the screen at rest and out to
// CAMERA.maxSpeedScreenFraction of the way across at speed, so there's
// more visible road ahead the faster the bus is moving. Driven through
// Phaser's followOffset (rather than setting scroll ourselves) so it
// still rides Phaser's own per-frame lerp smoothing and bounds clamping.
update() {
const speed = Math.abs(this.target.body.velocity.x);
const t = Phaser.Math.Clamp(speed / CAMERA.lookaheadMaxSpeed, 0, 1);
const screenFraction = Phaser.Math.Linear(CAMERA.restScreenFraction, CAMERA.maxSpeedScreenFraction, t);
this.cam.followOffset.x = (screenFraction - 0.5) * this.cam.width;
}
}