diff --git a/assets/backgrounds/bg_far.png b/assets/backgrounds/bg_far.png
new file mode 100644
index 0000000..4382f36
Binary files /dev/null and b/assets/backgrounds/bg_far.png differ
diff --git a/assets/backgrounds/bg_mid.png b/assets/backgrounds/bg_mid.png
new file mode 100644
index 0000000..5af84ef
Binary files /dev/null and b/assets/backgrounds/bg_mid.png differ
diff --git a/assets/backgrounds/bg_near.png b/assets/backgrounds/bg_near.png
new file mode 100644
index 0000000..0f305c2
Binary files /dev/null and b/assets/backgrounds/bg_near.png differ
diff --git a/editor.html b/editor.html
new file mode 100644
index 0000000..bbb9cc7
--- /dev/null
+++ b/editor.html
@@ -0,0 +1,191 @@
+
+
+
+
+
+Monsterplex Level Editor
+
+
+
+
+
Monsterplex Level Editor
+
Build a level from snap-together sections, then export it as a level file for src/data/levels/.
+
+
+
Level Details
+
+
+
+
+
Sections
+
+
+
+
+
Sequence
+
+
+
+
+
+
+
+
+
+
Preview
+
+
+
+
+
Export
+
+
+
+
+
+
+
diff --git a/src/config.js b/src/config.js
index a0ca313..f5bf4c1 100644
--- a/src/config.js
+++ b/src/config.js
@@ -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;
diff --git a/src/data/levels/index.js b/src/data/levels/index.js
index 0e7f300..ed24469 100644
--- a/src/data/levels/index.js
+++ b/src/data/levels/index.js
@@ -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);
diff --git a/src/data/levels/level01.js b/src/data/levels/level01.js
index 9c16f29..11a01ab 100644
--- a/src/data/levels/level01.js
+++ b/src/data/levels/level01.js
@@ -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);
diff --git a/src/data/levels/level04.js b/src/data/levels/level04.js
new file mode 100644
index 0000000..c68274f
--- /dev/null
+++ b/src/data/levels/level04.js
@@ -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 },
+};
diff --git a/src/editor/exportLevel.js b/src/editor/exportLevel.js
new file mode 100644
index 0000000..33810a6
--- /dev/null
+++ b/src/editor/exportLevel.js
@@ -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');
+}
diff --git a/src/editor/levelBuilder.js b/src/editor/levelBuilder.js
new file mode 100644
index 0000000..fd3eb26
--- /dev/null
+++ b/src/editor/levelBuilder.js
@@ -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),
+ },
+ };
+}
diff --git a/src/editor/main.js b/src/editor/main.js
new file mode 100644
index 0000000..b201809
--- /dev/null
+++ b/src/editor/main.js
@@ -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 = '
Add a section to see the preview.
';
+ 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();
diff --git a/src/editor/sections.js b/src/editor/sections.js
new file mode 100644
index 0000000..f61b877
--- /dev/null
+++ b/src/editor/sections.js
@@ -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);
+}
diff --git a/src/entities/Bus.js b/src/entities/Bus.js
index 2cc9e8b..53793b6 100644
--- a/src/entities/Bus.js
+++ b/src/entities/Bus.js
@@ -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.
+ }
}
diff --git a/src/main.js b/src/main.js
index 801a969..d04ca2c 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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,
},
},
diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js
index 4f6ed35..5bcb273 100644
--- a/src/scenes/PlayScene.js
+++ b/src/scenes/PlayScene.js
@@ -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();
}
}
diff --git a/src/systems/CameraRig.js b/src/systems/CameraRig.js
index 793aba3..235e635 100644
--- a/src/systems/CameraRig.js
+++ b/src/systems/CameraRig.js
@@ -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;
}
}