Compare commits
5 Commits
ffe29468d6
...
3b8c13fb4e
| Author | SHA1 | Date |
|---|---|---|
|
|
3b8c13fb4e | |
|
|
f3e7f96220 | |
|
|
c4954b2292 | |
|
|
9892df762c | |
|
|
4973b22f7c |
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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
|
@ -0,0 +1,266 @@
|
|||
<!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;
|
||||
}
|
||||
#size-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #232c40;
|
||||
}
|
||||
.size-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.size-field label {
|
||||
font-size: 12px;
|
||||
color: #9aa7bb;
|
||||
}
|
||||
.size-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.size-field-row input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
.size-field-row input[type="number"] {
|
||||
width: 64px;
|
||||
background: #0b0e14;
|
||||
border: 1px solid #2c374f;
|
||||
color: #e8eef7;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.size-field-row input:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #e8eef7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-row:has(input: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">◀ Move Left</button>
|
||||
<button id="move-right-btn">Move Right ▶</button>
|
||||
<button id="remove-btn">✕ Remove</button>
|
||||
</div>
|
||||
<div id="size-panel" hidden>
|
||||
<div class="size-field">
|
||||
<label for="width-range">Width (horizontal)</label>
|
||||
<div class="size-field-row">
|
||||
<input type="range" id="width-range" min="250" max="2100" step="10">
|
||||
<input type="number" id="width-number" min="250" max="2100" step="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="size-field">
|
||||
<label for="vscale-range">Vertical Scale</label>
|
||||
<div class="size-field-row">
|
||||
<input type="range" id="vscale-range" min="0.3" max="2.5" step="0.05">
|
||||
<input type="number" id="vscale-number" min="0.3" max="2.5" step="0.05">
|
||||
</div>
|
||||
</div>
|
||||
<div class="size-field">
|
||||
<label for="smooth-checkbox">Entry</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="smooth-checkbox">
|
||||
Smooth (round off the seam with the previous section)
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
137
src/config.js
137
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
|
||||
|
|
@ -39,6 +40,31 @@ export const BUS = {
|
|||
|
||||
maxSeats: 5,
|
||||
|
||||
// The invisible box kids ride in, local to the chassis (0,0 = chassis
|
||||
// center, y down): solid floor + left/right walls, but deliberately no
|
||||
// ceiling fixture at all - see Bus.js's _buildCompartment. A kid whose
|
||||
// own local Y (rotation-corrected relative to the chassis) rises above
|
||||
// compartmentTopY has nothing left to collide with and is airborne; the
|
||||
// same check runs both directions each tick (see KidManager), so falling
|
||||
// back below it while inside compartmentHalfWidth re-contains the kid.
|
||||
// Tune by playtesting - not physically derived.
|
||||
compartmentHalfWidth: 75 * WORLD_SCALE,
|
||||
compartmentWallThickness: 10 * WORLD_SCALE,
|
||||
compartmentFloorThickness: 10 * WORLD_SCALE,
|
||||
compartmentFloorY: 12 * WORLD_SCALE,
|
||||
compartmentTopY: -60 * WORLD_SCALE,
|
||||
// The clamp in KidManager only makes sense as a same-tick tunnel-through
|
||||
// guard against the compartment's OWN fixtures - a kid further than this
|
||||
// from the chassis was never inside the compartment to begin with, and
|
||||
// local-frame coordinates alone can't tell "just tunneled through" apart
|
||||
// from "resting somewhere else in the level entirely" without a distance
|
||||
// check like this (a kid at rest far away can still end up with local
|
||||
// coordinates that look like a boundary violation purely because the
|
||||
// chassis itself has moved since). Generous enough to still catch a
|
||||
// genuinely fast tunnel-through, nowhere near large enough to reach a kid
|
||||
// that's actually elsewhere on the level.
|
||||
compartmentClampRadius: 400 * WORLD_SCALE,
|
||||
|
||||
// Density/friction/stiffness/damping are dimensionless ratios, not pixel
|
||||
// distances - they don't scale with WORLD_SCALE.
|
||||
chassisDensity: 0.0015,
|
||||
|
|
@ -54,16 +80,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,43 +111,102 @@ 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
|
||||
// control would feel wrong on a bus that's now moving quickly.
|
||||
maxLeanAngularVelocity: 0.13,
|
||||
leanTorqueStep: 0.018,
|
||||
// Cut by more than half (both the cap and the per-tick torque step) for a
|
||||
// much slower, more gradual lean - easier to fine-tune mid-air at the
|
||||
// cost of snappiness.
|
||||
maxLeanAngularVelocity: 0.06,
|
||||
leanTorqueStep: 0.004,
|
||||
};
|
||||
|
||||
// Kid ejection is no longer impact-triggered (see KidManager/Bus's
|
||||
// compartment box) - GFORCE now only drives the DEBUG g-force readout in
|
||||
// PlayScene, kept because it's still a useful "how hard did that landing
|
||||
// hit" diagnostic on its own.
|
||||
export const GFORCE = {
|
||||
// Scaled with WORLD_SCALE so "meters" keeps the same real-world meaning
|
||||
// relative to the bus, and ejectThresholdG doesn't need re-tuning.
|
||||
// relative to the bus.
|
||||
pixelsPerMeter: 50 * WORLD_SCALE,
|
||||
ejectThresholdG: 6, // primary tuning knob - not physically derived, tune by playtesting
|
||||
gracePeriodMs: 500,
|
||||
ejectImpulseMagnitude: 0.03 * WORLD_SCALE,
|
||||
// A single hard landing can stay above threshold for several consecutive
|
||||
// physics ticks; without a cooldown that would eject several kids from one
|
||||
// impact instead of one. Debounces ejections to one per impact.
|
||||
ejectCooldownMs: 400,
|
||||
};
|
||||
|
||||
export const KID = {
|
||||
radius: 14 * WORLD_SCALE,
|
||||
density: 0.001,
|
||||
friction: 0.6,
|
||||
seatStiffness: 0.22,
|
||||
seatDamping: 0.1,
|
||||
seatLength: 4 * WORLD_SCALE,
|
||||
settleTimeMs: 2000,
|
||||
// While outside the compartment (past the open top - see
|
||||
// BUS.compartmentTopY), a kid ignores the world's real gravity entirely
|
||||
// and KidManager applies this fraction of it instead (see
|
||||
// KidManager._onBeforeUpdate) - horizontal velocity is untouched either
|
||||
// way (this game's gravity is purely vertical), so a lighter fall is all
|
||||
// it takes for a kid to hang in the air and arc much further than the
|
||||
// bus does, Snuggle-Truck style. Primary tuning knob for "how floaty,"
|
||||
// not physically derived.
|
||||
ejectedGravityScale: 0.35,
|
||||
// How slow (raw Matter velocity magnitude, same convention as everything
|
||||
// else that reads body.velocity directly in this codebase) an ejected
|
||||
// kid needs to be moving to count as "settled" outside the bus - used by
|
||||
// PlayScene to delay the all-kids-lost failure until every kid has
|
||||
// actually landed and stopped, not the instant the last one leaves the
|
||||
// compartment, so a jump that pops everyone loose still leaves a window
|
||||
// to drive back underneath and catch them. Calibrated via a headless
|
||||
// matter-js simulation of a kid landing with realistic horizontal speed
|
||||
// under ejectedGravityScale: settles below this within ~3s of landing,
|
||||
// fully stops within ~10s - tune by playtesting if that feels off.
|
||||
restSpeedThreshold: 1,
|
||||
// Must stay below restSpeedThreshold continuously for this long to count
|
||||
// as settled, not just an instant - a kid arcing under weak gravity can
|
||||
// pass through near-zero speed for a single tick right at the peak of
|
||||
// its jump while still very much airborne (about to fall again, not
|
||||
// landed), so a momentary dip alone isn't proof it's actually stopped.
|
||||
settleDurationMs: 500,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// engine-heavy.mp3 loops for the whole level, pitched by EngineSound via
|
||||
// Sound.setRate() to track the rear wheel's angular velocity (see
|
||||
// Bus.applyIntent - it's the only wheel driven directly, so its spin IS the
|
||||
// engine's rev signal). minRate is idle (throttle released, wheel spun down
|
||||
// by wheelFrictionAir), maxRate is pinned to BUS.maxWheelAngularVelocity.
|
||||
export const ENGINE = {
|
||||
minRate: 0.75,
|
||||
maxRate: 1.9,
|
||||
volume: 0.5,
|
||||
// Per-frame lerp factor from the current playback rate toward the target
|
||||
// rate - smooths over physics-tick-to-tick noise (wheel bounce, brief
|
||||
// airborne spin) so the pitch rises/falls like a real engine instead of
|
||||
// stair-stepping with every torque tick.
|
||||
rateSmoothing: 0.12,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,387 @@
|
|||
import { generateWave } from './terrainHelpers.js';
|
||||
import { WORLD_SCALE } from '../../config.js';
|
||||
|
||||
const END_X = 3600 * 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);
|
||||
|
||||
// Generated by editor.html - review before dropping into src/data/levels/.
|
||||
export default {
|
||||
id: 'level01',
|
||||
name: 'First Hills',
|
||||
description: 'Gentle rolling hills - learn to drive and lean.',
|
||||
kidsAboard: 5,
|
||||
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
|
||||
id: "level01",
|
||||
name: "GoFundMe",
|
||||
description: "Get the Money!",
|
||||
kidsAboard: 4,
|
||||
startPosition: { x: 200, y: 700 },
|
||||
startAngle: 0,
|
||||
terrain: [{ points }],
|
||||
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: 840 },
|
||||
{ x: 1140, y: 836 },
|
||||
{ x: 1210, y: 830 },
|
||||
{ x: 1280, y: 820 },
|
||||
{ x: 1350, y: 808 },
|
||||
{ x: 1420, y: 792 },
|
||||
{ x: 1490, y: 776 },
|
||||
{ x: 1560, y: 760 },
|
||||
{ x: 1630, y: 744 },
|
||||
{ x: 1700, y: 728 },
|
||||
{ x: 1770, y: 712 },
|
||||
{ x: 1840, y: 696 },
|
||||
{ x: 1910, y: 680 },
|
||||
{ x: 1980, y: 664 },
|
||||
{ x: 2050, y: 648 },
|
||||
{ x: 2120, y: 632 },
|
||||
{ x: 2190, y: 616 },
|
||||
{ x: 2260, y: 600 },
|
||||
{ x: 2330, y: 584 },
|
||||
{ x: 2400, y: 568 },
|
||||
{ x: 2470, y: 568 },
|
||||
{ x: 2540, y: 568 },
|
||||
{ x: 2610, y: 568 },
|
||||
{ x: 2680, y: 568 },
|
||||
{ x: 2750, y: 568 },
|
||||
{ x: 2820, y: 568 },
|
||||
{ x: 2890, y: 568 },
|
||||
{ x: 2960, y: 568 },
|
||||
{ x: 3030, y: 568 },
|
||||
{ x: 3100, y: 568 },
|
||||
{ x: 3170, y: 568 },
|
||||
{ x: 3240, y: 568 },
|
||||
{ x: 3310, y: 568 },
|
||||
{ x: 3380, y: 568 },
|
||||
{ x: 3450, y: 568 },
|
||||
{ x: 3520, y: 568 },
|
||||
{ x: 3590, y: 568 },
|
||||
{ x: 3660, y: 568 },
|
||||
{ x: 3730, y: 568 },
|
||||
{ x: 3800, y: 568 },
|
||||
{ x: 3870, y: 568 },
|
||||
{ x: 3940, y: 564 },
|
||||
{ x: 4010, y: 554 },
|
||||
{ x: 4080, y: 538 },
|
||||
{ x: 4150, y: 518 },
|
||||
{ x: 4220, y: 494 },
|
||||
{ x: 4290, y: 468 },
|
||||
{ x: 4360, y: 444 },
|
||||
{ x: 4430, y: 444 },
|
||||
] },
|
||||
{ points: [
|
||||
{ x: 4780, y: 456 },
|
||||
{ x: 4850, y: 464 },
|
||||
{ x: 4920, y: 486 },
|
||||
{ x: 4990, y: 512 },
|
||||
{ x: 5060, y: 540 },
|
||||
{ x: 5130, y: 560 },
|
||||
{ x: 5200, y: 568 },
|
||||
{ x: 5270, y: 576 },
|
||||
{ x: 5340, y: 586 },
|
||||
{ x: 5410, y: 596 },
|
||||
{ x: 5480, y: 610 },
|
||||
{ x: 5550, y: 624 },
|
||||
{ x: 5620, y: 640 },
|
||||
{ x: 5690, y: 656 },
|
||||
{ x: 5760, y: 672 },
|
||||
{ x: 5830, y: 688 },
|
||||
{ x: 5900, y: 704 },
|
||||
{ x: 5970, y: 720 },
|
||||
{ x: 6040, y: 736 },
|
||||
{ x: 6110, y: 752 },
|
||||
{ x: 6180, y: 768 },
|
||||
{ x: 6250, y: 784 },
|
||||
{ x: 6320, y: 800 },
|
||||
{ x: 6390, y: 816 },
|
||||
{ x: 6460, y: 832 },
|
||||
{ x: 6530, y: 848 },
|
||||
{ x: 6600, y: 864 },
|
||||
{ x: 6670, y: 880 },
|
||||
{ x: 6740, y: 894 },
|
||||
{ x: 6810, y: 910 },
|
||||
{ x: 6880, y: 922 },
|
||||
{ x: 6950, y: 934 },
|
||||
{ x: 7020, y: 944 },
|
||||
{ x: 7090, y: 954 },
|
||||
{ x: 7160, y: 968 },
|
||||
{ x: 7230, y: 980 },
|
||||
{ x: 7300, y: 994 },
|
||||
{ x: 7370, y: 1008 },
|
||||
{ x: 7440, y: 1020 },
|
||||
{ x: 7510, y: 1034 },
|
||||
{ x: 7580, y: 1046 },
|
||||
{ x: 7650, y: 1056 },
|
||||
{ x: 7720, y: 1066 },
|
||||
{ x: 7790, y: 1074 },
|
||||
{ x: 7860, y: 1080 },
|
||||
{ x: 7930, y: 1082 },
|
||||
{ x: 8000, y: 1084 },
|
||||
{ x: 8000, y: 1086 },
|
||||
{ x: 8070, y: 1088 },
|
||||
{ x: 8140, y: 1090 },
|
||||
{ x: 8210, y: 1090 },
|
||||
{ x: 8280, y: 1092 },
|
||||
{ x: 8350, y: 1092 },
|
||||
{ x: 8420, y: 1092 },
|
||||
{ x: 8490, y: 1092 },
|
||||
{ x: 8560, y: 1092 },
|
||||
{ x: 8630, y: 1092 },
|
||||
{ x: 8700, y: 1092 },
|
||||
{ x: 8770, y: 1092 },
|
||||
{ x: 8840, y: 1092 },
|
||||
{ x: 8910, y: 1092 },
|
||||
{ x: 8980, y: 1092 },
|
||||
{ x: 9050, y: 1092 },
|
||||
{ x: 9120, y: 1092 },
|
||||
{ x: 9190, y: 1092 },
|
||||
{ x: 9260, y: 1092 },
|
||||
{ x: 9330, y: 1092 },
|
||||
{ x: 9400, y: 1092 },
|
||||
{ x: 9470, y: 1092 },
|
||||
{ x: 9540, y: 1090 },
|
||||
{ x: 9610, y: 1088 },
|
||||
{ x: 9680, y: 1084 },
|
||||
{ x: 9750, y: 1080 },
|
||||
{ x: 9820, y: 1074 },
|
||||
{ x: 9890, y: 1064 },
|
||||
{ x: 9960, y: 1048 },
|
||||
{ x: 10030, y: 1030 },
|
||||
{ x: 10100, y: 1010 },
|
||||
{ x: 10170, y: 988 },
|
||||
{ x: 10240, y: 970 },
|
||||
{ x: 10310, y: 954 },
|
||||
{ x: 10380, y: 944 },
|
||||
{ x: 10450, y: 942 },
|
||||
{ x: 10520, y: 944 },
|
||||
{ x: 10590, y: 954 },
|
||||
{ x: 10660, y: 970 },
|
||||
{ x: 10730, y: 988 },
|
||||
{ x: 10800, y: 1010 },
|
||||
{ x: 10870, y: 1010 },
|
||||
{ x: 10940, y: 1010 },
|
||||
{ x: 11010, y: 1010 },
|
||||
{ x: 11080, y: 1010 },
|
||||
{ x: 11150, y: 1010 },
|
||||
{ x: 11220, y: 1010 },
|
||||
{ x: 11290, y: 1010 },
|
||||
{ x: 11360, y: 1010 },
|
||||
{ x: 11430, y: 1010 },
|
||||
{ x: 11500, y: 1010 },
|
||||
{ x: 11570, y: 1010 },
|
||||
{ x: 11640, y: 1010 },
|
||||
{ x: 11710, y: 1010 },
|
||||
{ x: 11780, y: 1010 },
|
||||
{ x: 11850, y: 1010 },
|
||||
{ x: 11920, y: 1010 },
|
||||
{ x: 11990, y: 1010 },
|
||||
{ x: 12060, y: 1010 },
|
||||
{ x: 12130, y: 1010 },
|
||||
{ x: 12200, y: 1010 },
|
||||
{ x: 12270, y: 1010 },
|
||||
{ x: 12340, y: 1010 },
|
||||
{ x: 12410, y: 1010 },
|
||||
{ x: 12480, y: 1010 },
|
||||
{ x: 12550, y: 1010 },
|
||||
{ x: 12620, y: 1010 },
|
||||
{ x: 12690, y: 1010 },
|
||||
{ x: 12760, y: 1010 },
|
||||
{ x: 12830, y: 1010 },
|
||||
{ x: 12900, y: 1010 },
|
||||
{ x: 12970, y: 1010 },
|
||||
{ x: 13040, y: 1010 },
|
||||
{ x: 13110, y: 1010 },
|
||||
{ x: 13180, y: 1010 },
|
||||
{ x: 13250, y: 1010 },
|
||||
{ x: 13320, y: 1010 },
|
||||
{ x: 13390, y: 1010 },
|
||||
{ x: 13460, y: 1010 },
|
||||
{ x: 13530, y: 1010 },
|
||||
{ x: 13600, y: 1010 },
|
||||
{ x: 13670, y: 1008 },
|
||||
{ x: 13740, y: 1004 },
|
||||
{ x: 13810, y: 990 },
|
||||
{ x: 13880, y: 970 },
|
||||
{ x: 13950, y: 942 },
|
||||
{ x: 14020, y: 908 },
|
||||
{ x: 14090, y: 872 },
|
||||
{ x: 14160, y: 838 },
|
||||
{ x: 14230, y: 838 },
|
||||
] },
|
||||
{ points: [
|
||||
{ x: 14580, y: 850 },
|
||||
{ x: 14650, y: 862 },
|
||||
{ x: 14720, y: 890 },
|
||||
{ x: 14790, y: 930 },
|
||||
{ x: 14860, y: 968 },
|
||||
{ x: 14930, y: 998 },
|
||||
{ x: 15000, y: 1010 },
|
||||
{ x: 15070, y: 1022 },
|
||||
{ x: 15140, y: 1030 },
|
||||
{ x: 15210, y: 1032 },
|
||||
{ x: 15280, y: 1030 },
|
||||
{ x: 15350, y: 1022 },
|
||||
{ x: 15420, y: 1010 },
|
||||
{ x: 15490, y: 998 },
|
||||
{ x: 15560, y: 986 },
|
||||
{ x: 15630, y: 972 },
|
||||
{ x: 15700, y: 960 },
|
||||
{ x: 15770, y: 946 },
|
||||
{ x: 15840, y: 932 },
|
||||
{ x: 15910, y: 920 },
|
||||
{ x: 15980, y: 908 },
|
||||
{ x: 16050, y: 898 },
|
||||
{ x: 16120, y: 888 },
|
||||
{ x: 16190, y: 880 },
|
||||
{ x: 16260, y: 874 },
|
||||
{ x: 16330, y: 870 },
|
||||
{ x: 16400, y: 870 },
|
||||
{ x: 16470, y: 870 },
|
||||
{ x: 16540, y: 870 },
|
||||
{ x: 16610, y: 870 },
|
||||
{ x: 16680, y: 870 },
|
||||
{ x: 16750, y: 870 },
|
||||
{ x: 16820, y: 870 },
|
||||
{ x: 16890, y: 870 },
|
||||
{ x: 16960, y: 870 },
|
||||
{ x: 17030, y: 870 },
|
||||
{ x: 17100, y: 870 },
|
||||
{ x: 17170, y: 870 },
|
||||
{ x: 17240, y: 870 },
|
||||
{ x: 17310, y: 870 },
|
||||
{ x: 17380, y: 870 },
|
||||
{ x: 17450, y: 870 },
|
||||
{ x: 17520, y: 870 },
|
||||
{ x: 17590, y: 870 },
|
||||
{ x: 17660, y: 870 },
|
||||
{ x: 17730, y: 870 },
|
||||
{ x: 17800, y: 870 },
|
||||
{ x: 17870, y: 870 },
|
||||
{ x: 17940, y: 868 },
|
||||
{ x: 18010, y: 864 },
|
||||
{ x: 18080, y: 858 },
|
||||
{ x: 18150, y: 848 },
|
||||
{ x: 18220, y: 838 },
|
||||
{ x: 18290, y: 824 },
|
||||
{ x: 18360, y: 812 },
|
||||
{ x: 18430, y: 798 },
|
||||
{ x: 18500, y: 786 },
|
||||
{ x: 18570, y: 772 },
|
||||
{ x: 18640, y: 760 },
|
||||
{ x: 18710, y: 746 },
|
||||
{ x: 18780, y: 732 },
|
||||
{ x: 18850, y: 720 },
|
||||
{ x: 18920, y: 706 },
|
||||
{ x: 18990, y: 694 },
|
||||
{ x: 19060, y: 680 },
|
||||
{ x: 19130, y: 666 },
|
||||
{ x: 19200, y: 654 },
|
||||
{ x: 19270, y: 640 },
|
||||
{ x: 19340, y: 628 },
|
||||
{ x: 19410, y: 614 },
|
||||
{ x: 19480, y: 600 },
|
||||
{ x: 19500, y: 598 },
|
||||
{ x: 19570, y: 582 },
|
||||
{ x: 19640, y: 566 },
|
||||
{ x: 19710, y: 550 },
|
||||
{ x: 19780, y: 534 },
|
||||
{ x: 19850, y: 518 },
|
||||
{ x: 19920, y: 502 },
|
||||
{ x: 19990, y: 486 },
|
||||
{ x: 20060, y: 470 },
|
||||
{ x: 20130, y: 454 },
|
||||
{ x: 20200, y: 438 },
|
||||
{ x: 20270, y: 422 },
|
||||
{ x: 20340, y: 406 },
|
||||
{ x: 20410, y: 390 },
|
||||
{ x: 20480, y: 374 },
|
||||
{ x: 20550, y: 358 },
|
||||
{ x: 20620, y: 342 },
|
||||
{ x: 20690, y: 326 },
|
||||
{ x: 20760, y: 310 },
|
||||
{ x: 20830, y: 294 },
|
||||
{ x: 20900, y: 278 },
|
||||
{ x: 20970, y: 378 },
|
||||
{ x: 21040, y: 462 },
|
||||
{ x: 21110, y: 518 },
|
||||
{ x: 21180, y: 538 },
|
||||
{ x: 21250, y: 518 },
|
||||
{ x: 21320, y: 462 },
|
||||
{ x: 21390, y: 378 },
|
||||
{ x: 21460, y: 278 },
|
||||
{ x: 21530, y: 294 },
|
||||
{ x: 21600, y: 310 },
|
||||
{ x: 21670, y: 326 },
|
||||
{ x: 21740, y: 342 },
|
||||
{ x: 21810, y: 358 },
|
||||
{ x: 21880, y: 374 },
|
||||
{ x: 21950, y: 390 },
|
||||
{ x: 22020, y: 406 },
|
||||
{ x: 22090, y: 422 },
|
||||
{ x: 22160, y: 438 },
|
||||
{ x: 22230, y: 454 },
|
||||
{ x: 22300, y: 470 },
|
||||
{ x: 22370, y: 486 },
|
||||
{ x: 22440, y: 502 },
|
||||
{ x: 22510, y: 518 },
|
||||
{ x: 22580, y: 534 },
|
||||
{ x: 22650, y: 550 },
|
||||
{ x: 22720, y: 566 },
|
||||
{ x: 22790, y: 582 },
|
||||
{ x: 22860, y: 598 },
|
||||
{ x: 22930, y: 614 },
|
||||
{ x: 23000, y: 626 },
|
||||
{ x: 23070, y: 636 },
|
||||
{ x: 23140, y: 642 },
|
||||
{ x: 23210, y: 646 },
|
||||
{ x: 23280, y: 646 },
|
||||
{ x: 23350, y: 646 },
|
||||
{ x: 23420, y: 646 },
|
||||
{ x: 23490, y: 646 },
|
||||
{ x: 23560, y: 646 },
|
||||
{ x: 23630, y: 646 },
|
||||
{ x: 23700, y: 646 },
|
||||
{ x: 23770, y: 646 },
|
||||
{ x: 23840, y: 646 },
|
||||
{ x: 23910, y: 646 },
|
||||
{ x: 23980, y: 646 },
|
||||
{ x: 24050, y: 646 },
|
||||
{ x: 24120, y: 646 },
|
||||
{ x: 24190, y: 646 },
|
||||
{ x: 24260, y: 646 },
|
||||
{ x: 24330, y: 646 },
|
||||
{ x: 24400, y: 646 },
|
||||
{ x: 24470, y: 646 },
|
||||
{ x: 24540, y: 646 },
|
||||
{ x: 24610, y: 646 },
|
||||
{ x: 24680, y: 646 },
|
||||
{ x: 24750, y: 646 },
|
||||
{ x: 24820, y: 646 },
|
||||
{ x: 24890, y: 646 },
|
||||
{ x: 24960, y: 646 },
|
||||
{ x: 25030, y: 646 },
|
||||
{ x: 25100, y: 646 },
|
||||
{ x: 25170, y: 646 },
|
||||
{ x: 25240, y: 646 },
|
||||
{ x: 25310, y: 646 },
|
||||
{ x: 25380, y: 646 },
|
||||
{ x: 25450, y: 646 },
|
||||
{ x: 25520, y: 646 },
|
||||
{ x: 25590, y: 646 },
|
||||
{ x: 25660, y: 646 },
|
||||
] },
|
||||
],
|
||||
obstacles: [],
|
||||
goal: { x: END_X - 120 * WORLD_SCALE, y: BASE_Y - 120 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 320 * WORLD_SCALE },
|
||||
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
|
||||
goal: { x: 25420, y: 406, width: 280, height: 640 },
|
||||
cameraBounds: { x: -600, y: -522, width: 27060, height: 2414 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,473 @@
|
|||
// Generated by editor.html - review before dropping into src/data/levels/.
|
||||
export default {
|
||||
id: "level04",
|
||||
name: "Mega Drop",
|
||||
description: "",
|
||||
kidsAboard: 5,
|
||||
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 },
|
||||
{ x: 9470, y: 1316 },
|
||||
{ x: 9540, y: 1332 },
|
||||
{ x: 9610, y: 1348 },
|
||||
{ x: 9680, y: 1364 },
|
||||
{ x: 9750, y: 1380 },
|
||||
{ x: 9820, y: 1396 },
|
||||
{ x: 9890, y: 1412 },
|
||||
{ x: 9960, y: 1428 },
|
||||
{ x: 10030, y: 1444 },
|
||||
{ x: 10100, y: 1460 },
|
||||
{ x: 10170, y: 1476 },
|
||||
{ x: 10240, y: 1492 },
|
||||
{ x: 10310, y: 1508 },
|
||||
{ x: 10380, y: 1524 },
|
||||
{ x: 10450, y: 1540 },
|
||||
{ x: 10520, y: 1556 },
|
||||
{ x: 10590, y: 1572 },
|
||||
{ x: 10660, y: 1588 },
|
||||
{ x: 10730, y: 1604 },
|
||||
{ x: 10800, y: 1620 },
|
||||
{ x: 10870, y: 1636 },
|
||||
{ x: 10940, y: 1652 },
|
||||
{ x: 11010, y: 1668 },
|
||||
{ x: 11080, y: 1684 },
|
||||
{ x: 11150, y: 1700 },
|
||||
{ x: 11220, y: 1716 },
|
||||
{ x: 11290, y: 1732 },
|
||||
{ x: 11360, y: 1748 },
|
||||
{ x: 11430, y: 1764 },
|
||||
{ x: 11500, y: 1780 },
|
||||
{ x: 11570, y: 1796 },
|
||||
{ x: 11640, y: 1812 },
|
||||
{ x: 11710, y: 1828 },
|
||||
{ x: 11780, y: 1844 },
|
||||
{ x: 11850, y: 1860 },
|
||||
{ x: 11920, y: 1876 },
|
||||
{ x: 11990, y: 1892 },
|
||||
{ x: 12060, y: 1908 },
|
||||
{ x: 12130, y: 1924 },
|
||||
{ x: 12200, y: 1940 },
|
||||
{ x: 12270, y: 1956 },
|
||||
{ x: 12340, y: 1972 },
|
||||
{ x: 12410, y: 1988 },
|
||||
{ x: 12480, y: 2004 },
|
||||
{ x: 12550, y: 2020 },
|
||||
{ x: 12620, y: 2036 },
|
||||
{ x: 12690, y: 2052 },
|
||||
{ x: 12760, y: 2068 },
|
||||
{ x: 12830, y: 2084 },
|
||||
{ x: 12900, y: 2100 },
|
||||
{ x: 12970, y: 2116 },
|
||||
{ x: 13040, y: 2132 },
|
||||
{ x: 13110, y: 2148 },
|
||||
{ x: 13180, y: 2164 },
|
||||
{ x: 13250, y: 2180 },
|
||||
{ x: 13320, y: 2196 },
|
||||
{ x: 13390, y: 2212 },
|
||||
{ x: 13460, y: 2228 },
|
||||
{ x: 13530, y: 2244 },
|
||||
{ x: 13600, y: 2260 },
|
||||
{ x: 13670, y: 2276 },
|
||||
{ x: 13740, y: 2292 },
|
||||
{ x: 13810, y: 2308 },
|
||||
{ x: 13880, y: 2324 },
|
||||
{ x: 13950, y: 2340 },
|
||||
{ x: 14020, y: 2356 },
|
||||
{ x: 14090, y: 2372 },
|
||||
{ x: 14160, y: 2388 },
|
||||
{ x: 14230, y: 2404 },
|
||||
{ x: 14300, y: 2420 },
|
||||
{ x: 14370, y: 2436 },
|
||||
{ x: 14440, y: 2452 },
|
||||
{ x: 14510, y: 2468 },
|
||||
{ x: 14580, y: 2484 },
|
||||
{ x: 14650, y: 2500 },
|
||||
{ x: 14720, y: 2516 },
|
||||
{ x: 14790, y: 2532 },
|
||||
{ x: 14860, y: 2548 },
|
||||
{ x: 14930, y: 2564 },
|
||||
{ x: 15000, y: 2580 },
|
||||
{ x: 15070, y: 2596 },
|
||||
{ x: 15140, y: 2612 },
|
||||
{ x: 15210, y: 2628 },
|
||||
{ x: 15280, y: 2644 },
|
||||
{ x: 15350, y: 2660 },
|
||||
{ x: 15420, y: 2676 },
|
||||
{ x: 15490, y: 2692 },
|
||||
{ x: 15560, y: 2708 },
|
||||
{ x: 15630, y: 2724 },
|
||||
{ x: 15700, y: 2740 },
|
||||
{ x: 15770, y: 2756 },
|
||||
{ x: 15840, y: 2772 },
|
||||
{ x: 15910, y: 2788 },
|
||||
{ x: 15980, y: 2804 },
|
||||
{ x: 16050, y: 2820 },
|
||||
{ x: 16120, y: 2836 },
|
||||
{ x: 16190, y: 2852 },
|
||||
{ x: 16260, y: 2868 },
|
||||
{ x: 16330, y: 2884 },
|
||||
{ x: 16400, y: 2900 },
|
||||
{ x: 16470, y: 2900 },
|
||||
{ x: 16540, y: 2900 },
|
||||
{ x: 16610, y: 2900 },
|
||||
{ x: 16680, y: 2900 },
|
||||
{ x: 16750, y: 2900 },
|
||||
{ x: 16820, y: 2900 },
|
||||
{ x: 16890, y: 2900 },
|
||||
{ x: 16960, y: 2900 },
|
||||
{ x: 17030, y: 2900 },
|
||||
{ x: 17100, y: 2900 },
|
||||
{ x: 17170, y: 2900 },
|
||||
{ x: 17240, y: 2900 },
|
||||
{ x: 17310, y: 2900 },
|
||||
{ x: 17380, y: 2900 },
|
||||
{ x: 17450, y: 2900 },
|
||||
{ x: 17520, y: 2900 },
|
||||
{ x: 17590, y: 2900 },
|
||||
{ x: 17660, y: 2900 },
|
||||
{ x: 17730, y: 2900 },
|
||||
{ x: 17800, y: 2900 },
|
||||
{ x: 17870, y: 2866 },
|
||||
{ x: 17940, y: 2830 },
|
||||
{ x: 18010, y: 2796 },
|
||||
{ x: 18080, y: 2760 },
|
||||
{ x: 18150, y: 2726 },
|
||||
{ x: 18220, y: 2690 },
|
||||
{ x: 18290, y: 2656 },
|
||||
{ x: 18360, y: 2620 },
|
||||
{ x: 18430, y: 2620 },
|
||||
] },
|
||||
{ points: [
|
||||
{ x: 18780, y: 2740 },
|
||||
{ x: 18850, y: 2752 },
|
||||
{ x: 18920, y: 2782 },
|
||||
{ x: 18990, y: 2820 },
|
||||
{ x: 19060, y: 2858 },
|
||||
{ x: 19130, y: 2888 },
|
||||
{ x: 19200, y: 2900 },
|
||||
{ x: 19270, y: 2928 },
|
||||
{ x: 19340, y: 2952 },
|
||||
{ x: 19410, y: 2972 },
|
||||
{ x: 19480, y: 2986 },
|
||||
{ x: 19550, y: 2990 },
|
||||
{ x: 19620, y: 2986 },
|
||||
{ x: 19690, y: 2972 },
|
||||
{ x: 19760, y: 2952 },
|
||||
{ x: 19830, y: 2928 },
|
||||
{ x: 19900, y: 2900 },
|
||||
{ x: 19970, y: 2872 },
|
||||
{ x: 20040, y: 2848 },
|
||||
{ x: 20110, y: 2828 },
|
||||
{ x: 20180, y: 2814 },
|
||||
{ x: 20250, y: 2810 },
|
||||
{ x: 20320, y: 2814 },
|
||||
{ x: 20390, y: 2828 },
|
||||
{ x: 20460, y: 2848 },
|
||||
{ x: 20530, y: 2872 },
|
||||
{ x: 20600, y: 2900 },
|
||||
{ x: 20670, y: 2940 },
|
||||
{ x: 20740, y: 2980 },
|
||||
{ x: 20810, y: 3018 },
|
||||
{ x: 20880, y: 3052 },
|
||||
{ x: 20950, y: 3084 },
|
||||
{ x: 21020, y: 3110 },
|
||||
{ x: 21090, y: 3132 },
|
||||
{ x: 21160, y: 3148 },
|
||||
{ x: 21230, y: 3156 },
|
||||
{ x: 21300, y: 3160 },
|
||||
{ x: 21370, y: 3156 },
|
||||
{ x: 21440, y: 3148 },
|
||||
{ x: 21510, y: 3132 },
|
||||
{ x: 21580, y: 3110 },
|
||||
{ x: 21650, y: 3084 },
|
||||
{ x: 21720, y: 3052 },
|
||||
{ x: 21790, y: 3018 },
|
||||
{ x: 21860, y: 2980 },
|
||||
{ x: 21930, y: 2940 },
|
||||
{ x: 22000, y: 2900 },
|
||||
{ x: 22070, y: 2866 },
|
||||
{ x: 22140, y: 2830 },
|
||||
{ x: 22210, y: 2796 },
|
||||
{ x: 22280, y: 2760 },
|
||||
{ x: 22350, y: 2726 },
|
||||
{ x: 22420, y: 2690 },
|
||||
{ x: 22490, y: 2656 },
|
||||
{ x: 22560, y: 2620 },
|
||||
{ x: 22630, y: 2620 },
|
||||
] },
|
||||
{ points: [
|
||||
{ x: 22980, y: 2740 },
|
||||
{ x: 23050, y: 2752 },
|
||||
{ x: 23120, y: 2782 },
|
||||
{ x: 23190, y: 2820 },
|
||||
{ x: 23260, y: 2858 },
|
||||
{ x: 23330, y: 2888 },
|
||||
{ x: 23400, y: 2900 },
|
||||
{ x: 23470, y: 2884 },
|
||||
{ x: 23540, y: 2868 },
|
||||
{ x: 23610, y: 2852 },
|
||||
{ x: 23680, y: 2836 },
|
||||
{ x: 23750, y: 2820 },
|
||||
{ x: 23820, y: 2804 },
|
||||
{ x: 23890, y: 2788 },
|
||||
{ x: 23960, y: 2772 },
|
||||
{ x: 24030, y: 2756 },
|
||||
{ x: 24100, y: 2740 },
|
||||
{ x: 24170, y: 2724 },
|
||||
{ x: 24240, y: 2708 },
|
||||
{ x: 24310, y: 2692 },
|
||||
{ x: 24380, y: 2676 },
|
||||
{ x: 24450, y: 2660 },
|
||||
{ x: 24520, y: 2644 },
|
||||
{ x: 24590, y: 2628 },
|
||||
{ x: 24660, y: 2612 },
|
||||
{ x: 24730, y: 2596 },
|
||||
{ x: 24800, y: 2580 },
|
||||
{ x: 24870, y: 2564 },
|
||||
{ x: 24940, y: 2548 },
|
||||
{ x: 25010, y: 2532 },
|
||||
{ x: 25080, y: 2516 },
|
||||
{ x: 25150, y: 2500 },
|
||||
{ x: 25220, y: 2484 },
|
||||
{ x: 25290, y: 2468 },
|
||||
{ x: 25360, y: 2452 },
|
||||
{ x: 25430, y: 2436 },
|
||||
{ x: 25500, y: 2420 },
|
||||
{ x: 25570, y: 2404 },
|
||||
{ x: 25640, y: 2388 },
|
||||
{ x: 25710, y: 2372 },
|
||||
{ x: 25780, y: 2356 },
|
||||
{ x: 25850, y: 2340 },
|
||||
{ x: 25920, y: 2324 },
|
||||
{ x: 25990, y: 2308 },
|
||||
{ x: 26060, y: 2292 },
|
||||
{ x: 26130, y: 2276 },
|
||||
{ x: 26200, y: 2260 },
|
||||
{ x: 26270, y: 2244 },
|
||||
{ x: 26340, y: 2228 },
|
||||
{ x: 26410, y: 2212 },
|
||||
{ x: 26480, y: 2196 },
|
||||
{ x: 26550, y: 2180 },
|
||||
{ x: 26620, y: 2164 },
|
||||
{ x: 26690, y: 2148 },
|
||||
{ x: 26760, y: 2132 },
|
||||
{ x: 26830, y: 2116 },
|
||||
{ x: 26900, y: 2100 },
|
||||
{ x: 26970, y: 2084 },
|
||||
{ x: 27040, y: 2068 },
|
||||
{ x: 27110, y: 2052 },
|
||||
{ x: 27180, y: 2036 },
|
||||
{ x: 27250, y: 2020 },
|
||||
{ x: 27320, y: 2004 },
|
||||
{ x: 27390, y: 1988 },
|
||||
{ x: 27460, y: 1972 },
|
||||
{ x: 27530, y: 1956 },
|
||||
{ x: 27600, y: 1940 },
|
||||
{ x: 27670, y: 1956 },
|
||||
{ x: 27740, y: 1972 },
|
||||
{ x: 27810, y: 1988 },
|
||||
{ x: 27880, y: 2004 },
|
||||
{ x: 27950, y: 2020 },
|
||||
{ x: 28020, y: 2036 },
|
||||
{ x: 28090, y: 2052 },
|
||||
{ x: 28160, y: 2068 },
|
||||
{ x: 28230, y: 2084 },
|
||||
{ x: 28300, y: 2100 },
|
||||
{ x: 28370, y: 2116 },
|
||||
{ x: 28440, y: 2132 },
|
||||
{ x: 28510, y: 2148 },
|
||||
{ x: 28580, y: 2164 },
|
||||
{ x: 28650, y: 2180 },
|
||||
{ x: 28720, y: 2196 },
|
||||
{ x: 28790, y: 2212 },
|
||||
{ x: 28860, y: 2228 },
|
||||
{ x: 28930, y: 2244 },
|
||||
{ x: 29000, y: 2260 },
|
||||
{ x: 29070, y: 2260 },
|
||||
{ x: 29140, y: 2260 },
|
||||
{ x: 29210, y: 2260 },
|
||||
{ x: 29280, y: 2260 },
|
||||
{ x: 29350, y: 2260 },
|
||||
{ x: 29420, y: 2260 },
|
||||
{ x: 29490, y: 2260 },
|
||||
{ x: 29560, y: 2260 },
|
||||
{ x: 29630, y: 2260 },
|
||||
{ x: 29700, y: 2260 },
|
||||
{ x: 29770, y: 2260 },
|
||||
{ x: 29840, y: 2260 },
|
||||
{ x: 29910, y: 2260 },
|
||||
{ x: 29980, y: 2260 },
|
||||
{ x: 30050, y: 2260 },
|
||||
{ x: 30120, y: 2260 },
|
||||
{ x: 30190, y: 2260 },
|
||||
{ x: 30260, y: 2260 },
|
||||
{ x: 30330, y: 2260 },
|
||||
{ x: 30400, y: 2260 },
|
||||
{ x: 30470, y: 2260 },
|
||||
{ x: 30540, y: 2260 },
|
||||
{ x: 30610, y: 2260 },
|
||||
{ x: 30680, y: 2260 },
|
||||
{ x: 30750, y: 2260 },
|
||||
{ x: 30820, y: 2260 },
|
||||
{ x: 30890, y: 2260 },
|
||||
{ x: 30960, y: 2260 },
|
||||
{ x: 31030, y: 2260 },
|
||||
{ x: 31100, y: 2260 },
|
||||
{ x: 31170, y: 2260 },
|
||||
{ x: 31240, y: 2260 },
|
||||
{ x: 31310, y: 2260 },
|
||||
{ x: 31380, y: 2260 },
|
||||
{ x: 31450, y: 2260 },
|
||||
{ x: 31520, y: 2260 },
|
||||
{ x: 31590, y: 2260 },
|
||||
{ x: 31660, y: 2260 },
|
||||
{ x: 31730, y: 2260 },
|
||||
{ x: 31800, y: 2260 },
|
||||
] },
|
||||
],
|
||||
obstacles: [],
|
||||
goal: { x: 31560, y: 2020, width: 280, height: 640 },
|
||||
cameraBounds: { x: -600, y: -140, width: 33200, height: 4100 },
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { generateSection, POINT_STEP } 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;
|
||||
// Vertical camera bounds used to be a fixed 1000 regardless of the actual
|
||||
// terrain - fine for the hand-authored levels (which stay near a constant
|
||||
// baseline by design), but an editor sequence can chain multiple
|
||||
// inclines/declines/ditches with no cap on cumulative elevation change, so
|
||||
// a long run of declines could push the terrain (and the bus) below what a
|
||||
// fixed-height bound could ever scroll down to reach - the camera would
|
||||
// clamp at its bounds and the bus would drift off the bottom of the
|
||||
// screen. Bounds are now sized to the level's actual min/max terrain Y,
|
||||
// with generous padding, with this as just a floor for very flat levels.
|
||||
const CAMERA_VERTICAL_PADDING = 400;
|
||||
const CAMERA_MIN_HEIGHT = 1000;
|
||||
|
||||
function scalePoint(p) {
|
||||
return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) };
|
||||
}
|
||||
|
||||
// How far into a "smooth" section's own point run (base design units, same
|
||||
// space sections.js generates in) the blend runs before handing off to the
|
||||
// section's natural, unmodified curve - a fraction of the section's own
|
||||
// width, clamped so a very short section can't have the blend swallow its
|
||||
// whole point run, and a very long one doesn't get an absurdly long taper.
|
||||
const SMOOTH_BLEND_FRACTION = 0.3;
|
||||
const SMOOTH_BLEND_MIN = POINT_STEP * 2;
|
||||
const SMOOTH_BLEND_MAX = 220;
|
||||
|
||||
function lastPair(points) {
|
||||
if (!points || points.length < 2) return null;
|
||||
return [points[points.length - 2], points[points.length - 1]];
|
||||
}
|
||||
|
||||
function smoothstep(u) {
|
||||
const c = Math.max(0, Math.min(1, u));
|
||||
return c * c * (3 - 2 * c);
|
||||
}
|
||||
|
||||
// Rounds off the sharp corner where a section's own point run meets
|
||||
// whatever terrain came immediately before it - from the BUS's
|
||||
// perspective, not the terrain's. A naive fix blends the *height* (fit a
|
||||
// curve through the two boundary points and their tangents), but a
|
||||
// position-matched curve can - and did - swing the instantaneous slope
|
||||
// PAST either boundary's angle to hit both the position and tangent
|
||||
// targets at once, which is a bigger jolt to the chassis than the sharp
|
||||
// corner it was meant to replace.
|
||||
//
|
||||
// This instead blends the ANGLE the bus actually feels: interpolate the
|
||||
// heading smoothly (via smoothstep, so the turn eases in/out rather than
|
||||
// snapping to a constant turn rate) from the incoming heading to the
|
||||
// section's own natural heading at the end of the blend window, then
|
||||
// integrate that heading back into y positions. The angle is then
|
||||
// guaranteed to move monotonically between the two endpoint angles -
|
||||
// no overshoot, so nowhere in the blend does the bus turn harder than the
|
||||
// sharper of the two corners it's smoothing between.
|
||||
//
|
||||
// Because the blended angle profile is only an approximation of the
|
||||
// original curve's true path between those two x's, the blended points
|
||||
// generally won't land exactly back on the original curve's height at the
|
||||
// blend's far end - so everything past the blend window (left untouched,
|
||||
// still the section's original shape) is rigidly shifted vertically by
|
||||
// that small residual to reconnect seamlessly. That shift preserves every
|
||||
// slope in the untouched tail exactly (a vertical translation doesn't
|
||||
// change slopes), so the returned `delta` just needs to be carried into
|
||||
// this section's endY too, since the section's actual endpoint moved by
|
||||
// the same amount.
|
||||
//
|
||||
// prevPair is [secondToLast, last] of whatever raw point run preceded this
|
||||
// one, or null for the very first section (nothing to blend against, so
|
||||
// this is a no-op).
|
||||
function smoothEntry(prevPair, points, width) {
|
||||
if (!prevPair || points.length < 3) return { points, delta: 0 };
|
||||
|
||||
const [p0, p1] = prevPair;
|
||||
if (p1.x === p0.x) return { points, delta: 0 };
|
||||
const angleIn = Math.atan2(p1.y - p0.y, p1.x - p0.x);
|
||||
|
||||
const blendLen = Math.min(SMOOTH_BLEND_MAX, Math.max(SMOOTH_BLEND_MIN, width * SMOOTH_BLEND_FRACTION));
|
||||
const startX = points[0].x;
|
||||
let idxB = points.findIndex((p) => p.x - startX >= blendLen);
|
||||
if (idxB <= 0) idxB = points.length - 1;
|
||||
|
||||
const a = points[0];
|
||||
const b = points[idxB];
|
||||
if (b.x === a.x) return { points, delta: 0 };
|
||||
|
||||
const prevB = points[Math.max(0, idxB - 1)];
|
||||
const nextB = points[Math.min(points.length - 1, idxB + 1)];
|
||||
const angleOut = prevB.x === nextB.x ? angleIn : Math.atan2(nextB.y - prevB.y, nextB.x - prevB.x);
|
||||
|
||||
const blended = [{ x: a.x, y: a.y }];
|
||||
let prevAngle = angleIn;
|
||||
for (let i = 1; i <= idxB; i++) {
|
||||
const p = points[i];
|
||||
const u = (p.x - a.x) / (b.x - a.x);
|
||||
const angle = angleIn + (angleOut - angleIn) * smoothstep(u);
|
||||
// Trapezoidal step (average of this segment's start/end angle) for a
|
||||
// closer height estimate than a single-sample slope would give.
|
||||
const avgSlope = (Math.tan(prevAngle) + Math.tan(angle)) / 2;
|
||||
const prev = blended[blended.length - 1];
|
||||
blended.push({ x: p.x, y: prev.y + avgSlope * (p.x - prev.x) });
|
||||
prevAngle = angle;
|
||||
}
|
||||
|
||||
const delta = blended[blended.length - 1].y - b.y;
|
||||
const result = blended.map((p) => ({ x: p.x, y: Math.round(p.y) }));
|
||||
for (let i = idxB + 1; i < points.length; i++) {
|
||||
result.push({ x: points[i].x, y: Math.round(points[i].y + delta) });
|
||||
}
|
||||
|
||||
return { points: result, delta };
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// sections: ordered array of { type, width, vScale, smooth } - type is an
|
||||
// id from sections.js's SECTION_TYPES, width/vScale independently stretch
|
||||
// or contract that section horizontally/vertically (see sections.js's
|
||||
// SECTION_WIDTH_MIN/MAX and SECTION_VSCALE_MIN/MAX for editor bounds), and
|
||||
// smooth rounds off the sharp corner where this section's terrain meets
|
||||
// the previous section's (see smoothEntry above) - off by default, since a
|
||||
// "Sharp Incline" or a jump's ramp is often supposed to look abrupt.
|
||||
// metadata: { id, name, description, kidsAboard }.
|
||||
// Returns a full level data object matching level01.js's shape, or null if
|
||||
// sections is empty.
|
||||
export function buildLevelData(sections, metadata) {
|
||||
if (!sections || sections.length === 0) return null;
|
||||
|
||||
let baseX = -TERRAIN_LEAD_IN;
|
||||
let baseY = INITIAL_GROUND_Y;
|
||||
|
||||
const terrainSegments = [];
|
||||
let currentPoints = [];
|
||||
// Raw (pre-scale) [secondToLast, last] points of whatever terrain run
|
||||
// immediately precedes the section currently being generated - the
|
||||
// reference smoothEntry blends a "smooth" section's start into.
|
||||
let prevPair = null;
|
||||
|
||||
for (const section of sections) {
|
||||
const result = generateSection(section.type, baseX, baseY, section.width, section.vScale);
|
||||
let endY = result.endY;
|
||||
|
||||
if (result.type === 'jump') {
|
||||
// Only the takeoff (the jump's own "beginning") gets smoothed - the
|
||||
// far side of the gap is a fresh terrain segment with no seam to
|
||||
// round off, and a takeoff shifted by a small delta doesn't need
|
||||
// correcting against the landing since nothing spans the gap.
|
||||
const takeoffPoints = section.smooth ? smoothEntry(prevPair, result.takeoffPoints, section.width).points : result.takeoffPoints;
|
||||
appendPoints(currentPoints, takeoffPoints);
|
||||
terrainSegments.push({ points: currentPoints });
|
||||
currentPoints = [];
|
||||
appendPoints(currentPoints, result.landingPoints);
|
||||
prevPair = lastPair(result.landingPoints);
|
||||
} else {
|
||||
let points = result.points;
|
||||
if (section.smooth) {
|
||||
const smoothed = smoothEntry(prevPair, result.points, section.width);
|
||||
points = smoothed.points;
|
||||
endY += smoothed.delta;
|
||||
}
|
||||
appendPoints(currentPoints, points);
|
||||
prevPair = lastPair(points);
|
||||
}
|
||||
|
||||
baseY = 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);
|
||||
|
||||
let minTerrainY = Infinity;
|
||||
let maxTerrainY = -Infinity;
|
||||
for (const segment of terrainSegments) {
|
||||
for (const p of segment.points) {
|
||||
minTerrainY = Math.min(minTerrainY, p.y);
|
||||
maxTerrainY = Math.max(maxTerrainY, p.y);
|
||||
}
|
||||
}
|
||||
|
||||
const verticalPadding = Math.round(CAMERA_VERTICAL_PADDING * WORLD_SCALE);
|
||||
const cameraTop = Math.min(0, minTerrainY - verticalPadding);
|
||||
const cameraHeight = Math.max(
|
||||
Math.round(CAMERA_MIN_HEIGHT * WORLD_SCALE),
|
||||
maxTerrainY + verticalPadding - cameraTop
|
||||
);
|
||||
|
||||
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: cameraTop,
|
||||
width: endX + Math.round(CAMERA_RIGHT_MARGIN * WORLD_SCALE),
|
||||
height: cameraHeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
import {
|
||||
SECTION_TYPES,
|
||||
SECTION_WIDTH,
|
||||
SECTION_WIDTH_MIN,
|
||||
SECTION_WIDTH_MAX,
|
||||
SECTION_VSCALE_MIN,
|
||||
SECTION_VSCALE_MAX,
|
||||
} 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. Each sequence
|
||||
// entry carries its own width/vScale so sections can be individually
|
||||
// stretched/contracted horizontally (width) or vertically (vScale, a
|
||||
// multiplier on that section's rise/amplitude/depth - see sections.js).
|
||||
const state = {
|
||||
metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 },
|
||||
sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }],
|
||||
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 sizePanelEl = document.getElementById('size-panel');
|
||||
const widthRangeEl = document.getElementById('width-range');
|
||||
const widthNumberEl = document.getElementById('width-number');
|
||||
const vscaleRangeEl = document.getElementById('vscale-range');
|
||||
const vscaleNumberEl = document.getElementById('vscale-number');
|
||||
const smoothCheckboxEl = document.getElementById('smooth-checkbox');
|
||||
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;
|
||||
|
||||
// 'flat' has no rise/amplitude/depth for vScale to multiply - disable that
|
||||
// control rather than leave it silently do nothing.
|
||||
const FLAT_LIKE_TYPES = new Set(['flat']);
|
||||
|
||||
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({ type: id, width: SECTION_WIDTH, vScale: 1, smooth: false });
|
||||
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 sizeSuffix(section) {
|
||||
const parts = [];
|
||||
if (section.width !== SECTION_WIDTH) parts.push(`${Math.round(section.width)}w`);
|
||||
if (!FLAT_LIKE_TYPES.has(section.type) && section.vScale !== 1) parts.push(`${section.vScale.toFixed(2)}x`);
|
||||
if (section.smooth) parts.push('smooth');
|
||||
return parts.length ? ` (${parts.join(', ')})` : '';
|
||||
}
|
||||
|
||||
function buildTrack() {
|
||||
trackEl.innerHTML = '';
|
||||
state.sequence.forEach((section, 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(section.type)}${sizeSuffix(section)}${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;
|
||||
}
|
||||
|
||||
function updateSizePanel() {
|
||||
const i = state.selectedIndex;
|
||||
if (i === null) {
|
||||
sizePanelEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
sizePanelEl.hidden = false;
|
||||
const section = state.sequence[i];
|
||||
|
||||
widthRangeEl.value = String(section.width);
|
||||
widthNumberEl.value = String(Math.round(section.width));
|
||||
|
||||
const vScaleDisabled = FLAT_LIKE_TYPES.has(section.type);
|
||||
vscaleRangeEl.disabled = vScaleDisabled;
|
||||
vscaleNumberEl.disabled = vScaleDisabled;
|
||||
vscaleRangeEl.value = String(section.vScale);
|
||||
vscaleNumberEl.value = section.vScale.toFixed(2);
|
||||
|
||||
// Smoothing blends into the seam with the *previous* section - the
|
||||
// leading section has none, so there's nothing for it to do there.
|
||||
smoothCheckboxEl.disabled = i === 0;
|
||||
smoothCheckboxEl.checked = section.smooth;
|
||||
}
|
||||
|
||||
function setSelectedWidth(value) {
|
||||
const i = state.selectedIndex;
|
||||
if (i === null) return;
|
||||
state.sequence[i].width = clamp(value, SECTION_WIDTH_MIN, SECTION_WIDTH_MAX);
|
||||
render();
|
||||
}
|
||||
|
||||
function setSelectedVScale(value) {
|
||||
const i = state.selectedIndex;
|
||||
if (i === null) return;
|
||||
state.sequence[i].vScale = clamp(value, SECTION_VSCALE_MIN, SECTION_VSCALE_MAX);
|
||||
render();
|
||||
}
|
||||
|
||||
widthRangeEl.addEventListener('input', () => setSelectedWidth(parseFloat(widthRangeEl.value)));
|
||||
widthNumberEl.addEventListener('change', () => setSelectedWidth(parseFloat(widthNumberEl.value) || SECTION_WIDTH));
|
||||
vscaleRangeEl.addEventListener('input', () => setSelectedVScale(parseFloat(vscaleRangeEl.value)));
|
||||
vscaleNumberEl.addEventListener('change', () => setSelectedVScale(parseFloat(vscaleNumberEl.value) || 1));
|
||||
|
||||
smoothCheckboxEl.addEventListener('change', () => {
|
||||
const i = state.selectedIndex;
|
||||
if (i === null) return;
|
||||
state.sequence[i].smooth = smoothCheckboxEl.checked;
|
||||
render();
|
||||
});
|
||||
|
||||
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();
|
||||
updateSizePanel();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function initSizeControls() {
|
||||
for (const el of [widthRangeEl, widthNumberEl]) {
|
||||
el.min = String(SECTION_WIDTH_MIN);
|
||||
el.max = String(SECTION_WIDTH_MAX);
|
||||
}
|
||||
for (const el of [vscaleRangeEl, vscaleNumberEl]) {
|
||||
el.min = String(SECTION_VSCALE_MIN);
|
||||
el.max = String(SECTION_VSCALE_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
buildPalette();
|
||||
buildMetadataForm();
|
||||
initSizeControls();
|
||||
render();
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// 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;
|
||||
|
||||
// Bounds the editor's width/vertical-scale controls clamp to. Width floor
|
||||
// keeps jump's fixed-length lip (JUMP_LIP_LEN) from eating the whole takeoff
|
||||
// ramp at the smallest width; the rest are just sane "still looks like a
|
||||
// track section" limits, not physically derived.
|
||||
export const SECTION_WIDTH_MIN = 250;
|
||||
export const SECTION_WIDTH_MAX = 2100;
|
||||
export const SECTION_VSCALE_MIN = 0.3;
|
||||
export const SECTION_VSCALE_MAX = 2.5;
|
||||
|
||||
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, vScale) {
|
||||
const rise = RISE_SMOOTH * vScale;
|
||||
const points = sampleBlock(startX, startY, width, (t) => -rise * smoothstep(t));
|
||||
return { type: 'simple', points, endY: startY - rise };
|
||||
}
|
||||
|
||||
function sharpIncline(startX, startY, width, vScale) {
|
||||
const rise = RISE_SHARP * vScale;
|
||||
const points = sampleBlock(startX, startY, width, (t) => -rise * t);
|
||||
return { type: 'simple', points, endY: startY - rise };
|
||||
}
|
||||
|
||||
function smoothDecline(startX, startY, width, vScale) {
|
||||
const rise = RISE_SMOOTH * vScale;
|
||||
const points = sampleBlock(startX, startY, width, (t) => rise * smoothstep(t));
|
||||
return { type: 'simple', points, endY: startY + rise };
|
||||
}
|
||||
|
||||
function sharpDecline(startX, startY, width, vScale) {
|
||||
const rise = RISE_SHARP * vScale;
|
||||
const points = sampleBlock(startX, startY, width, (t) => rise * t);
|
||||
return { type: 'simple', points, endY: startY + rise };
|
||||
}
|
||||
|
||||
function rollingHills(startX, startY, width, vScale) {
|
||||
const amp = AMP_HILLS * vScale;
|
||||
const points = sampleBlock(startX, startY, width, (t) => amp * Math.sin(2 * Math.PI * t));
|
||||
return { type: 'simple', points, endY: startY };
|
||||
}
|
||||
|
||||
function ditch(startX, startY, width, vScale) {
|
||||
const depth = DEPTH_DITCH * vScale;
|
||||
const points = sampleBlock(startX, startY, width, (t) => depth * 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, vScale) {
|
||||
const takeoffRise = JUMP_TAKEOFF_RISE * vScale;
|
||||
const landingDrop = JUMP_LANDING_DROP * vScale;
|
||||
|
||||
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 - takeoffRise;
|
||||
const takeoffPoints = [];
|
||||
for (let x = startX; x <= startX + w1; x += POINT_STEP) {
|
||||
const localX = x - startX;
|
||||
const y = localX <= rampLen ? startY - takeoffRise * (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 + landingDrop;
|
||||
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, vScale = 1) {
|
||||
const generator = GENERATORS[typeId];
|
||||
if (!generator) throw new Error(`Unknown section type: ${typeId}`);
|
||||
return generator(startX, startY, width, vScale);
|
||||
}
|
||||
|
|
@ -13,6 +13,24 @@ export default class Bus {
|
|||
const matter = scene.matter;
|
||||
|
||||
this.group = matter.world.nextGroup(true);
|
||||
// Dedicated category so the compartment fixtures below can target kids
|
||||
// specifically. They're standalone bodies (not part of the chassis),
|
||||
// pinned to it via rigid constraints rather than compounded into it -
|
||||
// Matter's own compound-body collision detection only ever consults the
|
||||
// ROOT body's collisionFilter to decide whether a pair is even
|
||||
// considered (verified against the vendored source: per-part filters on
|
||||
// a compound are read for shape/response, never for the go/no-go
|
||||
// decision), so a compounded fixture couldn't be selectively made to
|
||||
// collide with kids while the chassis part stays exempt - only
|
||||
// standalone bodies with their own root-level filter can do that.
|
||||
this.kidCategory = matter.body.nextCategory();
|
||||
// Chassis/wheels' own category, so kids can be exempted from just this
|
||||
// bus's structure via mask rather than by sharing `group` with it -
|
||||
// sharing a group is what used to also make every kid on this bus
|
||||
// mutually exempt from every OTHER kid (group exemption doesn't
|
||||
// distinguish which other body), which is why they were passing
|
||||
// through each other.
|
||||
this.busCategory = matter.body.nextCategory();
|
||||
|
||||
// The anchor sits suspensionTravel above where the wheel actually rests,
|
||||
// so the spring pulling the wheel out to its full constraint length is
|
||||
|
|
@ -32,10 +50,15 @@ export default class Bus {
|
|||
density: BUS.chassisDensity,
|
||||
friction: BUS.chassisFriction,
|
||||
frictionAir: BUS.chassisFrictionAir,
|
||||
collisionFilter: { group: this.group },
|
||||
collisionFilter: { group: this.group, category: this.busCategory },
|
||||
});
|
||||
this.chassis.setDisplaySize(BUS.chassisWidth, BUS.chassisHeight);
|
||||
this.chassis.setAngle(Phaser.Math.RadToDeg(angle));
|
||||
// Explicit depth (rather than default 0) so it's guaranteed to draw
|
||||
// over the kids riding behind it (see Kid.js's depth) regardless of
|
||||
// creation order - kept fully opaque per its real art, not faked with
|
||||
// alpha.
|
||||
this.chassis.setDepth(2);
|
||||
|
||||
const wheelY = y + BUS.wheelRestOffsetY;
|
||||
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY);
|
||||
|
|
@ -45,6 +68,109 @@ export default class Bus {
|
|||
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
|
||||
|
||||
this.seatOffsets = this._buildSeatOffsets();
|
||||
this._buildCompartment(x, y);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Builds the invisible floor + left/right wall bodies kids ride in -
|
||||
// solid on three sides, deliberately no ceiling body at all (that's what
|
||||
// makes the top "open" - see KidManager, which drives the actual
|
||||
// aboard/ejected state from a kid's position relative to
|
||||
// BUS.compartmentTopY, not from anything here). Each fixture is a real,
|
||||
// separate dynamic body (not compounded - see the kidCategory comment
|
||||
// above) rigidly pinned to the chassis with the same two-point "wishbone"
|
||||
// technique as the wheels, just tuned near-rigid (stiffness 1, length 0)
|
||||
// instead of springy, so it tracks the chassis's position AND rotation
|
||||
// together rather than swinging around a single point. Given non-trivial
|
||||
// density (not negligible) so a colliding kid gets a normal, solid-feeling
|
||||
// collision response rather than punching through underweight fixtures -
|
||||
// KidManager's per-tick clamp is the actual containment guarantee
|
||||
// (mirroring the wheel's hard clamp above), this is just what makes
|
||||
// contact feel physical before that clamp would ever need to matter.
|
||||
_buildCompartment(x, y) {
|
||||
const wallHeight = (BUS.compartmentFloorY - BUS.compartmentTopY) + BUS.compartmentFloorThickness;
|
||||
const wallCenterY = (BUS.compartmentFloorY + BUS.compartmentTopY) / 2;
|
||||
|
||||
this.compartmentFloor = this._buildFixture(x, y, 0, BUS.compartmentFloorY, BUS.compartmentHalfWidth * 2, BUS.compartmentFloorThickness);
|
||||
this.compartmentLeftWall = this._buildFixture(x, y, -BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
|
||||
this.compartmentRightWall = this._buildFixture(x, y, BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
|
||||
}
|
||||
|
||||
_buildFixture(chassisX, chassisY, localX, localY, width, height) {
|
||||
const matter = this.scene.matter;
|
||||
const body = matter.add.rectangle(chassisX + localX, chassisY + localY, width, height, {
|
||||
density: BUS.wheelDensity,
|
||||
friction: BUS.chassisFriction,
|
||||
collisionFilter: { mask: this.kidCategory },
|
||||
});
|
||||
|
||||
const pinSpread = Math.max(5, Math.min(width, height) / 2);
|
||||
const pinOptions = (anchorX) => ({
|
||||
pointA: { x: anchorX, y: localY },
|
||||
pointB: { x: 0, y: 0 },
|
||||
});
|
||||
matter.add.constraint(this.chassis, body, 0, 1, pinOptions(localX - pinSpread));
|
||||
matter.add.constraint(this.chassis, body, 0, 1, pinOptions(localX + pinSpread));
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
_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
|
||||
|
|
@ -103,9 +229,10 @@ export default class Bus {
|
|||
frictionStatic: BUS.wheelFrictionStatic,
|
||||
frictionAir: BUS.wheelFrictionAir,
|
||||
restitution: BUS.wheelRestitution,
|
||||
collisionFilter: { group: this.group },
|
||||
collisionFilter: { group: this.group, category: this.busCategory },
|
||||
});
|
||||
wheel.setDisplaySize(BUS.wheelRadius * 2, BUS.wheelRadius * 2);
|
||||
wheel.setDepth(2); // matches the chassis - see its setDepth comment
|
||||
return wheel;
|
||||
}
|
||||
|
||||
|
|
@ -173,4 +300,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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,97 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GFORCE, KID } from '../config.js';
|
||||
import { KID } from '../config.js';
|
||||
|
||||
// A kid has no constraint holding it to the bus at all - it's a free body,
|
||||
// contained purely by physically colliding with the invisible floor/wall
|
||||
// fixtures pinned to the chassis (see Bus.js's _buildCompartment). state is
|
||||
// a derived label KidManager updates every tick from the kid's actual
|
||||
// position relative to the chassis's open top (see
|
||||
// KidManager._onAfterUpdate), not something this class decides on its own.
|
||||
export default class Kid {
|
||||
constructor(scene, bus, seatIndex) {
|
||||
this.scene = scene;
|
||||
this.bus = bus;
|
||||
this.seatIndex = seatIndex;
|
||||
this.state = 'aboard'; // aboard | ejected | settled
|
||||
this.state = 'aboard'; // aboard | ejected
|
||||
this._slowSince = null; // see isSettled()
|
||||
|
||||
const seat = bus.seatOffsets[seatIndex];
|
||||
const worldPos = bus.getSeatWorldPosition(seatIndex);
|
||||
|
||||
this.image = scene.matter.add.image(worldPos.x, worldPos.y, 'kid_idle', null, {
|
||||
shape: { type: 'circle', radius: KID.radius },
|
||||
density: KID.density,
|
||||
friction: KID.friction,
|
||||
collisionFilter: { group: bus.group },
|
||||
// Exempt from the chassis/wheels forever (contained or not) via mask
|
||||
// rather than by sharing the bus's group - sharing a group would also
|
||||
// make every kid on this bus mutually exempt from every OTHER kid
|
||||
// (group exemption is "never collide with anything sharing this
|
||||
// group," not "never collide with this one specific body"), which is
|
||||
// exactly why kids used to pass through each other. Containment
|
||||
// itself is the floor/wall fixtures' job, which target this category
|
||||
// specifically (see bus.kidCategory in Bus.js) so they still collide
|
||||
// with the kid despite this mask.
|
||||
collisionFilter: { category: bus.kidCategory, mask: 0xffffffff & ~bus.busCategory },
|
||||
});
|
||||
this.image.setDisplaySize(KID.radius * 2, KID.radius * 2);
|
||||
|
||||
this.seatConstraint = scene.matter.add.constraint(bus.chassis, this.image, KID.seatLength, KID.seatStiffness, {
|
||||
pointA: seat,
|
||||
pointB: { x: 0, y: 0 },
|
||||
damping: KID.seatDamping,
|
||||
});
|
||||
|
||||
this._settleAt = 0;
|
||||
this._applyTexture('kid_idle');
|
||||
// Between the background/terrain (default depth 0) and the bus
|
||||
// (explicit depth 2, see Bus.js) - visible over the ground, but drawn
|
||||
// over by the chassis/wheels, contained or not.
|
||||
this.image.setDepth(1);
|
||||
}
|
||||
|
||||
eject(chassisVelocity, gForce) {
|
||||
if (this.state !== 'aboard') return;
|
||||
// kid_idle/kid_ejected are 5-frame sheets (one distinct kid per frame,
|
||||
// matching BUS.maxSeats) - picks the frame matching this kid's own seat
|
||||
// so it stays visually the same character whichever texture it's on.
|
||||
// Falls back to frame 0 for the single-frame placeholder texture used
|
||||
// when the real art fails to load (see placeholderTextures.js).
|
||||
_applyTexture(key) {
|
||||
if (this.scene.textures.get(key).has(this.seatIndex)) {
|
||||
this.image.setTexture(key, this.seatIndex);
|
||||
} else {
|
||||
this.image.setTexture(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by KidManager when this kid's position rises above the open top
|
||||
// of the compartment.
|
||||
markEjected() {
|
||||
if (this.state === 'ejected') return;
|
||||
this.state = 'ejected';
|
||||
|
||||
this.scene.matter.world.removeConstraint(this.seatConstraint, true);
|
||||
this.seatConstraint = null;
|
||||
|
||||
// Neutral group re-enables normal category/mask collision (was sharing
|
||||
// the bus's non-colliding group while seated).
|
||||
this.image.body.collisionFilter.group = 0;
|
||||
this.image.setTexture('kid_ejected');
|
||||
|
||||
const speed = Math.hypot(chassisVelocity.x, chassisVelocity.y) || 1;
|
||||
const dirX = chassisVelocity.x / speed;
|
||||
const dirY = chassisVelocity.y / speed;
|
||||
const severity = Phaser.Math.Clamp(gForce / GFORCE.ejectThresholdG, 1, 3);
|
||||
|
||||
this.image.applyForce({
|
||||
x: dirX * GFORCE.ejectImpulseMagnitude * severity + (Math.random() - 0.5) * GFORCE.ejectImpulseMagnitude * 0.5,
|
||||
y: dirY * GFORCE.ejectImpulseMagnitude * severity - GFORCE.ejectImpulseMagnitude * 0.4,
|
||||
});
|
||||
|
||||
this._settleAt = this.scene.time.now + KID.settleTimeMs;
|
||||
this._applyTexture('kid_ejected');
|
||||
// Ignores the world's real gravity - KidManager applies a lighter,
|
||||
// custom fraction of it instead (see KidManager._onBeforeUpdate) while
|
||||
// ejected, so it floats through a much bigger arc than the bus's own
|
||||
// fall would.
|
||||
this.image.body.ignoreGravity = true;
|
||||
}
|
||||
|
||||
update(time) {
|
||||
if (this.state === 'ejected' && time >= this._settleAt) {
|
||||
this.state = 'settled';
|
||||
// Called by KidManager when a floating kid drifts back down through the
|
||||
// open top and lands back inside the compartment.
|
||||
markAboard() {
|
||||
if (this.state === 'aboard') return;
|
||||
this.state = 'aboard';
|
||||
this._applyTexture('kid_idle');
|
||||
this.image.body.ignoreGravity = false;
|
||||
}
|
||||
|
||||
// True once this kid's speed has stayed below KID.restSpeedThreshold for
|
||||
// KID.settleDurationMs straight - used by PlayScene to tell "actually
|
||||
// landed and stopped" apart from a kid just passing through low speed for
|
||||
// an instant at the peak of its arc while still airborne. Only meaningful
|
||||
// to call repeatedly (e.g. every frame) once ejected - the timer resets
|
||||
// itself the moment speed rises back above threshold.
|
||||
isSettled(now) {
|
||||
const v = this.image.body.velocity;
|
||||
const speed = Math.hypot(v.x, v.y);
|
||||
if (speed >= KID.restSpeedThreshold) {
|
||||
this._slowSince = null;
|
||||
return false;
|
||||
}
|
||||
if (this._slowSince === null) this._slowSince = now;
|
||||
return now - this._slowSince >= KID.settleDurationMs;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// No removeConstraint() here (unlike eject(), which runs mid-gameplay
|
||||
// and needs it): destroy() only ever runs from PlayScene's shutdown
|
||||
// cleanup, by which point Phaser's Matter World plugin may have already
|
||||
// torn down (nulled) this.scene.matter.world, and World#shutdown()
|
||||
// already clears every constraint/body itself anyway.
|
||||
this.seatConstraint = null;
|
||||
this.image.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/main.js
17
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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||
import { playMenuMusic } from '../util/music.js';
|
||||
|
||||
export default class IntroScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -8,6 +9,7 @@ export default class IntroScene extends Phaser.Scene {
|
|||
|
||||
create() {
|
||||
this.cameras.main.setBackgroundColor('#8fc7e8');
|
||||
playMenuMusic(this);
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60 * WORLD_SCALE, 'MONSTERPLEX', {
|
||||
fontFamily: 'monospace',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createButton } from '../util/ui.js';
|
|||
|
||||
const REASON_TEXT = {
|
||||
'all-kids-lost': 'Every kid got thrown off the bus!',
|
||||
'bus-fell': 'The bus fell into the gap!',
|
||||
};
|
||||
|
||||
export default class LevelFailedScene extends Phaser.Scene {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||
import { LEVELS } from '../data/levels/index.js';
|
||||
import { getLevelResult } from '../util/progress.js';
|
||||
import { playMenuMusic } from '../util/music.js';
|
||||
|
||||
export default class LevelSelectScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -10,6 +11,7 @@ export default class LevelSelectScene extends Phaser.Scene {
|
|||
|
||||
create() {
|
||||
this.cameras.main.setBackgroundColor('#8fc7e8');
|
||||
playMenuMusic(this);
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, 60 * WORLD_SCALE, 'SELECT LEVEL', {
|
||||
fontFamily: 'monospace',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||
import { createButton } from '../util/ui.js';
|
||||
import { playMenuMusic } from '../util/music.js';
|
||||
|
||||
export default class MainMenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -9,6 +10,7 @@ export default class MainMenuScene extends Phaser.Scene {
|
|||
|
||||
create() {
|
||||
this.cameras.main.setBackgroundColor('#8fc7e8');
|
||||
playMenuMusic(this);
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, 90 * WORLD_SCALE, 'MONSTERPLEX', {
|
||||
fontFamily: 'monospace',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import InputController from '../systems/InputController.js';
|
|||
import GForceMonitor from '../systems/GForceMonitor.js';
|
||||
import KidManager from '../systems/KidManager.js';
|
||||
import CameraRig from '../systems/CameraRig.js';
|
||||
import EngineSound from '../systems/EngineSound.js';
|
||||
import { stopMenuMusic } from '../util/music.js';
|
||||
import { playLevelVoiceLine } from '../util/voiceLine.js';
|
||||
|
||||
export default class PlayScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -20,6 +23,8 @@ export default class PlayScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
stopMenuMusic(this);
|
||||
|
||||
this.cameras.main.setBackgroundColor('#8fc7e8');
|
||||
|
||||
this._buildParallax();
|
||||
|
|
@ -32,19 +37,18 @@ export default class PlayScene extends Phaser.Scene {
|
|||
|
||||
this.inputController = new InputController(this);
|
||||
|
||||
this.engineSound = new EngineSound(this, this.bus);
|
||||
this.voiceSound = playLevelVoiceLine(this, this.levelId);
|
||||
|
||||
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);
|
||||
this.gForceMonitor.resetBaseline();
|
||||
|
||||
this.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds);
|
||||
|
||||
this._buildGoal();
|
||||
this._buildHud();
|
||||
|
||||
this.events.on('kid-ejected', this._onKidEjected, this);
|
||||
this.events.on('kid-status-changed', this._onKidStatusChanged, this);
|
||||
this.events.on('level-failed', this._onLevelFailed, this);
|
||||
|
||||
this.matter.world.on('collisionstart', this._onCollisionStart, this);
|
||||
|
|
@ -57,7 +61,11 @@ export default class PlayScene extends Phaser.Scene {
|
|||
|
||||
const intent = this.inputController.getIntent();
|
||||
this.bus.applyIntent(intent);
|
||||
this.kidManager.update(time);
|
||||
this.engineSound.update();
|
||||
this.cameraRig.update();
|
||||
this._updateParallax();
|
||||
this._checkBusFell();
|
||||
this._checkAllKidsLost();
|
||||
|
||||
if (DEBUG && this.debugText) {
|
||||
const g = this.gForceMonitor.lastGForce || 0;
|
||||
|
|
@ -69,14 +77,92 @@ export default class PlayScene extends Phaser.Scene {
|
|||
const bounds = this.level.cameraBounds;
|
||||
const width = bounds.width + 1000 * WORLD_SCALE;
|
||||
|
||||
const far = this.add.tileSprite(bounds.x, 0, width, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
|
||||
far.setScrollFactor(0.15, 0);
|
||||
// Far layer fills the full viewport height at ONE uniform scale (same
|
||||
// factor on both axes, so its native aspect ratio is preserved - no
|
||||
// stretch), fixed to the screen (scroll factor 0). _updateParallax()
|
||||
// manually scrubs its tilePositionX each frame based on progress from
|
||||
// start to goal, so the single image pans start-to-end exactly once
|
||||
// over the course of the level.
|
||||
const farNative = this._nativeTextureSize('bg_far');
|
||||
const farScale = GAME_HEIGHT / farNative.height;
|
||||
this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
|
||||
this.bgFar.setScrollFactor(0, 0);
|
||||
this.bgFar.tileScaleX = farScale;
|
||||
this.bgFar.tileScaleY = farScale;
|
||||
this._bgFarPanRange = Math.max(0, farNative.width - GAME_WIDTH / farScale);
|
||||
|
||||
const mid = this.add.tileSprite(bounds.x, GAME_HEIGHT * 0.35, width, GAME_HEIGHT * 0.65, 'bg_mid').setOrigin(0, 0);
|
||||
mid.setScrollFactor(0.45, 0);
|
||||
// Mid/near are ground-level layers stacked from the bottom of the
|
||||
// screen upward, each at its own uniform (non-stretching) scale, tiled
|
||||
// across the level via the normal scrollFactor-driven parallax. Repeat
|
||||
// count isn't pinned to an exact number - it falls out of each layer's
|
||||
// target height, and near's is shorter than mid's, so it naturally
|
||||
// repeats more often across the same span.
|
||||
this._addGroundLayer('bg_mid', bounds.x, width, GAME_HEIGHT * 0.6, 0.45);
|
||||
this._addGroundLayer('bg_near', bounds.x, width, GAME_HEIGHT * 0.35, 0.75);
|
||||
}
|
||||
|
||||
const near = this.add.tileSprite(bounds.x, GAME_HEIGHT * 0.55, width, GAME_HEIGHT * 0.45, 'bg_near').setOrigin(0, 0);
|
||||
near.setScrollFactor(0.75, 0);
|
||||
_nativeTextureSize(key) {
|
||||
const src = this.textures.get(key).getSourceImage();
|
||||
return { width: src.width, height: src.height };
|
||||
}
|
||||
|
||||
// Adds a bottom-anchored parallax layer scaled uniformly (same factor on
|
||||
// both axes, so its native aspect ratio is preserved - no stretch),
|
||||
// tiled horizontally across `displayWidth` however many times that scale
|
||||
// naturally allows.
|
||||
_addGroundLayer(key, x, displayWidth, targetHeight, scrollFactorX) {
|
||||
const native = this._nativeTextureSize(key);
|
||||
const scale = targetHeight / native.height;
|
||||
const y = GAME_HEIGHT - targetHeight;
|
||||
const layer = this.add.tileSprite(x, y, displayWidth, targetHeight, key).setOrigin(0, 0);
|
||||
layer.tileScaleX = scale;
|
||||
layer.tileScaleY = scale;
|
||||
layer.setScrollFactor(scrollFactorX, 0);
|
||||
return layer;
|
||||
}
|
||||
|
||||
// Drives bg_far's pan independent of camera scroll: 0 at the bus's start
|
||||
// position, 1 at the goal, clamped so overshoot/backing up doesn't run
|
||||
// the image past its own ends.
|
||||
_updateParallax() {
|
||||
if (!this.bgFar) return;
|
||||
const startX = this.level.startPosition.x;
|
||||
const endX = this.level.goal.x;
|
||||
const progress = Phaser.Math.Clamp((this.bus.chassis.x - startX) / Math.max(1, endX - startX), 0, 1);
|
||||
this.bgFar.tilePositionX = progress * this._bgFarPanRange;
|
||||
}
|
||||
|
||||
// A bus that's fallen past the bottom of the level's own camera bounds
|
||||
// is somewhere the camera can never scroll down far enough to show again
|
||||
// (that's the literal definition of "off screen" here) - treated the
|
||||
// same as losing every kid, since there's no way to recover either.
|
||||
_checkBusFell() {
|
||||
if (this._isBelowCameraBounds(this.bus.chassis.y)) {
|
||||
this.events.emit('level-failed', { reason: 'bus-fell' });
|
||||
}
|
||||
}
|
||||
|
||||
_isBelowCameraBounds(y) {
|
||||
const bounds = this.level.cameraBounds;
|
||||
return y > bounds.y + bounds.height;
|
||||
}
|
||||
|
||||
// 0 kids aboard isn't itself a fail condition - KidManager just tracks
|
||||
// who's still seated. A kid popped loose by a jump is still catchable
|
||||
// (falls down through the compartment's open top and re-boards - see
|
||||
// KidManager) right up until it's either settled outside the bus or lost
|
||||
// down a gap, so the level only fails once EVERY kid has reached one of
|
||||
// those two unrecoverable states, not the instant the last one leaves.
|
||||
_checkAllKidsLost() {
|
||||
if (this.kidManager.kidsAboardCount > 0) return;
|
||||
|
||||
const allUnrecoverable = this.kidManager.kids.every((kid) => {
|
||||
return this._isBelowCameraBounds(kid.image.y) || kid.isSettled(this.time.now);
|
||||
});
|
||||
|
||||
if (allUnrecoverable) {
|
||||
this.events.emit('level-failed', { reason: 'all-kids-lost' });
|
||||
}
|
||||
}
|
||||
|
||||
_buildGoal() {
|
||||
|
|
@ -117,7 +203,7 @@ export default class PlayScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
_onKidEjected({ kidsAboard, total }) {
|
||||
_onKidStatusChanged({ kidsAboard, total }) {
|
||||
const ejectedCount = total - kidsAboard;
|
||||
for (let i = 0; i < this.hudIcons.length; i++) {
|
||||
this.hudIcons[i].setAlpha(i < ejectedCount ? 0.2 : 1);
|
||||
|
|
@ -153,7 +239,7 @@ export default class PlayScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
_cleanup() {
|
||||
this.events.off('kid-ejected', this._onKidEjected, this);
|
||||
this.events.off('kid-status-changed', this._onKidStatusChanged, this);
|
||||
this.events.off('level-failed', this._onLevelFailed, this);
|
||||
// No matter.world.off() here: Phaser's Matter World plugin registers its
|
||||
// own 'shutdown' listener before this scene's create() ever runs, so it
|
||||
|
|
@ -164,5 +250,8 @@ 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.engineSound) this.engineSound.destroy();
|
||||
if (this.voiceSound) this.voiceSound.stop();
|
||||
if (this.bus) this.bus.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||
import { ASSET_MANIFEST } from '../util/assetManifest.js';
|
||||
import { generatePlaceholder } from '../util/placeholderTextures.js';
|
||||
import { LEVELS } from '../data/levels/index.js';
|
||||
import { voiceKey, voiceAssetPath } from '../util/voiceLine.js';
|
||||
|
||||
export default class PreloadScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -17,10 +19,25 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
});
|
||||
|
||||
for (const entry of ASSET_MANIFEST) {
|
||||
if (entry.frameWidth) {
|
||||
this.load.spritesheet(entry.key, entry.path, { frameWidth: entry.frameWidth, frameHeight: entry.frameHeight });
|
||||
} else {
|
||||
this.load.image(entry.key, entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
this.load.audio('main_title_music', 'assets/music/main-title.mp3');
|
||||
this.load.audio('engine_heavy', 'assets/fx/engine-heavy.mp3');
|
||||
|
||||
// Not every level has a voice-over - this just attempts one per level
|
||||
// and lets a missing file 404 like any other optional asset (the
|
||||
// FILE_LOAD_ERROR listener above already tolerates that); PlayScene
|
||||
// checks the audio cache before playing rather than assuming it loaded.
|
||||
for (const level of LEVELS) {
|
||||
this.load.audio(voiceKey(level.id), voiceAssetPath(level.id));
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
for (const entry of ASSET_MANIFEST) {
|
||||
if (this._failedKeys.has(entry.key) || !this.textures.exists(entry.key)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import Phaser from 'phaser';
|
||||
import { BUS, ENGINE } from '../config.js';
|
||||
|
||||
// Loops engine-heavy.mp3 for the whole level and pitches it to track the
|
||||
// bus's revs, so it reads as one continuous engine rather than a sample
|
||||
// that starts/stops with throttle. wheelRear is the only wheel
|
||||
// applyIntent ever spins directly (see Bus.applyIntent), so its angular
|
||||
// velocity IS the rev signal - it climbs toward BUS.maxWheelAngularVelocity
|
||||
// under throttle and bleeds back down via wheelFrictionAir once the gas is
|
||||
// released, exactly like an engine returning to idle on its own.
|
||||
export default class EngineSound {
|
||||
constructor(scene, bus) {
|
||||
this.bus = bus;
|
||||
this.rate = ENGINE.minRate;
|
||||
|
||||
this.sound = scene.sound.add('engine_heavy', { loop: true, volume: ENGINE.volume, rate: ENGINE.minRate });
|
||||
this.sound.play();
|
||||
}
|
||||
|
||||
update() {
|
||||
const wheelBody = this.bus.wheelRear.body;
|
||||
const revs = Phaser.Math.Clamp(Math.abs(wheelBody.angularVelocity) / BUS.maxWheelAngularVelocity, 0, 1);
|
||||
const targetRate = Phaser.Math.Linear(ENGINE.minRate, ENGINE.maxRate, revs);
|
||||
|
||||
this.rate = Phaser.Math.Linear(this.rate, targetRate, ENGINE.rateSmoothing);
|
||||
this.sound.setRate(this.rate);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.sound.stop();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,26 @@
|
|||
import { GFORCE, PHYSICS_TIMESTEP_SECONDS } from '../config.js';
|
||||
import { PHYSICS_TIMESTEP_SECONDS, GFORCE } from '../config.js';
|
||||
|
||||
// Pure detector: samples the chassis's linear velocity every fixed physics
|
||||
// step, estimates acceleration via finite difference, and emits
|
||||
// 'gforce-exceeded' on the scene when it crosses the tunable threshold.
|
||||
// Ejection logic lives elsewhere (KidManager) so this stays a single-purpose
|
||||
// sensor.
|
||||
// Pure debug diagnostic: kid ejection is no longer impact-triggered (see
|
||||
// KidManager/Bus's open-top compartment box) - this just samples the
|
||||
// chassis's linear velocity every fixed physics step and estimates
|
||||
// acceleration via finite difference, purely for the DEBUG g-force readout
|
||||
// in PlayScene.
|
||||
export default class GForceMonitor {
|
||||
constructor(scene, chassisBody) {
|
||||
this.scene = scene;
|
||||
this.chassisBody = chassisBody;
|
||||
this.previousVelocity = { x: 0, y: 0 };
|
||||
this.graceUntil = 0;
|
||||
this.enabled = true;
|
||||
this.lastGForce = 0;
|
||||
|
||||
this._onAfterUpdate = this._onAfterUpdate.bind(this);
|
||||
scene.matter.world.on('afterupdate', this._onAfterUpdate);
|
||||
}
|
||||
|
||||
startGracePeriod(durationMs = GFORCE.gracePeriodMs) {
|
||||
this.graceUntil = this.scene.time.now + durationMs;
|
||||
// Resets the velocity baseline to whatever it is right now, so the next
|
||||
// tick's comparison isn't against a stale one - call this once whatever
|
||||
// isn't a "real" impact (e.g. the spawn drop) has settled.
|
||||
resetBaseline() {
|
||||
const v = this.chassisBody.velocity;
|
||||
this.previousVelocity = { x: v.x, y: v.y };
|
||||
}
|
||||
|
|
@ -33,16 +35,9 @@ export default class GForceMonitor {
|
|||
|
||||
const accelPxPerSec2 = deltaV / PHYSICS_TIMESTEP_SECONDS;
|
||||
const accelMPerSec2 = accelPxPerSec2 / GFORCE.pixelsPerMeter;
|
||||
const gForce = accelMPerSec2 / 9.8;
|
||||
this.lastGForce = accelMPerSec2 / 9.8;
|
||||
|
||||
this.previousVelocity = { x: v.x, y: v.y };
|
||||
this.lastGForce = gForce;
|
||||
|
||||
if (this.scene.time.now < this.graceUntil) return;
|
||||
|
||||
if (gForce > GFORCE.ejectThresholdG) {
|
||||
this.scene.events.emit('gforce-exceeded', { gForce });
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GFORCE } from '../config.js';
|
||||
import { KID, BUS } from '../config.js';
|
||||
import Kid from '../entities/Kid.js';
|
||||
|
||||
export default class KidManager {
|
||||
|
|
@ -8,47 +7,157 @@ export default class KidManager {
|
|||
this.bus = bus;
|
||||
this.total = kidsAboard;
|
||||
this.kids = [];
|
||||
this._lastEjectTime = -Infinity;
|
||||
|
||||
for (let i = 0; i < kidsAboard; i++) {
|
||||
this.kids.push(new Kid(scene, bus, i));
|
||||
}
|
||||
|
||||
this._onGForceExceeded = this._onGForceExceeded.bind(this);
|
||||
scene.events.on('gforce-exceeded', this._onGForceExceeded);
|
||||
// A kid past the open top (see Kid.markEjected) sets body.ignoreGravity
|
||||
// so the engine's own gravity application skips it entirely; this
|
||||
// replaces it with a lighter fraction, applied here (before the engine
|
||||
// integrates the step, matching how Matter itself applies gravity)
|
||||
// rather than in an afterupdate hook.
|
||||
this._onBeforeUpdate = this._onBeforeUpdate.bind(this);
|
||||
scene.matter.world.on('beforeupdate', this._onBeforeUpdate);
|
||||
|
||||
// Drives the actual aboard/ejected transition, in both directions - see
|
||||
// _onAfterUpdate.
|
||||
this._onAfterUpdate = this._onAfterUpdate.bind(this);
|
||||
scene.matter.world.on('afterupdate', this._onAfterUpdate);
|
||||
}
|
||||
|
||||
get kidsAboardCount() {
|
||||
return this.kids.filter((kid) => kid.state === 'aboard').length;
|
||||
}
|
||||
|
||||
_onGForceExceeded({ gForce }) {
|
||||
const now = this.scene.time.now;
|
||||
if (now - this._lastEjectTime < GFORCE.ejectCooldownMs) return;
|
||||
_onBeforeUpdate() {
|
||||
const gravity = this.scene.matter.world.engine.gravity;
|
||||
const g = gravity.y * gravity.scale * KID.ejectedGravityScale;
|
||||
for (const kid of this.kids) {
|
||||
if (kid.state === 'aboard') continue;
|
||||
const body = kid.image.body;
|
||||
body.force.y += body.mass * g;
|
||||
}
|
||||
}
|
||||
|
||||
const aboard = this.kids.filter((kid) => kid.state === 'aboard');
|
||||
if (aboard.length === 0) return;
|
||||
// Every physics step: hard-clamps each kid against the floor/walls (see
|
||||
// _clampToCompartment - same "soft collision + hard clamp" pairing as
|
||||
// Bus.js's wheel wishbone, since the fixtures' own collision response
|
||||
// alone isn't a hard guarantee against a fast enough hit), then checks
|
||||
// the (now-corrected) position against the compartment's open top in the
|
||||
// chassis's own rotated frame. A kid drifting up past the open top
|
||||
// becomes ejected immediately. A floating kid drifting back down through
|
||||
// the open top only re-boards once it has actually come to rest inside
|
||||
// the compartment (see Kid.isSettled), so a kid still mid-arc can't
|
||||
// teleport back in mid-air.
|
||||
_onAfterUpdate() {
|
||||
const chassis = this.bus.chassis;
|
||||
const cos = Math.cos(chassis.rotation);
|
||||
const sin = Math.sin(chassis.rotation);
|
||||
|
||||
this._lastEjectTime = now;
|
||||
const kid = Phaser.Utils.Array.GetRandom(aboard);
|
||||
kid.eject(this.bus.chassis.body.velocity, gForce);
|
||||
let anyChanged = false;
|
||||
for (const kid of this.kids) {
|
||||
this._clampToCompartment(kid, chassis, cos, sin);
|
||||
|
||||
this.scene.events.emit('kid-ejected', {
|
||||
const dx = kid.image.x - chassis.x;
|
||||
const dy = kid.image.y - chassis.y;
|
||||
const localY = -dx * sin + dy * cos;
|
||||
const isAboveOpenTop = localY < BUS.compartmentTopY;
|
||||
|
||||
if (isAboveOpenTop && kid.state === 'aboard') {
|
||||
kid.markEjected();
|
||||
anyChanged = true;
|
||||
} else if (!isAboveOpenTop && kid.state === 'ejected' && kid.isSettled(this.scene.time.now)) {
|
||||
// Re-boarding is gated on the kid actually settling (see Kid.isSettled),
|
||||
// not just passing through the compartment's vertical band again. Ejected
|
||||
// kids run under reduced gravity, so a kid still floating/arc-ing will
|
||||
// happily drift back through the open top while airborne; without this
|
||||
// gate it would flip back to aboard mid-air and "teleport" back in.
|
||||
kid.markAboard();
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyChanged) {
|
||||
this.scene.events.emit('kid-status-changed', {
|
||||
kidsAboard: this.kidsAboardCount,
|
||||
total: this.total,
|
||||
});
|
||||
|
||||
if (this.kidsAboardCount === 0) {
|
||||
this.scene.events.emit('level-failed', { reason: 'all-kids-lost' });
|
||||
// No level-failed here even at 0 aboard - PlayScene decides that once
|
||||
// every kid has actually settled outside the bus (or fallen down a
|
||||
// gap), not the instant the last one leaves, so a jump that pops
|
||||
// everyone loose still leaves a window to drive back underneath and
|
||||
// catch them (see PlayScene._checkAllKidsLost).
|
||||
}
|
||||
}
|
||||
|
||||
update(time) {
|
||||
for (const kid of this.kids) kid.update(time);
|
||||
// Keeps a kid from ever tunneling through the floor or a side wall in a
|
||||
// single hard hit, in the chassis's own rotated frame (same un-rotate
|
||||
// technique as Bus.js's wheel clamp). Never clamps the top - that's the
|
||||
// one side deliberately left open.
|
||||
_clampToCompartment(kid, chassis, cos, sin) {
|
||||
const dx = kid.image.x - chassis.x;
|
||||
const dy = kid.image.y - chassis.y;
|
||||
|
||||
// A kid genuinely outside compartmentClampRadius was never inside the
|
||||
// compartment this tick - skip it entirely, rather than let the below
|
||||
// checks (which only bound ONE axis each) treat "far away in the other
|
||||
// axis" as a boundary violation. Without this, a kid resting anywhere
|
||||
// else in the level could get yanked toward wherever the bus currently
|
||||
// is, since local-frame coordinates alone don't distinguish "just
|
||||
// tunneled through" from "elsewhere entirely."
|
||||
if (dx * dx + dy * dy > BUS.compartmentClampRadius * BUS.compartmentClampRadius) return;
|
||||
|
||||
let localX = dx * cos + dy * sin;
|
||||
let localY = -dx * sin + dy * cos;
|
||||
let hitFloor = false;
|
||||
let hitLeftWall = false;
|
||||
let hitRightWall = false;
|
||||
|
||||
const restY = BUS.compartmentFloorY - BUS.compartmentFloorThickness / 2 - KID.radius;
|
||||
if (localY > restY) {
|
||||
localY = restY;
|
||||
hitFloor = true;
|
||||
}
|
||||
|
||||
// Walls only exist at/below the open top - a kid that's already escaped
|
||||
// upward has nothing left to bump sideways into.
|
||||
if (localY >= BUS.compartmentTopY) {
|
||||
const leftBoundary = -BUS.compartmentHalfWidth + BUS.compartmentWallThickness / 2 + KID.radius;
|
||||
const rightBoundary = BUS.compartmentHalfWidth - BUS.compartmentWallThickness / 2 - KID.radius;
|
||||
if (localX < leftBoundary) {
|
||||
localX = leftBoundary;
|
||||
hitLeftWall = true;
|
||||
} else if (localX > rightBoundary) {
|
||||
localX = rightBoundary;
|
||||
hitRightWall = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hitFloor && !hitLeftWall && !hitRightWall) return;
|
||||
|
||||
const worldX = chassis.x + (localX * cos - localY * sin);
|
||||
const worldY = chassis.y + (localX * sin + localY * cos);
|
||||
kid.image.setPosition(worldX, worldY);
|
||||
|
||||
// Kill only the relative velocity component(s) still driving it through
|
||||
// whichever boundary caught it, same reasoning as the wheel clamp - a
|
||||
// bump stop absorbing the hit, not bouncing off it.
|
||||
const chassisBody = chassis.body;
|
||||
const body = kid.image.body;
|
||||
const relVX = body.velocity.x - chassisBody.velocity.x;
|
||||
const relVY = body.velocity.y - chassisBody.velocity.y;
|
||||
let localVX = relVX * cos + relVY * sin;
|
||||
let localVY = -relVX * sin + relVY * cos;
|
||||
if (hitFloor && localVY > 0) localVY = 0;
|
||||
if (hitLeftWall && localVX < 0) localVX = 0;
|
||||
if (hitRightWall && localVX > 0) localVX = 0;
|
||||
const newRelVX = localVX * cos - localVY * sin;
|
||||
const newRelVY = localVX * sin + localVY * cos;
|
||||
kid.image.setVelocity(chassisBody.velocity.x + newRelVX, chassisBody.velocity.y + newRelVY);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.scene.events.off('gforce-exceeded', this._onGForceExceeded);
|
||||
for (const kid of this.kids) kid.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ import { WORLD_SCALE } from '../config.js';
|
|||
export const ASSET_MANIFEST = [
|
||||
{ key: 'bus_chassis', path: 'assets/sprites/bus_chassis.png', width: 170 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
|
||||
{ key: 'bus_wheel', path: 'assets/sprites/bus_wheel.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 },
|
||||
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e },
|
||||
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b },
|
||||
// Real art is a 5-frame sheet (one distinct kid per frame, 56x56px each,
|
||||
// matching BUS.maxSeats) - frameWidth/frameHeight (raw source pixels, NOT
|
||||
// WORLD_SCALE-relative) make PreloadScene load it as a spritesheet
|
||||
// instead of a single squashed image; Kid.js picks the frame matching its
|
||||
// seat index. width/height here stay the DISPLAY size of one frame, same
|
||||
// as before - only used for the single-frame placeholder fallback.
|
||||
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e, frameWidth: 56, frameHeight: 56 },
|
||||
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b, frameWidth: 56, frameHeight: 56 },
|
||||
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
|
||||
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
|
||||
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
// Menu music spans Intro -> MainMenu -> LevelSelect as one continuous track
|
||||
// (Phaser's sound manager lives on the game, not the scene, so a Sound
|
||||
// object started in one scene keeps playing through scene.start() calls
|
||||
// into the next). playMenuMusic is safe to call from every one of those
|
||||
// scenes' create() - it no-ops if the track is already playing so
|
||||
// navigating between them doesn't restart it.
|
||||
const KEY = 'main_title_music';
|
||||
|
||||
export function playMenuMusic(scene) {
|
||||
const existing = scene.sound.get(KEY);
|
||||
if (existing && existing.isPlaying) return;
|
||||
if (existing) {
|
||||
existing.play();
|
||||
return;
|
||||
}
|
||||
scene.sound.add(KEY, { loop: true, volume: 0.5 }).play();
|
||||
}
|
||||
|
||||
export function stopMenuMusic(scene) {
|
||||
scene.sound.get(KEY)?.stop();
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// Per-level voice-over: assets/voice/<levelId>.mp3, if present, plays once
|
||||
// at the start of that level. Not every level has one - PreloadScene
|
||||
// attempts to load one for every level in LEVELS and just lets a missing
|
||||
// file fail (same 404-tolerant pattern as everything in ASSET_MANIFEST),
|
||||
// so playLevelVoiceLine only has to check whether the load actually landed
|
||||
// in the audio cache before playing.
|
||||
const keyFor = (levelId) => `voice_${levelId}`;
|
||||
|
||||
export function voiceKey(levelId) {
|
||||
return keyFor(levelId);
|
||||
}
|
||||
|
||||
export function voiceAssetPath(levelId) {
|
||||
return `assets/voice/${levelId}.mp3`;
|
||||
}
|
||||
|
||||
// Plays levelId's voice line if assets/voice/<levelId>.mp3 was found at
|
||||
// load time. Returns the Sound instance (so the caller can stop it early
|
||||
// on scene teardown) or null if this level has none.
|
||||
export function playLevelVoiceLine(scene, levelId) {
|
||||
const key = keyFor(levelId);
|
||||
if (!scene.cache.audio.exists(key)) return null;
|
||||
|
||||
const sound = scene.sound.add(key);
|
||||
sound.play();
|
||||
return sound;
|
||||
}
|
||||
Loading…
Reference in New Issue