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; } }