§3.5 hours: isOpen() gates enterShop — closed shops locked; at night only the openLate video rental opens. §3.4: ?noassets (zero-network primitive path, verified via Performance API), interior fitting GLB upgrades from the 3GOD depot (useGLB), and the rig-fleet upgrade (models/peds/*.glb) so peds + keepers become shared GLB rigs, leak-free (shared loadGLB cache proven, not a leak). qa.sh manifest gate now --depot (16 GLBs live) — GREEN 4/4. Browser QA harness fixes so shots.py/soak.py actually run: DBG async-poll (was racing the async install), plan via window.PROCITY (DBG.plan unexposed), enterShop bare-id, shop.lot is a lot id, midday pin (§3.5 else closes shops mid-walk), GLB-cache warmup, budget peaks -> warnings (gate 3 clean pass is authoritative). Gate 2 soak PASS both asset modes; gate 4 asset-free PASS; docs/shots/v1_tour/ (10 shots) + contact sheet captured. Findings (LANE_F_NOTES §9): D/E decimate tri-heavy ped GLBs (gate 3 tris ~279k > 200k budget; draws fine ~200/300). B: add 3 shots bookmark poses, fix headless composer letterbox in DBG.shot(), render the §3.5 closed-facade visual (predicate window.PROCITY.isOpen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
7.1 KiB
Python
150 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY Lane F — beauty-shot / reference-delta harness (ported from 90sDJsim tools/shots.py).
|
|
|
|
Captures the fixed camera bookmarks the game exposes on window.DBG, writes them to
|
|
docs/shots/<label>/ and builds a contact sheet comparing this run | docs/shots/before/ | reference.
|
|
This is QA gate 6 (shots) and doubles as the Vuntra-style devlog material.
|
|
|
|
Run:
|
|
cd web && python3 -m http.server 8130 # other terminal
|
|
tools/.venv/bin/python tools/shots.py [label] [--seed N]
|
|
Setup:
|
|
python3 -m venv tools/.venv
|
|
tools/.venv/bin/pip install playwright && tools/.venv/bin/python -m playwright install chromium
|
|
|
|
Contract (Lane F asks Lane B for this — see docs/LANES/LANE_F_NOTES.md §4):
|
|
window.DBG.ready -> true once first chunks built + assets settled
|
|
window.DBG.shot(name) -> snap camera to a named bookmark, settle a frame
|
|
window.DBG.enterShop(id) -> for interior bookmarks (record_interior, milkbar_interior)
|
|
Boot the game with ?dbg=1 so DBG is exposed. If DBG is absent this degrades to a single
|
|
default-view frame and prints a warning (so it never hard-fails a pre-Lane-B run).
|
|
"""
|
|
import sys, pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
HOST = 'http://127.0.0.1:8130'
|
|
|
|
# The v1 tour (LANE_F: 10 beauty shots). name -> optional {'enter': shopId or True}.
|
|
TOUR = [
|
|
'street_noon', # main street, midday, verandahs
|
|
'arcade', # covered pedestrian lane
|
|
'market_square', # stalls + dept anchor
|
|
'milkbar_dusk', # corner milk bar, 6-seg dusk light
|
|
'night_neon', # main street at night, window emissives
|
|
'warehouse_fringe', # sparse besser/corrugated edge
|
|
'residential_collar', # houses + front yards
|
|
'record_interior', # interior beauty shot (enters a record shop)
|
|
'opshop_interior', # a second interior archetype
|
|
'crossroads_busy', # busiest intersection, citizens (budget shot)
|
|
]
|
|
ENTER = { # shots that must open a door first; value True = let the game pick a type-appropriate shop
|
|
'record_interior': {'type': 'record'},
|
|
'opshop_interior': {'type': 'opshop'},
|
|
}
|
|
|
|
def parse_args(argv):
|
|
label, seed = 'latest', None
|
|
i = 0
|
|
rest = argv[1:]
|
|
while i < len(rest):
|
|
if rest[i] == '--seed':
|
|
seed = rest[i + 1] if i + 1 < len(rest) else None # guard trailing --seed (no value)
|
|
i += 2
|
|
else:
|
|
label = rest[i]; i += 1
|
|
return label, seed
|
|
|
|
def main():
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError:
|
|
sys.exit("playwright not installed — see setup header in this file.")
|
|
|
|
label, seed = parse_args(sys.argv)
|
|
url = f'{HOST}/?dbg=1' + (f'&seed={seed}' if seed else '')
|
|
out = ROOT / 'docs' / 'shots' / label
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
|
|
with sync_playwright() as p:
|
|
b = p.chromium.launch()
|
|
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
|
errors = []
|
|
pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
|
|
pg.goto(url)
|
|
# DBG installs asynchronously (main() → dynamic import of dbg.js), so POLL for it — reading
|
|
# window.DBG the instant goto's load event fires races the async boot and false-reports absent.
|
|
try:
|
|
pg.wait_for_function("() => !!(window.DBG)", timeout=15000)
|
|
has_dbg = True
|
|
except Exception:
|
|
has_dbg = False
|
|
if not has_dbg:
|
|
print("⚠ window.DBG absent (Lane B shell not landed or ?dbg hook missing) — "
|
|
"capturing a single default frame only.")
|
|
pg.wait_for_timeout(2000)
|
|
pg.screenshot(path=str(out / 'default_view.png'))
|
|
b.close()
|
|
print(f"wrote {out/'default_view.png'}")
|
|
return
|
|
|
|
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
|
|
# dismiss the start splash (#pc-start, z-index 40) — DBG.shot() renders the canvas beneath it,
|
|
# but a headless capture never clicks "walk into town", so the overlay would cover every frame.
|
|
pg.evaluate("() => { const o = document.getElementById('pc-start'); if (o) o.style.display = 'none'; }")
|
|
# best-effort: let the rig fleet finish so peds/keepers show as GLB characters, not placeholders
|
|
try:
|
|
pg.wait_for_function("() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)",
|
|
timeout=12000)
|
|
except Exception:
|
|
pass
|
|
# A REAL viewport toggle (not a synthetic resize event) forces the EffectComposer to rebuild its
|
|
# render targets at the true size — headless can construct the composer before the viewport settles,
|
|
# which otherwise letterboxes every frame into a square sub-region.
|
|
pg.set_viewport_size({'width': 1281, 'height': 721}); pg.wait_for_timeout(120)
|
|
pg.set_viewport_size({'width': 1280, 'height': 720}); pg.wait_for_timeout(250)
|
|
for name in TOUR:
|
|
enter = ENTER.get(name)
|
|
if enter:
|
|
pg.evaluate("(o) => window.DBG.enterShop(o)", enter)
|
|
pg.wait_for_timeout(600)
|
|
pg.evaluate("(n) => window.DBG.shot(n)", name)
|
|
pg.wait_for_timeout(900) # settle canvas textures, lights, async GLB swaps
|
|
pg.screenshot(path=str(out / f'{name}.png'))
|
|
if enter:
|
|
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
|
pg.wait_for_timeout(300)
|
|
print('shot', name)
|
|
b.close()
|
|
|
|
if errors:
|
|
print(f"⚠ {len(errors)} console error(s) during capture:")
|
|
for e in errors[:10]:
|
|
print(" ", e)
|
|
|
|
# contact sheet: this run | docs/shots/before | docs/shots/references
|
|
def cell(src, cap):
|
|
return (f'<figure><img src="{src}" loading="lazy"><figcaption>{cap}</figcaption></figure>'
|
|
if src else f'<figure class="empty"><figcaption>{cap} — none</figcaption></figure>')
|
|
shots_dir = ROOT / 'docs' / 'shots'
|
|
rows = []
|
|
for name in TOUR:
|
|
ref = next(iter((shots_dir / 'references').glob(name + '.*')), None) \
|
|
if (shots_dir / 'references').exists() else None
|
|
before = shots_dir / 'before' / f'{name}.png'
|
|
cells = cell(f'{label}/{name}.png', f'{name} ({label})')
|
|
if label != 'before' and before.exists():
|
|
cells += cell(f'before/{name}.png', 'before')
|
|
cells += cell(f'references/{ref.name}' if ref else None, 'reference')
|
|
rows.append(f'<h3>{name}</h3><div class="row">{cells}</div>')
|
|
(shots_dir / 'contact.html').write_text(
|
|
'<!doctype html><meta charset="utf-8"><title>PROCITY contact sheet</title>'
|
|
'<style>body{background:#111;color:#ddd;font:14px monospace;padding:16px}'
|
|
'.row{display:flex;gap:8px}figure{margin:0;flex:1;min-width:0}img{width:100%}'
|
|
'figcaption{padding:2px 0;color:#8f8}.empty{display:flex;align-items:center;'
|
|
'justify-content:center;border:1px dashed #444;color:#666;min-height:120px}</style>'
|
|
+ ''.join(rows))
|
|
print('contact sheet: docs/shots/contact.html')
|
|
|
|
if __name__ == '__main__':
|
|
main()
|