diff --git a/ASSETS.md b/ASSETS.md index e0be209..e416a00 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -80,3 +80,12 @@ The map-resilience slice uses the reference to guide fallback hierarchy, state m | Block DNA expansion visual target | QA reference for eight fictional tactical archetypes and their terrain silhouettes | 2560×1440 | `/home/ubuntu/slide-artifact-audit/block_dna_expansion_batch_reference.png` | No; review reference only | The Block DNA batch-one reference establishes harbor, transit-market, canal, and service-district silhouettes in the canonical tropical-noir oblique-diorama direction. It remains outside the runtime asset tree and must not be copied into `frontend/public` or registered in `runtimeManifest.json`; the content expansion is intentionally data-driven and has no asset-budget impact. + +## 2026-09-30 — Drive-By Car Loadout Plates (#197) + +| Asset | Provenance | Source master | Runtime manifest ID/path | Output | +|---|---|---|---|---:| +| Night street with top-down four-seat vehicle, landscape | Original OpenAI GPT Image 2.5 generation; no found artwork incorporated | `art-src/generated/environments/street/block_slide_car_loadout_desktop_v001.png` (2560×1440) | `generated.environments.street.block_slide_car_loadout_desktop_v001` → `/assets/runtime/generated/environments/street/block_slide_car_loadout_desktop_v001.webp` | 1920×1080, 138,152 bytes | +| Matching vertical vehicle loadout plate | Original OpenAI GPT Image 2.5 generation; no found artwork incorporated | `art-src/generated/environments/street/block_slide_car_loadout_mobile_v001.png` (1440×2560) | `generated.environments.street.block_slide_car_loadout_mobile_v001` → `/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp` | 1080×1920, 201,184 bytes | + +Both sources were imported using the processor's exact-source `--only` option after #198, not by hand-editing the manifest. The global runtime package is 7.70 MB against the 20 MB limit. These plates are **decorative only**: four seat buttons, selection/status text, stats, cargo, and the launch action remain accessible DOM. Fictional demo members have only a clearly labeled role-art fallback; a verified personal likeness depends on a real member photo and the separate #195 generation-provider gate. The loadout excludes the driver from passenger-shooter seats and post-mission shooter XP, but the legacy mini-game does not yet attribute shots to named passengers (#201). diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index a88ce2e..0b2d422 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,12 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-09-30 — Drive-by car loadout: true roles, portrait-first HUD, original responsive art (#197) + +- The DRIVE app's four-seat selector now limits drivers to active dealers/recruits/shooters/dedicated drivers; only active shooters occupy passenger seats. Seat assignments are rechecked at launch, including stale member roles, injuries, absence and duplicates. The legacy first-person mini-game does not yet bind individual shots to a named passenger (#201), although the driver is excluded from shooter XP. +- The accessible, responsive DOM HUD displays stored member images first, labels resolver-backed role art as **not member likeness**, and shows actual recorded health/stats and carried inventory without inventing weapons or absent health/gear. Two original GPT Image 2.5 street/car plates were imported with the scoped processor into the manifest (339,336 bytes total) under the existing 20 MB asset budget; generated art contains no functional HUD text. +- Phone/desktop demo DRIVE → assign dealer driver + shooter passenger → mission entry was replayed at 375×812, 390×844 and 1440×900. This is a local demo path, **not** authenticated saved-game proof, founder-photo likeness generation, a canonical ATTACK route, a dismount implementation, individual shooter attribution, or a beta deployment. #175, #192, #195, #201 and the release gate remain open. + ### 2026-09-30 — Opt-in exact-source asset import (#198) - `process.mjs --only=` may be repeated for newly reserved staged sources; it retains and validates all registered runtime entries and the global budget but never consumes unselected legacy inputs. Absolute, traversal, runtime/package, duplicate, symlink, and nonexistent selections fail before mutation. The no-flag global behavior remains unchanged. diff --git a/frontend/public/assets/runtime/generated/environments/street/block_slide_car_loadout_desktop_v001.webp b/frontend/public/assets/runtime/generated/environments/street/block_slide_car_loadout_desktop_v001.webp new file mode 100644 index 0000000..ba0e123 Binary files /dev/null and b/frontend/public/assets/runtime/generated/environments/street/block_slide_car_loadout_desktop_v001.webp differ diff --git a/frontend/public/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp b/frontend/public/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp new file mode 100644 index 0000000..f5c1bc4 Binary files /dev/null and b/frontend/public/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp differ diff --git a/frontend/src/assets/runtimeManifest.json b/frontend/src/assets/runtimeManifest.json index 9625015..644ffb0 100644 --- a/frontend/src/assets/runtimeManifest.json +++ b/frontend/src/assets/runtimeManifest.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-08-06T02:36:25.102Z", + "generatedAt": "2026-10-01T03:16:53.148Z", "budgetMB": 20, - "totalBytes": 6096278, + "totalBytes": 6435614, "qualityPass": "standard", "entries": [ { @@ -940,6 +940,42 @@ }, "alphaRepaired": false }, + { + "id": "generated.environments.street.block_slide_car_loadout_desktop_v001", + "runtimePath": "/assets/runtime/generated/environments/street/block_slide_car_loadout_desktop_v001.webp", + "sourcePath": "art-src/generated/environments/street/block_slide_car_loadout_desktop_v001.png", + "class": "env-street", + "role": null, + "state": null, + "width": 1920, + "height": 1080, + "bytes": 138152, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 1 + }, + "alphaRepaired": false + }, + { + "id": "generated.environments.street.block_slide_car_loadout_mobile_v001", + "runtimePath": "/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp", + "sourcePath": "art-src/generated/environments/street/block_slide_car_loadout_mobile_v001.png", + "class": "env-street", + "role": null, + "state": null, + "width": 1080, + "height": 1920, + "bytes": 201184, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 1 + }, + "alphaRepaired": false + }, { "id": "generated.environments.street.block_stripplaza_day_street_v001", "runtimePath": "/assets/runtime/generated/environments/street/block_stripplaza_day_street_v001.webp", diff --git a/frontend/src/components/driveby/CarCrewSelector.css b/frontend/src/components/driveby/CarCrewSelector.css index 5326ab8..46d0576 100644 --- a/frontend/src/components/driveby/CarCrewSelector.css +++ b/frontend/src/components/driveby/CarCrewSelector.css @@ -1,365 +1,157 @@ -/* ═══════════════════════════════════════════════════════════ - Car Crew Selector - Top-Down Car Seat Assignment - ═══════════════════════════════════════════════════════════ */ - +/* SLIDE mission prep — all rules scoped so legacy TopDownShooter remains independent. */ .car-crew-selector { - width: 100%; - height: 100%; - min-height: 0; - background: #0a0a0a; - display: flex; - flex-direction: column; - overflow-y: auto; - overscroll-behavior: contain; - padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); - box-sizing: border-box; + --ccs-amber: #e9aa70; + --ccs-cyan: #89cdd5; + --ccs-line: rgba(196, 218, 220, .21); + width: 100%; height: 100%; min-height: 0; + box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; + padding: 0 0 calc(58px + env(safe-area-inset-bottom, 0px)); + color: #f5f0e8; background: #090f15; font-family: 'Courier New', monospace; } - -.ccs-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: rgba(0,0,0,0.9); - border-bottom: 1px solid #333; -} - -.ccs-back { - background: none; - border: 1px solid #555; - color: #fff; - padding: 4px 12px; - border-radius: 4px; - font-size: 12px; - cursor: pointer; -} - -.ccs-title { - color: #ff6600; - font-weight: bold; - font-size: 14px; - text-transform: uppercase; - letter-spacing: 1px; -} - -/* ─── TOP-DOWN CAR ─── */ -.car-topdown { - padding: 16px; - display: flex; - flex-direction: column; - align-items: center; -} - -.car-body { - width: 200px; - position: relative; - display: flex; - flex-direction: column; - align-items: center; -} - -.car-hood { - width: 140px; - height: 50px; - background: linear-gradient(180deg, #2a2a2a, #1a1a1a); - border-radius: 30px 30px 0 0; - border: 2px solid #444; - border-bottom: none; -} - -.car-cabin { - width: 180px; - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr 1fr; - gap: 8px; - padding: 12px; - background: linear-gradient(180deg, #1a1a1a, #111); - border: 2px solid #444; - border-top: none; - border-bottom: none; -} - -.car-trunk { - width: 160px; - height: 40px; - background: linear-gradient(180deg, #1a1a1a, #2a2a2a); - border-radius: 0 0 20px 20px; - border: 2px solid #444; - border-top: none; -} - -/* ─── SEATS ─── */ -.car-seat { - width: 70px; - height: 70px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; - position: relative; -} - -.car-seat.empty { - background: rgba(255,255,255,0.03); - border: 2px dashed #333; -} - -.car-seat.occupied { - background: rgba(255,102,0,0.1); - border: 2px solid #ff6600; -} - -.car-seat.selected { - border-color: #00ff88; - box-shadow: 0 0 10px rgba(0,255,136,0.3); -} - -.seat-driver { grid-area: 1 / 1 / 2 / 2; } -.seat-passenger { grid-area: 1 / 2 / 2 / 3; } -.seat-back_left { grid-area: 2 / 1 / 3 / 2; } -.seat-back_right { grid-area: 2 / 2 / 3 / 3; } - -.seat-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -} - -.seat-plus { - font-size: 20px; - color: #555; -} - -.seat-label { - font-size: 7px; - color: #555; - text-align: center; -} - -.seat-member { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -} - -.seat-avatar { - width: 30px; - height: 30px; - border-radius: 50%; - object-fit: cover; -} - -.seat-name { - font-size: 8px; - color: #fff; - font-weight: bold; -} - -.seat-role { - font-size: 7px; - color: #ff6600; - text-transform: uppercase; -} - -.seat-legend { - margin-top: 12px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.seat-legend .legend-item { - font-size: 9px; - color: #666; -} - -/* ─── MEMBER PICKER ─── */ -.member-picker { - margin: 0 12px; - background: rgba(255,255,255,0.03); - border: 1px solid #333; - border-radius: 8px; - max-height: 200px; - overflow-y: auto; -} - -.picker-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 12px; - border-bottom: 1px solid #222; - color: #aaa; - font-size: 11px; -} - -.picker-header button { - background: none; - border: none; - color: #888; - cursor: pointer; - font-size: 14px; -} - -.picker-list { - padding: 4px; -} - -.picker-empty { - padding: 16px; - text-align: center; - color: #666; - font-size: 11px; -} - -.picker-member { - display: flex; - align-items: center; - gap: 8px; - padding: 8px; - border-radius: 6px; - cursor: pointer; - transition: background 0.2s; -} - -.picker-member:hover { - background: rgba(255,102,0,0.1); -} - -.picker-avatar { - width: 32px; - height: 32px; - border-radius: 50%; - object-fit: cover; -} - -.picker-info { - flex: 1; - display: flex; - flex-direction: column; -} - -.picker-name { - color: #fff; - font-size: 12px; - font-weight: bold; -} - -.picker-role { - color: #888; - font-size: 10px; -} - -.picker-stats { - display: flex; - gap: 8px; - font-size: 10px; - color: #aaa; -} - -/* ─── TARGET BLOCK ─── */ -.target-block-section { - padding: 12px 16px; -} - -.target-label { - display: block; - color: #888; - font-size: 10px; - text-transform: uppercase; - margin-bottom: 6px; -} - -.target-input { - width: 100%; - padding: 10px; - background: rgba(255,255,255,0.05); - border: 1px solid #333; - border-radius: 6px; - color: #fff; - font-size: 13px; - font-family: 'Courier New', monospace; - box-sizing: border-box; -} - -.target-input:focus { - outline: none; - border-color: #ff6600; -} - -.target-chip { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-top: 8px; - padding: 6px 10px; - background: rgba(255,102,0,0.08); - border: 1px solid #ff6600; - border-radius: 6px; -} - -.target-chip-label { - color: #ffb27a; - font-size: 11px; +.car-crew-selector * { box-sizing: border-box; } +.car-crew-selector button { font: inherit; cursor: pointer; } +.car-crew-selector button:focus-visible, .car-crew-selector input:focus-visible { + outline: 2px solid #ffd6a4; outline-offset: 3px; +} +.car-crew-selector .ccs-header { + display: flex; align-items: center; gap: 18px; min-height: 72px; + padding: 11px clamp(14px, 3vw, 36px); border-bottom: 1px solid var(--ccs-line); + background: linear-gradient(90deg, #081117, #17212a 55%, #090f15); +} +.car-crew-selector .ccs-back { + min-width: 56px; min-height: 42px; padding: 8px; color: #f4dfcd; + background: rgba(255,255,255,.04); border: 1px solid var(--ccs-line); border-radius: 6px; +} +.car-crew-selector .ccs-heading { flex: 1; min-width: 0; } +.car-crew-selector .ccs-kicker { display: block; color: var(--ccs-cyan); font-size: 10px; letter-spacing: .24em; } +.car-crew-selector .ccs-title { margin: 3px 0 0; color: #fff4e5; font-size: clamp(16px, 2vw, 26px); line-height: 1.15; letter-spacing: .11em; } +.car-crew-selector .ccs-count { font-size: 24px; font-weight: 700; color: var(--ccs-amber); } +.car-crew-selector .ccs-count span { color: #82919b; font-size: 13px; } +.car-crew-selector .ccs-grid { + display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(320px, .82fr); + gap: clamp(16px, 3vw, 30px); max-width: 1200px; padding: 22px clamp(14px, 3vw, 36px) 34px; + margin: 0 auto; align-items: start; +} +.car-crew-selector .ccs-left, .car-crew-selector .ccs-right { min-width: 0; } +.car-crew-selector .car-topdown { + position: relative; width: 100%; min-height: 310px; aspect-ratio: 16 / 10; + overflow: hidden; isolation: isolate; border: 1px solid #54707b; border-radius: 12px; + background: #0e1b25; + box-shadow: 0 16px 48px #0008, inset 0 0 0 1px #b5d5d122; +} +.car-crew-selector .ccs-scene, .car-crew-selector .ccs-scene img, +.car-crew-selector .ccs-scene-vignette { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.car-crew-selector .ccs-scene img { display: block; object-fit: cover; object-position: center; } +.car-crew-selector .ccs-scene-vignette { background: linear-gradient(90deg, #06101896, transparent 27% 73%, #06101896), linear-gradient(#06101888, transparent 30% 68%, #061018cc); } +.car-crew-selector .ccs-plate-label, .car-crew-selector .ccs-plate-foot { + position: absolute; z-index: 1; left: 16px; right: 16px; color: #e7ebdd; font-size: 10px; + letter-spacing: .15em; text-shadow: 0 2px 4px #000; +} +.car-crew-selector .ccs-plate-label { top: 16px; } +.car-crew-selector .ccs-plate-foot { bottom: 16px; color: #f6c69b; } +.car-crew-selector .car-body { + position: absolute; z-index: 2; left: 50%; top: 53%; transform: translate(-50%, -50%); + width: clamp(190px, 44%, 280px); padding: 5px; + border: 1px solid #d7dcd45e; border-radius: 54px 54px 24px 24px; + background: rgba(9, 18, 27, .21); box-shadow: 0 0 0 8px #5b84911c; +} +.car-crew-selector .car-cabin { + display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; padding: 13px 8px; +} +.car-crew-selector .car-seat { + width: 100%; min-height: 68px; min-width: 0; padding: 4px; + display: flex; align-items: center; justify-content: center; position: relative; + color: #f9f1e7; border: 1px dashed #d0dadd9c; border-radius: 10px; + background: rgba(5, 14, 21, .81); transition: border-color .18s, background .18s; +} +.car-crew-selector .car-seat:hover, .car-crew-selector .car-seat.selected { + border-color: #ffe0af; background: rgba(49, 63, 61, .91); +} +.car-crew-selector .car-seat.occupied { border-style: solid; border-color: #98c9c8; } +.car-crew-selector .car-seat.unavailable { border-color: #f19577; } +.car-crew-selector .seat-empty, .car-crew-selector .seat-member { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } +.car-crew-selector .seat-plus { font-size: 19px; line-height: 1; color: #e7bd90; } +.car-crew-selector .seat-label { color: #e6ebeb; text-align: center; font-size: 9px; line-height: 1.16; } +.car-crew-selector .seat-avatar, .car-crew-selector .picker-avatar { + flex: none; width: 35px; height: 35px; object-fit: cover; border-radius: 50%; + border: 1px solid #c4e0dc; background: #1b313a; text-align: center; +} +.car-crew-selector .portrait-unavailable { display: grid; place-items: center; color: #fff; } +.car-crew-selector .seat-name { font-size: 10px; font-weight: 700; white-space: nowrap; max-width: 102px; overflow: hidden; text-overflow: ellipsis; } +.car-crew-selector .seat-role { font-size: 8px; letter-spacing: .09em; color: #f7c69e; } +.car-crew-selector .seat-legend { + display: grid; gap: 8px; margin: 12px 0 15px; padding: 14px; + border: 1px solid var(--ccs-line); border-radius: 8px; background: #101a22; +} +.car-crew-selector .legend-item { font-size: 11px; line-height: 1.45; color: #d5dcdd; } +.car-crew-selector .legend-item strong { color: var(--ccs-amber); margin-right: 8px; } +.car-crew-selector .member-picker, .car-crew-selector .ccs-manifest, .car-crew-selector .target-block-section { + border: 1px solid var(--ccs-line); border-radius: 8px; background: #121d26; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.target-chip-clear { - background: none; - border: none; - color: #888; - font-size: 16px; - cursor: pointer; - line-height: 1; -} - -.target-offline { - margin-top: 8px; -} - -.target-label-sub { - color: #666; - text-transform: none; -} - -/* ─── LAUNCH ─── */ -.launch-section { - position: sticky; - bottom: calc(48px + env(safe-area-inset-bottom, 0px)); - z-index: 20; - padding: 12px 16px; - margin-top: auto; - background: linear-gradient(180deg, rgba(10, 10, 10, 0), rgba(10, 10, 10, 0.98) 24%); -} - -.launch-btn { - width: 100%; - padding: 14px; - border-radius: 8px; - font-size: 14px; - font-weight: bold; - text-transform: uppercase; - cursor: pointer; - border: none; - font-family: 'Courier New', monospace; -} - -.launch-btn.ready { - background: linear-gradient(135deg, #ff6600, #ff3300); - color: #fff; } - -.launch-btn.disabled { - background: #222; - color: #555; - cursor: not-allowed; +.car-crew-selector .member-picker { margin-top: 12px; } +.car-crew-selector .picker-header, .car-crew-selector .ccs-panel-heading { + display: flex; justify-content: space-between; align-items: center; gap: 8px; + padding: 11px 14px; border-bottom: 1px solid var(--ccs-line); + color: var(--ccs-cyan); font-size: 10px; font-weight: 700; letter-spacing: .13em; +} +.car-crew-selector .picker-header button { min-width: 38px; min-height: 38px; background: none; border: 0; color: #f8e8dd; font-size: 24px; } +.car-crew-selector .picker-list { max-height: 240px; overflow-y: auto; padding: 6px; } +.car-crew-selector .picker-empty { padding: 20px; color: #d1c8bc; font-size: 11px; line-height: 1.5; } +.car-crew-selector .picker-member { + display: flex; width: 100%; min-height: 54px; align-items: center; gap: 10px; + padding: 7px 9px; margin: 2px 0; text-align: left; color: #f7f2e9; + border: 1px solid transparent; border-radius: 6px; background: none; +} +.car-crew-selector .picker-member:hover { border-color: #e9aa7088; background: #d0915022; } +.car-crew-selector .picker-avatar { width: 38px; height: 38px; } +.car-crew-selector .picker-info { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.car-crew-selector .picker-name { font-size: 12px; font-weight: 700; } +.car-crew-selector .picker-role, .car-crew-selector .picker-stats { font-size: 10px; color: #c9d4d4; } +.car-crew-selector .picker-art-note { font-size: 9px; color: #f0b98b; } +.car-crew-selector .ccs-manifest { padding-bottom: 12px; } +.car-crew-selector .ccs-member-title { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; padding: 15px 14px 6px; } +.car-crew-selector .ccs-member-title strong { font-size: 16px; } +.car-crew-selector .ccs-member-title span { color: var(--ccs-amber); font-size: 10px; text-transform: uppercase; } +.car-crew-selector .ccs-stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; margin: 10px 14px 14px; } +.car-crew-selector .ccs-stat-grid > div { padding: 10px 12px; background: #23303b; border-left: 2px solid #6daeba; } +.car-crew-selector .ccs-stat-grid small, .car-crew-selector .ccs-cargo small { display: block; color: #bdc9cd; font-size: 9px; letter-spacing: .14em; } +.car-crew-selector .ccs-stat-grid strong { display: block; margin-top: 5px; font-size: 13px; font-variant-numeric: tabular-nums; } +.car-crew-selector .ccs-cargo { margin: 0 14px; padding: 11px; border: 1px solid var(--ccs-line); } +.car-crew-selector .ccs-cargo p, .car-crew-selector .ccs-empty-note { color: #d0d4d5; font-size: 11px; line-height: 1.5; } +.car-crew-selector .ccs-cargo ul { list-style: none; margin: 8px 0 0; padding: 0; } +.car-crew-selector .ccs-cargo li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; font-size: 11px; } +.car-crew-selector .ccs-cargo li strong { color: var(--ccs-amber); } +.car-crew-selector .ccs-empty-note { padding: 3px 14px; } +.car-crew-selector .ccs-portrait-note { margin: 10px 14px 0; font-size: 10px; color: #e5ac82; } +.car-crew-selector .target-block-section { padding: 14px; margin-top: 15px; } +.car-crew-selector .target-label { display: block; color: var(--ccs-amber); font-size: 10px; text-transform: uppercase; margin-bottom: 9px; letter-spacing: .08em; } +.car-crew-selector .target-label-sub { color: #c5cbd1; text-transform: none; line-height: 1.4; } +.car-crew-selector .target-input { width: 100%; min-height: 44px; padding: 10px; color: #fff; font: inherit; font-size: 12px; background: #081219; border: 1px solid var(--ccs-line); border-radius: 6px; } +.car-crew-selector .target-offline { margin-top: 12px; } +.car-crew-selector .target-chip { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; margin-top: 10px; border: 1px solid #c38b62; color: #fff0da; } +.car-crew-selector .target-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } +.car-crew-selector .target-chip-clear { min-width: 38px; min-height: 38px; border: 0; color: #fff; background: none; font-size: 20px; } +.car-crew-selector .launch-section { margin-top: 15px; padding: 0; } +.car-crew-selector .launch-btn { width: 100%; min-height: 50px; border: 0; border-radius: 6px; font-size: 12px; font-weight: 700; letter-spacing: .1em; } +.car-crew-selector .launch-btn.ready { color: #1c1612; background: #f3b377; } +.car-crew-selector .launch-btn.ready:hover { background: #ffcd9f; } +.car-crew-selector .launch-btn.disabled { color: #d9d8d5; background: #343c41; cursor: not-allowed; } +.car-crew-selector .ccs-footnote { color: #d1d8d8; line-height: 1.4; font-size: 10px; margin: 9px 0 0; } +@media (max-width: 699px) { + .car-crew-selector .ccs-header { min-height: 65px; gap: 10px; } + .car-crew-selector .ccs-kicker { font-size: 8px; } + .car-crew-selector .ccs-title { font-size: 15px; } + .car-crew-selector .ccs-count { font-size: 18px; } + .car-crew-selector .ccs-grid { grid-template-columns: minmax(0,1fr); gap: 8px; padding: 12px 14px 22px; } + .car-crew-selector .car-topdown { height: 315px; min-height: 315px; aspect-ratio: auto; } + .car-crew-selector .car-body { top: 52%; width: 190px; } + .car-crew-selector .seat-legend { margin-bottom: 9px; } + .car-crew-selector .ccs-stat-grid { gap: 5px; } + .car-crew-selector .ccs-right { display: flex; flex-direction: column; } + .car-crew-selector .launch-section { position: sticky; bottom: calc(48px + env(safe-area-inset-bottom, 0px)); z-index: 5; padding: 11px 0; background: #091219f2; } +} +@media (prefers-reduced-motion: reduce) { + .car-crew-selector .car-seat, .car-crew-selector .picker-member { transition: none; } } diff --git a/frontend/src/components/driveby/CarCrewSelector.tsx b/frontend/src/components/driveby/CarCrewSelector.tsx index 626be14..dcb0f5d 100644 --- a/frontend/src/components/driveby/CarCrewSelector.tsx +++ b/frontend/src/components/driveby/CarCrewSelector.tsx @@ -1,273 +1,191 @@ -// ============================================================ -// CarCrewSelector - Pick gang members for each car seat -// Driver: dealer/enforcer/lookout (drives only, no shooting) -// Passengers: shooters (can fire weapons) -// Shows top-down car view with clickable seats -// ============================================================ - -import React, { useState, useMemo, useEffect } from 'react'; +// Crew preparation for the passenger-only drive-by; the driver never fires from the car. +import React, { useEffect, useMemo, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useGangStore } from '../../stores/gameStore'; import { useCombatIntentStore } from '../../stores/combatIntentStore'; -import { generatePlaceholderAvatar } from '../../services/ai/artPipeline'; +import type { GangMember } from '../../types/game.types'; +import { getPortrait } from '../../render/worldActorResolver'; +import { getCarLoadoutBackdropUrl } from '../../services/assetResolver'; import AddressSearchBar, { type AddressResult } from '../map/AddressSearchBar'; -import { - normalizeAddressText, - type DriveByTarget, -} from '../../utils/driveByTarget'; +import { normalizeAddressText, type DriveByTarget } from '../../utils/driveByTarget'; import './CarCrewSelector.css'; -// ═══════════════════════════════════════════════════════════ -// TYPES -// ═══════════════════════════════════════════════════════════ - export interface CarSeat { position: 'driver' | 'passenger' | 'back_left' | 'back_right'; label: string; allowedRoles: string[]; memberId: string | null; } - export interface CarCrew { seats: CarSeat[]; - /** Structured drive-by target (#108) — carries coordinates when geocoded. */ targetBlock: DriveByTarget | null; } +interface Props { onConfirm: (crew: CarCrew) => void; onCancel: () => void } -interface CarCrewSelectorProps { - onConfirm: (crew: CarCrew) => void; - onCancel: () => void; +const DRIVER_ROLES = ['dealer', 'recruit', 'shooter', 'driver']; +const SHOOTER_ROLES = ['shooter']; +const INITIAL_SEATS: CarSeat[] = [ + { position: 'driver', label: 'Driver', allowedRoles: DRIVER_ROLES, memberId: null }, + { position: 'passenger', label: 'Passenger (Shooter)', allowedRoles: SHOOTER_ROLES, memberId: null }, + { position: 'back_left', label: 'Back Left (Shooter)', allowedRoles: SHOOTER_ROLES, memberId: null }, + { position: 'back_right', label: 'Back Right (Shooter)', allowedRoles: SHOOTER_ROLES, memberId: null }, +]; +function eligible(seat: CarSeat, member: GangMember | undefined): boolean { + return Boolean(member?.status === 'active' && seat.allowedRoles.includes(member.role ?? '')); +} +function ready(seats: CarSeat[], members: GangMember[]): boolean { + const occupied = seats.filter(s => s.memberId); + if (new Set(occupied.map(s => s.memberId)).size !== occupied.length) return false; + if (!occupied.every(s => eligible(s, members.find(m => m.id === s.memberId)))) return false; + return Boolean(seats.find(s => s.position === 'driver')?.memberId && occupied.some(s => s.position !== 'driver')); +} +const storedPortrait = (member: GangMember) => member.customAvatarUrl || member.portraitUrl || member.avatarUrl; +function MemberPortrait({ member, className, onFallback }: { member: GangMember; className: string; onFallback?: () => void }) { + const identity = storedPortrait(member); + const roleArt = getPortrait(member.role ?? 'recruit'); + const [url, setUrl] = useState(identity || roleArt); + useEffect(() => setUrl(identity || roleArt), [identity, roleArt]); + if (!url) return {member.name.slice(0, 1)}; + return {identity { + if (url !== roleArt && roleArt) { onFallback?.(); setUrl(roleArt); } + else setUrl(null); + }} />; } -// ═══════════════════════════════════════════════════════════ -// COMPONENT -// ═══════════════════════════════════════════════════════════ - -const CarCrewSelector: React.FC = ({ onConfirm, onCancel }) => { - const { members } = useGangStore(); - const pendingTarget = useCombatIntentStore((s) => s.pendingTarget); - - const [seats, setSeats] = useState([ - { position: 'driver', label: 'Driver', allowedRoles: ['dealer', 'enforcer', 'lookout'], memberId: null }, - { position: 'passenger', label: 'Passenger (Shooter)', allowedRoles: ['shooter'], memberId: null }, - { position: 'back_left', label: 'Back Left (Shooter)', allowedRoles: ['shooter'], memberId: null }, - { position: 'back_right', label: 'Back Right (Shooter)', allowedRoles: ['shooter'], memberId: null }, - ]); - - const [selectedSeat, setSelectedSeat] = useState(null); +const CarCrewSelector: React.FC = ({ onConfirm, onCancel }) => { + const members = useGangStore(s => s.members); + const pendingTarget = useCombatIntentStore(s => s.pendingTarget); + const [seats, setSeats] = useState(() => INITIAL_SEATS.map(s => ({ ...s }))); + const [selectedSeat, setSelectedSeat] = useState(null); + const [focusedId, setFocusedId] = useState(null); + const [failedPortraits, setFailedPortraits] = useState>(() => new Set()); const [target, setTarget] = useState(pendingTarget); const [targetText, setTargetText] = useState(''); + useEffect(() => { if (pendingTarget) setTarget(pendingTarget); }, [pendingTarget]); + + const assigned = useMemo(() => new Set(seats.map(s => s.memberId).filter(Boolean)), [seats]); + const selected = seats.find(s => s.position === selectedSeat); + const choices = selected ? members.filter(m => !assigned.has(m.id) && eligible(selected, m)) : []; + const focused = members.find(m => m.id === focusedId); + const canLaunch = ready(seats, members); + const count = seats.filter(s => s.memberId && eligible(s, members.find(m => m.id === s.memberId))).length; + const portraitKey = (member: GangMember) => `${member.id}:${storedPortrait(member)}`; + const showsRoleArt = (member: GangMember) => !storedPortrait(member) || failedPortraits.has(portraitKey(member)); + const markPortraitFallback = (member: GangMember) => setFailedPortraits(prev => new Set(prev).add(portraitKey(member))); - useEffect(() => { - if (pendingTarget) setTarget(pendingTarget); - }, [pendingTarget]); - - // Available members (not already assigned to a seat, not jailed/dead/hospital) - const assignedIds = useMemo(() => new Set(seats.map(s => s.memberId).filter(Boolean)), [seats]); - - const availableMembers = useMemo(() => { - return members.filter(m => - m.status === 'active' && - !assignedIds.has(m.id) - ); - }, [members, assignedIds]); - - const getEligibleMembers = (seat: CarSeat) => { - return availableMembers.filter(m => seat.allowedRoles.includes(m.role ?? 'soldier')); - }; - - const assignMember = (seatPosition: string, memberId: string) => { - setSeats(prev => prev.map(s => - s.position === seatPosition ? { ...s, memberId } : s - )); + function assignMember(position: CarSeat['position'], id: string) { + const seat = seats.find(s => s.position === position); + if (!seat || !eligible(seat, useGangStore.getState().members.find(m => m.id === id)) || + seats.some(s => s.memberId === id)) return; + setSeats(prev => prev.map(s => s.position === position ? { ...s, memberId: id } : s)); + setFocusedId(id); setSelectedSeat(null); - }; - - const removeMember = (seatPosition: string) => { - setSeats(prev => prev.map(s => - s.position === seatPosition ? { ...s, memberId: null } : s - )); - }; - - const getMemberById = (id: string) => members.find(m => m.id === id); - - const canLaunch = seats[0].memberId !== null && seats.some(s => s.position !== 'driver' && s.memberId !== null); - - return ( -
-
- - 🚗 LOAD UP THE CAR -
- - {/* Top-Down Car View */} -
-
-
-
+ } + function launch() { + // Validate again against current store to avoid a stale, invalid seat at click time. + if (!ready(seats, useGangStore.getState().members)) return; + const typed = targetText.trim(); + const finalTarget: DriveByTarget | null = target ?? + (typed ? { address: normalizeAddressText(typed), seedMode: 'text-seed' } : null); + onConfirm({ seats, targetBlock: finalTarget }); + useCombatIntentStore.getState().reset(); + } + + return
+
+ +
SLIDE / MISSION PREP

LOAD UP THE CAR

+ {count}/04 +
+
+
+
+ +
+
+ 01 DRIVER Dealer / recruit / shooter / driver — drives only + 02–04 PASSENGERS Shooters only — fire from the car
+ {selected && +
ASSIGN / {selected.label.toUpperCase()} +
+
{choices.length === 0 ?
No available {selected.position === 'driver' ? 'driver' : 'shooters'}. Recruit or free a member in CREW.
: + choices.map(member => assignMember(selected.position, member.id)} whileTap={{ scale: 0.98 }}> + markPortraitFallback(member)} /> + {member.name} + {(member.role ?? 'recruit').toUpperCase()} · LV {member.level} + {showsRoleArt(member) && Role portrait — not member likeness} + HP {member.health ?? '—'} + )}
+
}
- - {/* Member Picker (when a seat is selected) */} - - {selectedSeat && ( - -
- Pick member for: {seats.find(s => s.position === selectedSeat)?.label} - +
+
+
CREW / LOADOUT01–04
+ {focused ? <>
{focused.name}{focused.role ?? 'recruit'} · LV {focused.level}
+
+
HEALTH{focused.health === undefined ? 'Not recorded' : + focused.maxHealth === undefined ? `${focused.health} · max not recorded` : `${focused.health} / ${focused.maxHealth}`}
+
MORALE{focused.morale}/100
+
LOYALTY{focused.loyalty}/100
+
AGILITY{focused.stats.agility}/100
-
- {getEligibleMembers(seats.find(s => s.position === selectedSeat)!).length === 0 ? ( -
- No eligible members available. Need: {seats.find(s => s.position === selectedSeat)?.allowedRoles.join(', ')} -
- ) : ( - getEligibleMembers(seats.find(s => s.position === selectedSeat)!).map(member => ( - assignMember(selectedSeat, member.id)} - whileTap={{ scale: 0.98 }} - > - {member.name} -
- {member.name} - {member.role} • LV{member.level} -
-
- 🎯 {(member.stats as any)?.shooting ?? member.stats?.strength ?? 50} - ❤️ {member.health || 100} -
-
- )) - )} +
CARRIED ITEMS + {focused.inventory === undefined ?

Carried items not recorded

: + focused.inventory.some(item => item.quantity > 0) ? +
    {focused.inventory.filter(item => item.quantity > 0).map(item => +
  • {item.name || item.itemId}×{item.quantity}
  • )}
+ :

No items carried

}
- - )} - - - {/* Target Block Selection (#108): structured target via address search, - with a clearly labelled offline text-seed fallback. */} -
- - { - setTarget({ - address: r.address, - lat: r.lat, - lng: r.lng, - placeId: r.placeId, - seedMode: 'geocoded', - }); + {showsRoleArt(focused) &&

Role portrait — not member likeness

} + :

Assign a member to inspect their condition and actual carried gear.

} +
+
+ + { + setTarget({ address: r.address, lat: r.lat, lng: r.lng, placeId: r.placeId, seedMode: 'geocoded' }); setTargetText(''); - }} - /> - {target && ( -
- 🎯 {target.address} - -
- )} - {!target && ( -
- - setTargetText(e.target.value)} - placeholder="e.g. 63rd & King Drive" - /> -
- )} + }} /> + {target &&
{target.address} +
} + {!target &&
+ setTargetText(e.target.value)} placeholder="e.g. 63rd & King Drive" />
} +
+
+ {canLaunch ? 'SLIDE ON THEY BLOCK' : 'Need driver + at least 1 shooter'} +

