diff --git a/art/title.jpg b/art/title.jpg new file mode 100644 index 0000000..efef840 Binary files /dev/null and b/art/title.jpg differ diff --git a/src/game.js b/src/game.js index 25ffbce..07782cf 100644 --- a/src/game.js +++ b/src/game.js @@ -9,6 +9,9 @@ const cvs = document.getElementById('game'); const ctx = cvs.getContext('2d'); const VW = cvs.width, VH = cvs.height; +const titleArt = new Image(); +titleArt.src = 'art/title.jpg'; + // ---- balance ---- const BAL = { bpm: 112, @@ -878,8 +881,20 @@ function overlay(color, big, small, prompt) { function drawTitle() { const t = performance.now() / 1000; + if (titleArt.complete && titleArt.naturalWidth) { + // cover-fit the painted backdrop, then knock it back so the logo reads + const s = Math.max(VW / titleArt.naturalWidth, VH / titleArt.naturalHeight); + const w = titleArt.naturalWidth * s, h = titleArt.naturalHeight * s; + ctx.drawImage(titleArt, (VW - w) / 2, (VH - h) / 2, w, h); + const g = ctx.createLinearGradient(0, 0, 0, VH); + g.addColorStop(0, 'rgba(5,6,10,.86)'); + g.addColorStop(0.45, 'rgba(5,6,10,.52)'); + g.addColorStop(1, 'rgba(5,6,10,.92)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, VW, VH); + } // drifting waveform - ctx.strokeStyle = '#1c2a44'; + ctx.strokeStyle = '#1c2a4499'; ctx.lineWidth = 2; for (let k = 0; k < 3; k++) { ctx.beginPath(); @@ -900,7 +915,12 @@ function drawTitle() { ctx.fillStyle = '#8fa3c8'; ctx.font = '15px ui-monospace, Menlo, monospace'; ctx.fillText('a possession roguelite — Ranarama × Paradroid', VW / 2, 205); - ctx.fillStyle = '#5a6a80'; + // the painted backdrop is busy — text carries its own shadow rather than + // washing the art out with a heavier scrim + ctx.save(); + ctx.shadowColor = '#05060a'; + ctx.shadowBlur = 8; + ctx.fillStyle = '#93a9c4'; ctx.font = '13px ui-monospace, Menlo, monospace'; const lines = [ 'You are a rogue SIGNAL. Hardware cannot see you. Static can.', @@ -908,14 +928,20 @@ function drawTitle() { 'Its body is your health, your ammo, your disguise. It is always dying.', 'SPACE fire (hold = gain = loud) · E eject · WASD move · M mute', ]; - lines.forEach((l, i) => ctx.fillText(l, VW / 2, 300 + i * 24)); + lines.forEach((l, i) => { + ctx.fillText(l, VW / 2, 300 + i * 24); + ctx.fillText(l, VW / 2, 300 + i * 24); // double pass = denser shadow + }); if (Math.floor(t * 2) % 2) { - ctx.fillStyle = '#cfe3ff'; + ctx.fillStyle = '#eaf4ff'; ctx.font = 'bold 17px ui-monospace, Menlo, monospace'; ctx.fillText('PRESS ENTER', VW / 2, 440); + ctx.fillText('PRESS ENTER', VW / 2, 440); } - ctx.fillStyle = '#3a4560'; + ctx.fillStyle = '#6b7a90'; + ctx.font = '13px ui-monospace, Menlo, monospace'; ctx.fillText('purge every machine · the drone resolves when the floor is clean', VW / 2, 560); + ctx.restore(); ctx.textAlign = 'left'; } diff --git a/tools/mbgen.py b/tools/mbgen.py new file mode 100644 index 0000000..f23c0b8 --- /dev/null +++ b/tools/mbgen.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""mbgen.py — batch text→image on the MODELBEAST farm (flux_local / comfyui_sd). + +Reads a JSON job spec, submits with a concurrency cap, polls, and pulls each +output image back by `parent_job` (NEVER newest-asset: two concurrent jobs +raced and crossed outputs on 2026-07-16). + +Spec: [{"out": "path.png", "operator": "flux_local", "params": {...}}, ...] +Usage: mbgen.py spec.json [--max-active 4] +Token: MB_TOKEN env, else ~/Documents/fluxgod-work/.env +""" +import json, os, sys, time, urllib.request, urllib.error + +HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777') +ENVS = ['~/Documents/fluxgod-work/.env', '~/Documents/backnforth/.env'] + + +def token(): + t = os.environ.get('MB_TOKEN') + if t: + return t + for e in ENVS: + p = os.path.expanduser(e) + if os.path.exists(p): + for line in open(p): + if line.startswith('MB_TOKEN='): + return line.split('=', 1)[1].strip().strip('"\'') + sys.exit('no MB_TOKEN') + + +TOK = token() + + +def req(path, data=None, headers=None, raw=False, timeout=180): + h = {'Authorization': f'Bearer {TOK}'} + h.update(headers or {}) + r = urllib.request.Request(HOST + path, data=data, headers=h) + body = urllib.request.urlopen(r, timeout=timeout).read() + if raw: + return body + s = body.decode('utf-8', 'replace') + # job logs carry raw control chars — strip before parsing + return json.loads(''.join(c if c >= ' ' or c in '\t' else ' ' for c in s)) + + +def submit(item): + payload = {'operator': item['operator'], 'params': item['params']} + j = req('/api/jobs', data=json.dumps(payload).encode(), + headers={'Content-Type': 'application/json'}) + return j['id'] + + +def fetch_output(jid, out): + """Pull the image asset belonging to THIS job (parent_job match only).""" + a = req('/api/assets?limit=200') + items = a if isinstance(a, list) else a.get('items', []) + mine = [x for x in items + if x.get('parent_job') == jid + and str(x.get('name', x.get('filename', ''))).lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] + if not mine: + return False + blob = req(f"/api/assets/{mine[0]['id']}/file", raw=True) + os.makedirs(os.path.dirname(out) or '.', exist_ok=True) + with open(out, 'wb') as f: + f.write(blob) + return True + + +def main(): + spec = json.load(open(sys.argv[1])) + cap = 4 + if '--max-active' in sys.argv: + cap = int(sys.argv[sys.argv.index('--max-active') + 1]) + todo = [dict(i, _state='pending') for i in spec + if not (os.path.exists(i['out']) and os.path.getsize(i['out']) > 1000)] + skipped = len(spec) - len(todo) + if skipped: + print(f'[mb] {skipped} already done, skipping') + active, done, failed = {}, 0, [] + t0 = time.time() + while todo or active: + while todo and len(active) < cap: + it = todo.pop(0) + try: + jid = submit(it) + active[jid] = it + print(f'[mb] + {os.path.basename(it["out"])} → {jid[:8]}', flush=True) + except urllib.error.HTTPError as e: + if e.code in (429, 409): # queue full — retry this one later + todo.insert(0, it) + time.sleep(10) + break + failed.append((it['out'], f'submit {e.code}')) + if not active: + continue + time.sleep(6) + for jid in list(active): + try: + st = req(f'/api/jobs/{jid}').get('status') + except Exception: + continue + if st in ('done', 'error', 'cancelled'): + it = active.pop(jid) + if st == 'done' and fetch_output(jid, it['out']): + done += 1 + print(f'[mb] ✓ {os.path.basename(it["out"])} ({done} done, {len(todo)} left, {int(time.time()-t0)}s)', flush=True) + else: + failed.append((it['out'], st)) + print(f'[mb] ✗ {os.path.basename(it["out"])} — {st}', flush=True) + print(f'\n[mb] {done} generated, {len(failed)} failed in {int(time.time()-t0)}s') + for f, why in failed: + print(f' ✗ {os.path.basename(f)} — {why}') + return 1 if failed else 0 + + +if __name__ == '__main__': + sys.exit(main())