clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M11: pad visuals - lip banners for jump/propeller, entry banners for boost zones

Dami committed f7f424f
commit
f7f424fa85a310c7a73a7105fea34dd8fe3a835c
parent
e94897cf8356cfab0aeab3b8e3ca50b23fc12be3
author
Dami <dami.etoile@gmail.com>
2 files changed, +69 -8
Msrc/gen/endless/world.ts66++++++++++++++++++++++++++----
Mtests/scenario_test.ts11+++++
M src/gen/endless/world.ts +58 -8
@@ -15,7 +15,7 @@
1515 * segment and its islands made, so memory tracks the streaming window, not1616 * the distance travelled.1717 */18-import type { LoopPath, Placement } from "../../shared/types.ts";18+import type { Dir, LoopPath, Placement } from "../../shared/types.ts";1919 import { rngFamily } from "../rng.ts";2020 import { buildRegistry, type PieceRegistry } from "../registry.ts";2121 import { pickStyle, type StyleField } from "../style.ts";
@@ -370,16 +370,66 @@ export class EndlessWorld {
370370 for (let k = startIdx; k < end; k++) dropped.add(k);371371 }372372 373+ // Zone pad visuals (M11): set-pieces must READ on screen. Banners374+ // stand on the channel rails (the goal-gate measurement — Kenney375+ // designed the posts to sit on them), so they work even in the void:376+ // banner-high marks a jump lip, banner-double-high a propeller lip,377+ // a plain banner the entry of a standalone boost zone. Rule-derived,378+ // no RNG. Cells inside reservations are skipped (a banner through a379+ // ramp mesh clips).380+ const isReserved = (idx: number) =>381+ plan.reservations.some((r) => idx >= r.start && idx < r.start + r.len);382+ const gapStarts = new Set(383+ plan.mods384+ .filter((m) => m.kind === "jump" || m.kind === "propeller")385+ .map((m) => m.start),386+ );387+ const pads: Placement[] = [];388+ for (const m of plan.mods) {389+ if (m.kind === "jump" || m.kind === "propeller") {390+ const lip = plan.cells[m.start - 1];391+ if (lip && lip.move === "F" && !isReserved(m.start - 1)) {392+ pads.push({393+ pieceId: m.kind === "propeller"394+ ? "banner-double-high"395+ : "banner-high",396+ pos: [lip.x, lip.baseY + 0.5, lip.z],397+ rotation: ((1 - lip.dirIn + 4) % 4) as Dir,398+ role: "track",399+ });400+ }401+ } else if (m.kind === "boost") {402+ // The lip banner already announces a jump's approach zone.403+ if (gapStarts.has(m.start + m.len)) continue;404+ for (405+ let idx = m.start;406+ idx < m.start + m.len && idx < plan.cells.length;407+ idx++408+ ) {409+ const c = plan.cells[idx];410+ if (c.move !== "F" || isReserved(idx)) continue;411+ pads.push({412+ pieceId: "banner",413+ pos: [c.x, c.baseY + 0.5, c.z],414+ rotation: ((1 - c.dirIn + 4) % 4) as Dir,415+ role: "track",416+ });417+ break;418+ }419+ }420+ }421+373422 // Slalom rule (M8): the generator plans in track cells; the WORLD sees374423 // track pieces at ×TRACK_SCALE (position and mesh scale) while islands375424 // and decor stay 1×. The centerline scales with the track.376- const placements = assembled.placements377- .filter((_, idx) => !dropped.has(idx))378- .map((p): Placement => ({379- ...p,380- pos: scaleVec(p.pos),381- scale: TRACK_SCALE,382- }));425+ const placements = [426+ ...assembled.placements.filter((_, idx) => !dropped.has(idx)),427+ ...pads,428+ ].map((p): Placement => ({429+ ...p,430+ pos: scaleVec(p.pos),431+ scale: TRACK_SCALE,432+ }));383433 const path: LoopPath = {384434 segments: assembled.loop.segments.map((s) => {385435 const curve = s.curve.map(scaleSeg);
M tests/scenario_test.ts +11 -0