- commit
- f7f424fa85a310c7a73a7105fea34dd8fe3a835c
- parent
- e94897cf8356cfab0aeab3b8e3ca50b23fc12be3
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 66 | ++++++++++++++++++++++++++---- |
| M | tests/ | 11 | +++++ |
@@ -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);@@ -273,6 +273,17 @@ Deno.test("jump set-piece: every planned jump obeys the trajectory contract", ()
273273 `${seed} seg ${i}: landing cell ${k} not flat at lip−0.5`,274274 );275275 }276+ // Pad visual: the lip carries its banner so the set-piece reads.277+ const lip = cells[g - 1];278+ const bannerId = propeller ? "banner-double-high" : "banner-high";279+ assert(280+ seg.placements.some((p) =>281+ p.pieceId === bannerId &&282+ Math.abs(p.pos[0] - lip.x * TRACK_SCALE) < 0.01 &&283+ Math.abs(p.pos[2] - lip.z * TRACK_SCALE) < 0.01284+ ),285+ `${seed} seg ${i}: ${m.kind} @${g} missing its lip banner`,286+ );276287 }277288 // Shock rule: every ≥1.5 chute descent has its impact-valley zone.278289 for (const r of seg.plan.reservations) {