Driver controls the car. Only seated passenger shooters can fire during a drive-by.

- - {/* Launch Button */} -
- { - if (!canLaunch) return; - const typed = targetText.trim(); - const finalTarget: DriveByTarget | null = - target ?? - (typed - ? { address: normalizeAddressText(typed), seedMode: 'text-seed' } - : null); - onConfirm({ seats, targetBlock: finalTarget }); - useCombatIntentStore.getState().reset(); - }} - disabled={!canLaunch} - whileTap={canLaunch ? { scale: 0.95 } : {}} - > - {canLaunch ? '🚗💨 SLIDE ON THEY BLOCK' : 'Need driver + at least 1 shooter'} - -
-
- ); +
+
; }; - export default CarCrewSelector; diff --git a/frontend/src/components/driveby/__tests__/carCrewTarget.test.tsx b/frontend/src/components/driveby/__tests__/carCrewTarget.test.tsx index 890d9eb..e6d031e 100644 --- a/frontend/src/components/driveby/__tests__/carCrewTarget.test.tsx +++ b/frontend/src/components/driveby/__tests__/carCrewTarget.test.tsx @@ -3,7 +3,7 @@ // resolution with its real coordinates (never the fixed fallback). import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; // ── Mock the gang store with a driver + a shooter ──────────── const baseStats = { strength: 50, agility: 50, intelligence: 50, charisma: 50, luck: 50, intimidation: 50 }; @@ -16,13 +16,24 @@ const mkMember = (id: string, name: string, role: string) => ({ status: 'active', currentAssignment: null, joinedAt: '2026-01-01', role, }); -const mockMembers = [ +type FixtureMember = ReturnType & { + customAvatarUrl?: string; + portraitUrl?: string; + health?: number; + maxHealth?: number; + inventory?: Array<{ itemId: string; type: string; name: string; quantity: number }>; +}; +let mockMembers: FixtureMember[] = [ mkMember('d1', 'Wheel Man', 'dealer'), mkMember('s1', 'Trigger', 'shooter'), ]; vi.mock('../../../stores/gameStore', () => ({ - useGangStore: () => ({ members: mockMembers }), + useGangStore: Object.assign( + (selector?: (state: { members: typeof mockMembers }) => unknown) => + selector ? selector({ members: mockMembers }) : { members: mockMembers }, + { getState: () => ({ members: mockMembers }) }, + ), })); // Mock the placeholder avatar pipeline (avoids image/canvas work) @@ -37,6 +48,7 @@ import { useCombatIntentStore } from '../../../stores/combatIntentStore'; describe('CarCrewSelector → street handoff (#108)', () => { beforeEach(() => { vi.clearAllMocks(); + mockMembers = [mkMember('d1', 'Wheel Man', 'dealer'), mkMember('s1', 'Trigger', 'shooter')]; useCombatIntentStore.getState().reset(); }); @@ -160,4 +172,115 @@ describe('CarCrewSelector → street handoff (#108)', () => { const b = resolveStreetForTarget(picks[1].targetBlock); expect(a.seed).not.toBe(b.seed); }); + + it('offers dealer, recruit, shooter and driver as drivers, but only shooters as passengers', () => { + mockMembers = [ + ...mockMembers, + mkMember('r1', 'New Recruit', 'recruit'), + mkMember('x1', 'Heavy', 'enforcer'), + mkMember('l1', 'Watch', 'lookout'), + mkMember('v1', 'Wheel Specialist', 'driver'), + ]; + render( {}} onCancel={() => {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + for (const name of ['Wheel Man', 'Trigger', 'New Recruit', 'Wheel Specialist']) { + expect(screen.getByText(name)).toBeInTheDocument(); + } + expect(screen.queryByText('Heavy')).not.toBeInTheDocument(); + expect(screen.queryByText('Watch')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Passenger (Shooter)', { selector: '.seat-label' }).closest('.car-seat')!); + expect(screen.getByText('Trigger')).toBeInTheDocument(); + for (const name of ['Wheel Man', 'New Recruit', 'Heavy', 'Watch', 'Wheel Specialist']) { + expect(screen.queryByText(name)).not.toBeInTheDocument(); + } + }); + + it('lets a shooter drive without counting them as the passenger shooter', () => { + mockMembers = [...mockMembers, mkMember('s2', 'Second Shooter', 'shooter')]; + const onConfirm = vi.fn(); + render( {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + fireEvent.click(screen.getByText('Trigger')); + expect(screen.getByRole('button', { name: /need driver.*shooter/i })).toBeDisabled(); + fireEvent.click(screen.getByText('Passenger (Shooter)', { selector: '.seat-label' }).closest('.car-seat')!); + expect(screen.queryByText('Trigger', { selector: '.picker-name' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Second Shooter')); + fireEvent.click(screen.getByRole('button', { name: /slide on they block/i })); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm.mock.calls[0][0].seats.map((seat: { memberId: string | null }) => seat.memberId)).toEqual(['s1', 's2', null, null]); + }); + + it('refuses a now-injured or changed-role passenger before launch', () => { + const onConfirm = vi.fn(); + const { rerender } = render( {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + fireEvent.click(screen.getByText('Wheel Man')); + fireEvent.click(screen.getByText('Passenger (Shooter)', { selector: '.seat-label' }).closest('.car-seat')!); + fireEvent.click(screen.getByText('Trigger')); + mockMembers = mockMembers.map(m => m.id === 's1' ? { ...m, role: 'dealer', status: 'injured' } : m); + rerender( {}} />); + expect(screen.getByRole('button', { name: /need driver.*shooter/i })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: /need driver.*shooter/i })); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('shows the member portrait first, and labels role art when identity is missing', () => { + mockMembers = [ + { ...mkMember('d1', 'Wheel Man', 'dealer'), customAvatarUrl: '/private/member-portrait.webp' }, + mkMember('s1', 'Trigger', 'shooter'), + ]; + render( {}} onCancel={() => {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + expect(screen.getAllByRole('img', { name: /wheel man.*portrait/i })[0]).toHaveAttribute('src', '/private/member-portrait.webp'); + fireEvent.click(screen.getByText('Wheel Man')); + expect(within(screen.getByRole('button', { name: /remove wheel man from driver/i })).getByRole('img', { name: /wheel man.*portrait/i })).toHaveAttribute('src', '/private/member-portrait.webp'); + fireEvent.click(screen.getByText('Passenger (Shooter)', { selector: '.seat-label' }).closest('.car-seat')!); + expect(screen.getAllByText(/role portrait.*not member likeness/i).length).toBeGreaterThan(0); + expect(screen.getByRole('img', { name: /shooter.*role portrait/i })).toHaveAttribute('src', expect.stringMatching(/\.webp$/)); + }); + + it('visibly labels role art when a stored personal portrait fails to load', () => { + mockMembers = [ + { ...mkMember('d1', 'Wheel Man', 'dealer'), customAvatarUrl: '/private/broken-member.webp' }, + mkMember('s1', 'Trigger', 'shooter'), + ]; + render( {}} onCancel={() => {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + fireEvent.error(screen.getByRole('img', { name: 'Wheel Man portrait' })); + expect(within(screen.getByRole('button', { name: /wheel man/i })).getByText('Role portrait — not member likeness')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Wheel Man')); + expect(within(screen.getByRole('region', { name: /member status and carried items/i })).getByText('Role portrait — not member likeness')).toBeInTheDocument(); + }); + + it('shows actual carried inventory and core member stats without inventing a weapon or cash', () => { + mockMembers = [{ + ...mkMember('d1', 'Wheel Man', 'dealer'), level: 4, health: 73, morale: 65, loyalty: 91, + inventory: [{ itemId: 'kit-1', type: 'tool', name: 'Field kit', quantity: 2 }], + }, mkMember('s1', 'Trigger', 'shooter')]; + render( {}} onCancel={() => {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + fireEvent.click(screen.getByText('Wheel Man')); + expect(screen.getByText('Field kit').closest('li')).toHaveTextContent('Field kit×2'); + expect(screen.getByText('73 · max not recorded')).toBeInTheDocument(); + expect(screen.getByText(/65\/100/)).toBeInTheDocument(); + expect(screen.getByText(/91\/100/)).toBeInTheDocument(); + expect(screen.queryByText(/Glock|\$200/)).not.toBeInTheDocument(); + }); + + it('loads registered desktop and phone car plates through the scene picture', () => { + const { container } = render( {}} onCancel={() => {}} />); + expect(container.querySelector('.ccs-scene img')).toHaveAttribute('src', + '/assets/runtime/generated/environments/street/block_slide_car_loadout_desktop_v001.webp'); + expect(container.querySelector('.ccs-scene source')).toHaveAttribute('srcset', + '/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp'); + }); + + it('does not invent full health or gear when the roster has no health record', () => { + render( {}} onCancel={() => {}} />); + fireEvent.click(screen.getByText('Driver', { selector: '.seat-label' }).closest('.car-seat')!); + fireEvent.click(screen.getByText('Wheel Man')); + expect(screen.getByText('Not recorded')).toBeInTheDocument(); + expect(screen.getByText('Carried items not recorded')).toBeInTheDocument(); + expect(screen.queryByText(/100\s*\/\s*100/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/services/assetResolver.ts b/frontend/src/services/assetResolver.ts index 1123372..cace77f 100644 --- a/frontend/src/services/assetResolver.ts +++ b/frontend/src/services/assetResolver.ts @@ -14,6 +14,7 @@ import { } from '../assets/assetManifest'; import { getWorldActor } from '../render/worldActorResolver'; import type { MemberRole } from '../types/block.types'; +import runtimeManifest from '../assets/runtimeManifest.json'; // ─── Role → character kit ──────────────────────────────────── // Roles without dedicated art borrow the closest existing kit. @@ -77,6 +78,13 @@ export function getHeroStreetBackdropUrl(): string { ?? getDefaultStreetBackdropUrl(); } +/** Registered generated car-loadout plates; an older manifest keeps the scene playable. */ +export function getCarLoadoutBackdropUrl(view: 'desktop' | 'mobile'): string { + const id = `generated.environments.street.block_slide_car_loadout_${view}_v001`; + const entry = runtimeManifest.entries.find(a => a.id === id && a.class === 'env-street'); + return entry?.runtimePath ?? getHeroStreetBackdropUrl(); +} + export function getHeroTopdownBgUrl(): string { return environmentAssets[HERO_ENVIRONMENT_ID].topdownBg ?? getDefaultTopdownBgUrl(); }