# Petri Dish Game > Groundwork for a Spore Cell-Stage-style mini-game on the Petri Dish — drive a specimen, eat smaller cells to grow and gain DNA, dodge bigger ones. --- .. toc:: ### Cell Stage Press **Start** and drive your specimen around the dish with the **joystick** (or **WASD / arrow keys**). It's the basic loop of Spore's Cell Stage: - **Green** cells are smaller than you — eat them to gain **DNA** and **grow**. - **Red** cells are bigger — they're threats; contact drains your **health**. - Grow big enough and you **evolve**. .. admonition::Groundwork :color: blue This is an early, self-contained scaffold (the whole game is a clientside loop rendering an SVG overlay each tick). It's the play surface for the [Cell Generator](/cell-silhouette-generator): the plan is for *your* generated specimen to become the player avatar, and for DNA to unlock new outline sections you evolve via Gemini. The other cells will be drawn from the catalogue + generated pool, sorted into food vs. threat by relative size. .. exec::docs.petri_dish_game.petri_dish_game :code: false ### Roadmap - Player avatar = your **My Specimens** cell from the Cell Generator. - DNA spend → unlock outline sections → send back through Gemini for new parts. - Diet (herbivore / carnivore / omnivore) gates what counts as food. ### Source ```python # File: docs/petri_dish_game/petri_dish_game.py from pathlib import Path from dash import dcc, html, clientside_callback, Input, Output, State import dash_mantine_components as dmc from dash_iconify import DashIconify import dash_gauge as dg from lib.adaptations import EYESPOT_VISION, TIER_LABEL from lib.diet import SPECIMEN_DIET, DIET_LABEL # /petri-dish-game — a Spore "Cell Stage"-style mini-game on a LARGER petri world # than fits on screen: a camera follows your specimen (the SVG viewBox windows the # world), so you explore to find cells. Every cell forages — grazing the Conway # "plankton" and eating smaller cells to grow and bank DNA — a real food chain. # Your EYESPOT adaptation (equipped via the Eyespot Lab, shared player-loadout) # sets how much of the world you see and how it's drawn (radar/blur/muted/full). # Bank enough DNA and the game pauses to send you off to evolve. # Rendering is the shared window.CV renderer (assets/cell_vision.js). ACCENT = "#3399ff" _PETRI = Path(__file__).resolve().parents[2] / "assets" / "petri" _NAMES = {"navicula": "Navicula", "closterium": "Closterium", "spirogyra": "Spirogyra", "volvox": "Volvox", "euglena": "Euglena", "amoeba": "Amoeba", "paramecium": "Paramecium", "stentor": "Stentor", "vorticella": "Vorticella", "actinosphaerium": "Actinosphaerium", "philodina": "Philodina", "daphnia": "Daphnia", "cyclops": "Cyclops", "celegans": "C. elegans", "_generic": "Generic"} IMGS = [f"/assets/petri/{p.stem}.svg" for p in sorted(_PETRI.glob("*.svg"))] PLAYER_CHOICES = [{"value": f"/assets/petri/{p.stem}.svg", "label": _NAMES.get(p.stem, p.stem.title())} for p in sorted(_PETRI.glob("*.svg")) if p.stem != "_generic"] _TIERS = ["none", "poor", "decent", "great"] def _hud_stat(label, _id, icon, color): return dmc.Group( [DashIconify(icon=icon, width=16, color=color), dmc.Text(label, size="xs", c="dimmed", style={"fontFamily": "monospace"}), dmc.Text("0", id=_id, fw=700, size="sm", style={"fontFamily": "monospace"})], gap=6, wrap="nowrap", ) # ----------------------------------------------------------------- arena ------- _arena = dmc.Paper( [ dmc.Group( [dmc.Text("PETRI DISH GAME", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Badge("CELL STAGE", id="pg-state-badge", color="brand", variant="light", styles={"root": {"fontFamily": "monospace"}})], justify="space-between", mb="xs", ), html.Div( [ html.Div( [ html.Div(id="pg-arena", style={"position": "absolute", "inset": 0, "zIndex": 1}), html.Div(id="pg-banner", style={"position": "absolute", "inset": 0, "zIndex": 4, "display": "none", "alignItems": "center", "justifyContent": "center", "flexDirection": "column", "gap": "8px", "padding": "10%", "background": "rgba(0,0,0,0.5)", "borderRadius": "50%", "color": "#fff", "fontFamily": "monospace", "textAlign": "center"}), ], className="petri-dish", style={"position": "absolute", "inset": 0, "borderRadius": "50%", "overflow": "hidden"}, ), html.Div( dg.DashRCJoystick(id="pg-joy", baseRadius=48, controllerRadius=22, directionCountMode="Nine", insideMode=True, throttle=40), style={"position": "absolute", "right": "2px", "bottom": "2px", "zIndex": 6, "padding": "6px", "borderRadius": "50%", "background": "rgba(255,255,255,0.55)", "backdropFilter": "blur(2px)", "boxShadow": "0 2px 10px rgba(15,80,72,0.25)"}, ), ], style={"position": "relative", "aspectRatio": "1 / 1", "width": "100%", "maxWidth": 460, "margin": "0 auto"}, ), dcc.Interval(id="pg-tick", interval=60, n_intervals=0, disabled=True), dcc.Interval(id="pg-preview-tick", interval=400, n_intervals=0, max_intervals=1), ], p="md", radius="md", withBorder=True, style={"flex": "1 1 460px", "maxWidth": 540, "background": "linear-gradient(rgb(255 255 255 / 0%) 0%, rgb(243 247 239 / 30%) 100%)", "borderColor": "rgb(51 142 234 / 24%)"}, ) # ----------------------------------------------------------------- console ----- _console = dmc.Stack( [ dmc.Text("CONTROL", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Paper( dmc.Stack( [_hud_stat("DNA", "pg-dna", "mdi:dna", "#7048e8"), _hud_stat("SIZE", "pg-size", "tabler:circle", ACCENT), dmc.Group([DashIconify(icon="mdi:food-fork-drink", width=16, color="#37b24d"), dmc.Text("DIET", size="xs", c="dimmed", style={"fontFamily": "monospace"}), dmc.Badge("omnivore", id="pg-diet", color="grape", variant="light", size="sm")], gap=6), dmc.Group([DashIconify(icon="tabler:heart", width=16, color="#e8590c"), dmc.Text("HEALTH", size="xs", c="dimmed", style={"fontFamily": "monospace"})], gap=6), dmc.Progress(id="pg-hp", value=100, color="red", animated=True, striped=True)], gap="xs", ), withBorder=True, radius="md", p="sm", style={"background": "light-dark(#eef5ff, #16202e)"}, ), dmc.Select(id="pg-player", label="Your specimen", value=PLAYER_CHOICES[3]["value"], data=PLAYER_CHOICES, allowDeselect=False, leftSection=DashIconify(icon="mdi:microscope")), dmc.Stack( [dmc.Group([DashIconify(icon="tabler:eye", width=15, color=ACCENT), dmc.Text("EYESPOT (vision)", fw=700, size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"})], gap=6), dmc.SegmentedControl(id="pg-eye", value="none", fullWidth=True, color="brand", radius="md", size="xs", data=[{"value": t, "label": TIER_LABEL[t]} for t in _TIERS]), dmc.Anchor("Splice a better eye in the Eyespot Lab →", href="/adaptation-eyespot", size="xs", c=ACCENT)], gap=4, ), dmc.Group( [dmc.Button("Start", id="pg-start", color="brand", leftSection=DashIconify(icon="tabler:player-play")), dmc.Button("Resume", id="pg-resume", variant="light", color="brand", leftSection=DashIconify(icon="tabler:player-play-filled")), dmc.Button("Reset", id="pg-reset", variant="default", leftSection=DashIconify(icon="tabler:refresh"))], gap="xs", ), dmc.Text("Drive with the joystick or WASD / arrow keys. Eat green cells & plankton to grow and " "bank DNA; dodge red ones (ringed hunters chase you). Your eyespot sets how far you see.", id="pg-status", size="xs", c="dimmed"), dmc.Paper( dmc.Stack( [dmc.Group([dmc.Badge("●", color="green", variant="filled", size="xs"), dmc.Text("green — smaller than you (food)", size="xs")], gap=6), dmc.Group([dmc.Badge("◎", color="red", variant="filled", size="xs"), dmc.Text("ringed — hunter, chases & kills", size="xs")], gap=6), dmc.Group([dmc.Badge("●", color="red", variant="light", size="xs"), dmc.Text("red — bigger than you (threat)", size="xs")], gap=6), dmc.Group([dmc.Badge("●", color="teal", variant="light", size="xs"), dmc.Text("faint green — plankton (Conway)", size="xs")], gap=6)], gap=4, ), withBorder=True, radius="md", p="sm", ), ], gap="sm", style={"flex": "1 1 240px", "maxWidth": 300}, ) component = dmc.Stack( [ dmc.Group([_arena, _console], align="flex-start", gap="xl", wrap="wrap"), dcc.Store(id="pg-imgs", data=IMGS), dcc.Store(id="pg-vis", data=EYESPOT_VISION), dcc.Store(id="pg-diet-data", data=SPECIMEN_DIET), dcc.Store(id="pg-cmd", data={"cmd": "idle"}), dcc.Store(id="pg-preview-sink"), ], gap="md", ) # ======================== client-side game engine ============================ # Start / Reset / Resume -> a command the loop reads, and toggles the tick. clientside_callback( """ function(nStart, nReset, nResume) { const ctx = window.dash_clientside.callback_context; const trig = (ctx.triggered[0] || {}).prop_id || ''; const nu = window.dash_clientside.no_update; if (trig.indexOf('pg-start') >= 0) { return [{cmd: 'start'}, false]; } if (trig.indexOf('pg-resume') >= 0) { return [{cmd: 'resume'}, false]; } if (trig.indexOf('pg-reset') >= 0) { return [{cmd: 'reset'}, true]; } return [nu, nu]; } """, Output("pg-cmd", "data"), Output("pg-tick", "disabled"), Input("pg-start", "n_clicks"), Input("pg-reset", "n_clicks"), Input("pg-resume", "n_clicks"), prevent_initial_call=True, ) # Equip the eyespot tier from the saved loadout when the page mounts. clientside_callback( """ function(loadout) { // player-loadout.eyespot is now a slot object {part,tier,...}; coerce to tier. const lo = window.__migrateLoadout ? window.__migrateLoadout(loadout) : (loadout || {}); return (lo.eyespot && lo.eyespot.tier) || (typeof lo.eyespot === 'string' ? lo.eyespot : 'none'); } """, Output("pg-eye", "value"), Input("player-loadout", "data"), ) # Main loop: physics + food chain + camera + render (via window.CV). clientside_callback( """ function(n, cmd, jAngle, jDist, playerImg, imgs, eyeTier, vis, loadout, dietData) { const W = 2400, CX = 1200, CY = 1200, RR = 1180; // larger world + bound const GN = 64, CW = W / GN; // Conway plankton grid const EMAX = 150, PMAX = 110; // max entity / player radius const inDish = (gx, gy) => Math.hypot((gx + 0.5) * CW - CX, (gy + 0.5) * CW - CY) < RR - 8; imgs = imgs || []; const host = document.getElementById('pg-arena'); const banner = document.getElementById('pg-banner'); const nu = window.dash_clientside.no_update; if (!window.CV) { return [nu, nu, nu, nu, nu]; } if (!window.__pgKeysInit) { window.__pgKeysInit = true; window.__pgKeys = {}; window.addEventListener('keydown', e => { window.__pgKeys[e.key.toLowerCase()] = true; }); window.addEventListener('keyup', e => { window.__pgKeys[e.key.toLowerCase()] = false; }); } const keys = window.__pgKeys || {}; const rnd = (a, b) => a + Math.random() * (b - a); const gauss = () => Math.random() + Math.random() + Math.random() - 1.5; const TRAJS = ['brownian', 'gliding', 'helical', 'run_and_tumble', 'hopping', 'amoeboid', 'ballistic']; const TBS = {brownian: 0.8, gliding: 1.9, helical: 2.2, run_and_tumble: 2.7, hopping: 1.2, amoeboid: 1.0, ballistic: 2.4}; function steerIn(en, h, str) { if (Math.hypot(en.x - CX, en.y - CY) > 0.78 * RR) { const ih = Math.atan2(CY - en.y, CX - en.x); return h + Math.atan2(Math.sin(ih - h), Math.cos(ih - h)) * str; } return h; } function step(en) { const t = en.traj, bs = en.bs; if (t === 'brownian') { en.vx = (en.vx + gauss() * 0.6) * 0.9; en.vy = (en.vy + gauss() * 0.6) * 0.9; } else if (t === 'gliding') { en.h = steerIn(en, en.h + gauss() * 0.05, 0.25); en.vx = Math.cos(en.h) * bs; en.vy = Math.sin(en.h) * bs; } else if (t === 'helical') { en.h = steerIn(en, en.h + 0.02, 0.25); en.ph += 0.5; const l = Math.sin(en.ph) * bs * 0.5; en.vx = Math.cos(en.h) * bs + Math.cos(en.h + 1.5708) * l; en.vy = Math.sin(en.h) * bs + Math.sin(en.h + 1.5708) * l; } else if (t === 'run_and_tumble') { if (en.rl <= 0) { en.h = Math.random() * 6.283; en.rl = 18 + (Math.random() * 22 | 0); } en.rl--; en.h = steerIn(en, en.h, 0.5); en.vx = Math.cos(en.h) * bs; en.vy = Math.sin(en.h) * bs; } else if (t === 'hopping') { en.ph++; if (en.ph % 14 === 0) { en.h = Math.random() * 6.283; en.vx = Math.cos(en.h) * bs * 6; en.vy = Math.sin(en.h) * bs * 6; } else { en.vx *= 0.8; en.vy *= 0.8; } } else if (t === 'amoeboid') { en.h = steerIn(en, en.h + gauss() * 0.4, 0.3); en.vx = Math.cos(en.h) * bs; en.vy = Math.sin(en.h) * bs; } else { en.h = steerIn(en, en.h + gauss() * 0.12, 0.3); en.ph += 0.8; const l = Math.sin(en.ph) * bs * 0.9; en.vx = Math.cos(en.h) * bs + Math.cos(en.h + 1.5708) * l; en.vy = Math.sin(en.h) * bs + Math.sin(en.h + 1.5708) * l; } } function spawn(kind, pr, px, py) { const r = kind === 'food' ? rnd(10, Math.max(14, pr * 0.85)) : rnd(pr * 1.05, pr * 1.9 + 10); let x, y; do { const a = rnd(0, 6.283), d = rnd(160, RR - r); x = CX + Math.cos(a) * d; y = CY + Math.sin(a) * d; } while (Math.hypot(x - px, y - py) < 360); // not on top of the player const tj = TRAJS[Math.random() * TRAJS.length | 0]; return {x: x, y: y, r: r, vx: 0, vy: 0, k: kind, dna: 0, hunt: kind === 'threat' && Math.random() < 0.5, deg: rnd(0, 360), traj: tj, h: rnd(0, 6.283), ph: rnd(0, 100), rl: 0, bs: TBS[tj] * (kind === 'food' ? 1 : 0.9), img: imgs[Math.random() * imgs.length | 0] || ''}; } function newGol() { const g = new Uint8Array(GN * GN); for (let gy = 0; gy < GN; gy++) for (let gx = 0; gx < GN; gx++) { if (inDish(gx, gy) && Math.random() < 0.30) { g[gy * GN + gx] = 1; } } return g; } // per-specimen speed lean + a small per-spawn luck roll, so each specimen // (and each playthrough) feels a little different. (Foundation for the // movement adaptations — see the Movement Labs.) const SPEEDF = {navicula: 0.85, closterium: 0.85, spirogyra: 0.7, volvox: 1.2, euglena: 1.25, amoeba: 0.8, paramecium: 1.35, stentor: 0.95, vorticella: 0.95, actinosphaerium: 0.8, philodina: 1.05, daphnia: 1.1, cyclops: 1.15, celegans: 1.0}; const slugOf = (s) => (s || '').split('/').pop().replace('.svg', ''); function init() { const img = playerImg || imgs[0] || ''; const sf = SPEEDF[slugOf(img)] || 1.0, luck = 0.9 + Math.random() * 0.24; const p = {x: CX, y: CY, r: 26, dna: 0, hp: 100, maxhp: 100, deg: 0, img: img, spd: 5.6 * sf * luck, diet: (dietData && dietData[slugOf(img)]) || 'omnivore'}; const e = []; for (let i = 0; i < 28; i++) { e.push(spawn('food', p.r, p.x, p.y)); } for (let i = 0; i < 10; i++) { e.push(spawn('threat', p.r, p.x, p.y)); } return {p: p, e: e, gol: newGol(), golTick: 0, over: false, won: false, evolved: false}; } function grazePlankton(g, ax, ay, ar, gain) { // returns dna gained, grows nothing let got = 0; const gx0 = Math.max(0, (ax - ar) / CW | 0), gx1 = Math.min(GN - 1, Math.ceil((ax + ar) / CW)); const gy0 = Math.max(0, (ay - ar) / CW | 0), gy1 = Math.min(GN - 1, Math.ceil((ay + ar) / CW)); for (let gy = gy0; gy <= gy1; gy++) for (let gx = gx0; gx <= gx1; gx++) { if (g[gy * GN + gx]) { const cx2 = (gx + 0.5) * CW, cy2 = (gy + 0.5) * CW; if (Math.hypot(cx2 - ax, cy2 - ay) < ar) { g[gy * GN + gx] = 0; got += gain; } } } return got; } // --- commands ------------------------------------------------------- const c = (cmd && cmd.cmd) || 'idle'; if (c === 'reset') { window.__pg = null; window.__pgCmd = 'idle'; if (banner) { banner.style.display = 'none'; } if (host) { host.innerHTML = ''; } return ['0', '26', 100, nu, nu]; } if (c === 'resume' && window.__pg) { window.__pg.evolved = true; if (banner) { banner.style.display = 'none'; } } // (re)spawn ONLY on Start, with the currently-selected specimen — so diet, // speed and avatar all reflect your pick. Before Start the preview shows. if (c === 'start' && window.__pgCmd !== 'started') { window.__pg = init(); window.__pgCmd = 'started'; } if (!window.__pg) { return [nu, nu, nu, nu, nu]; } const G = window.__pg, p = G.p; if (G.over) { return nu; } // --- player control ------------------------------------------------- let ax = 0, ay = 0; if (jAngle != null && jDist) { const m = Math.min(1, jDist / 26), a = jAngle * Math.PI / 180; ax += Math.cos(a) * m; ay += -Math.sin(a) * m; } if (keys['w'] || keys['arrowup']) { ay -= 1; } if (keys['s'] || keys['arrowdown']) { ay += 1; } if (keys['a'] || keys['arrowleft']) { ax -= 1; } if (keys['d'] || keys['arrowright']) { ax += 1; } const SP = p.spd || 5.6; p.x += ax * SP; p.y += ay * SP; if (Math.abs(ax) + Math.abs(ay) > 0.1) { p.deg = Math.atan2(ay, ax) * 180 / Math.PI; } { const dd = Math.hypot(p.x - CX, p.y - CY); if (dd > RR - p.r) { const k = (RR - p.r) / dd; p.x = CX + (p.x - CX) * k; p.y = CY + (p.y - CY) * k; } } // --- Conway plankton: evolve + everyone grazes ---------------------- G.golTick++; if (G.golTick % 9 === 0) { const g = G.gol, n2 = new Uint8Array(GN * GN); for (let gy = 0; gy < GN; gy++) for (let gx = 0; gx < GN; gx++) { if (!inDish(gx, gy)) { continue; } let cnt = 0; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) { continue; } const nx = gx + dx, ny = gy + dy; if (nx >= 0 && ny >= 0 && nx < GN && ny < GN) { cnt += g[ny * GN + nx]; } } const al = g[gy * GN + gx]; n2[gy * GN + gx] = (al ? (cnt === 2 || cnt === 3) : (cnt === 3)) ? 1 : 0; } for (let s = 0; s < 40; s++) { const gx = Math.random() * GN | 0, gy = Math.random() * GN | 0; if (inDish(gx, gy)) { n2[gy * GN + gx] = 1; } } G.gol = n2; } // player grazes plankton — herbivores & omnivores only (diet guard-rail) if (p.diet !== 'carnivore') { const got = grazePlankton(G.gol, p.x, p.y, p.r, 1); if (got) { p.dna += got; p.r = Math.min(PMAX, p.r + 0.05 * got); p.hp = Math.min(p.maxhp, p.hp + 0.04 * got); } } // --- entities: forage / chase + grow (food chain) ------------------- const CHASE = 360, HUNT_SP = 3.0, SENSE = 420; for (let i = 0; i < G.e.length; i++) { const en = G.e[i]; const tox = p.x - en.x, toy = p.y - en.y, td = Math.hypot(tox, toy) || 1; const huntPlayer = en.hunt && td < CHASE && en.r >= p.r * 0.9 && G.golTick > 40; if (huntPlayer) { en.vx += (tox / td) * 0.34; en.vy += (toy / td) * 0.34; const sp = Math.hypot(en.vx, en.vy); if (sp > HUNT_SP) { en.vx *= HUNT_SP / sp; en.vy *= HUNT_SP / sp; } } else if (en.hunt || en.r > 70) { // predators forage: seek nearest smaller cell let best = null, bd = SENSE; for (let j = 0; j < G.e.length; j++) { if (j === i) { continue; } const o = G.e[j]; if (o.r < en.r * 0.85) { const dd = Math.hypot(o.x - en.x, o.y - en.y); if (dd < bd) { bd = dd; best = o; } } } if (best) { const ddx = best.x - en.x, ddy = best.y - en.y, dl = Math.hypot(ddx, ddy) || 1; en.vx += (ddx / dl) * 0.22; en.vy += (ddy / dl) * 0.22; const sp = Math.hypot(en.vx, en.vy), mx = en.bs * 1.4; if (sp > mx) { en.vx *= mx / sp; en.vy *= mx / sp; } } else { step(en); } } else { step(en); } en.x += en.vx; en.y += en.vy; const ed = Math.hypot(en.x - CX, en.y - CY); if (ed > RR - en.r) { en.vx *= -1; en.vy *= -1; const k = (RR - en.r) / ed; en.x = CX + (en.x - CX) * k; en.y = CY + (en.y - CY) * k; } if (Math.hypot(en.vx, en.vy) > 0.3) { en.deg = Math.atan2(en.vy, en.vx) * 180 / Math.PI; } // entity grazes plankton -> grows slowly const got = grazePlankton(G.gol, en.x, en.y, en.r, 1); if (got) { en.dna += got; en.r = Math.min(EMAX, en.r + 0.04 * got); } } // entity-entity predation (bigger eats smaller) const dead = new Set(); for (let i = 0; i < G.e.length; i++) { if (dead.has(i)) { continue; } const en = G.e[i]; for (let j = 0; j < G.e.length; j++) { if (j === i || dead.has(j)) { continue; } const o = G.e[j]; if (en.r >= o.r * 1.15 && Math.hypot(en.x - o.x, en.y - o.y) < en.r + o.r * 0.6) { en.dna += Math.round(o.r); en.r = Math.min(EMAX, Math.sqrt(en.r * en.r + o.r * o.r * 0.5)); dead.add(j); } } } if (dead.size) { G.e = G.e.filter((_, i) => !dead.has(i)); } while (G.e.length < 38) { G.e.push(spawn(Math.random() < 0.7 ? 'food' : 'threat', p.r, p.x, p.y)); } // --- player vs entities --------------------------------------------- for (let i = G.e.length - 1; i >= 0; i--) { const en = G.e[i], dist = Math.hypot(en.x - p.x, en.y - p.y); if (dist < p.r + en.r) { if (en.r <= p.r * 0.92 && p.diet !== 'herbivore') { // carnivores/omnivores eat cells p.dna += Math.round(en.r); p.r = Math.min(PMAX, Math.sqrt(p.r * p.r + en.r * en.r * 0.6)); p.hp = Math.min(p.maxhp, p.hp + 1.5); G.e.splice(i, 1); G.e.push(spawn(Math.random() < 0.7 ? 'food' : 'threat', p.r, p.x, p.y)); } else if (en.r >= p.r * 1.08) { if (en.hunt) { G.over = true; G.won = false; G.cause = 'hunted'; } else { p.hp -= 0.9; const kx = (p.x - en.x) / (dist || 1), ky = (p.y - en.y) / (dist || 1); p.x += kx * 3; p.y += ky * 3; } } } } if (p.hp <= 0) { G.over = true; G.won = false; G.cause = G.cause || 'starved'; } if (p.r >= 100) { G.over = true; G.won = true; } // --- camera + render via shared CV ---------------------------------- const v = (vis && vis[eyeTier]) || (vis && vis.none) || {view: 520, style: 'muted'}; const vr = v.view, vb = [p.x - vr, p.y - vr, 2 * vr, 2 * vr]; const m = vr * 1.15, cells = [], plank = []; for (const en of G.e) { if (Math.abs(en.x - p.x) > m || Math.abs(en.y - p.y) > m) { continue; } const kind = en.r <= p.r * 0.92 ? 'food' : (en.r >= p.r * 1.08 ? (en.hunt ? 'hunter' : 'threat') : 'food'); cells.push({x: en.x, y: en.y, r: en.r, img: en.img, kind: kind, deg: en.deg || 0, ring: kind === 'hunter' && v.style === 'full'}); } cells.push({x: p.x, y: p.y, r: p.r, img: p.img, kind: 'player', deg: p.deg || 0, ring: true}); { const g = G.gol; const gx0 = Math.max(0, (p.x - m) / CW | 0), gx1 = Math.min(GN - 1, Math.ceil((p.x + m) / CW)); const gy0 = Math.max(0, (p.y - m) / CW | 0), gy1 = Math.min(GN - 1, Math.ceil((p.y + m) / CW)); for (let gy = gy0; gy <= gy1; gy++) for (let gx = gx0; gx <= gx1; gx++) { if (g[gy * GN + gx]) { plank.push({x: (gx + 0.5) * CW, y: (gy + 0.5) * CW, s: CW * 0.6}); } } } if (host) { host.innerHTML = window.CV.renderArena({vb: vb, style: v.style, cells: cells, plankton: plank, cam: {x: p.x, y: p.y, vr: vr}, t: G.golTick}); } // --- DNA threshold -> pause & offer evolution ----------------------- const THRESH = 12; let outDisabled = nu, outLoadout = nu; if (!G.evolved && p.dna >= THRESH && !G.over) { G.evolved = true; outDisabled = true; // preserve the slot-object loadout (move/feed/locks/sprite); set eyespot tier. const lo = window.__migrateLoadout ? window.__migrateLoadout(loadout) : {dna: 0}; lo.dna = p.dna; const curTier = (lo.eyespot && lo.eyespot.tier) || eyeTier || 'none'; lo.eyespot = {part: 'eyespot', tier: curTier, placement: 0, arc: 360, locked: false, sprite: (lo.eyespot && lo.eyespot.sprite) || null}; outLoadout = lo; if (banner) { banner.style.display = 'flex'; banner.innerHTML = '
\\u26a1 ' + p.dna + ' DNA banked
' + '
Evolve your eyespot, then Start a fresh run with ' + 'better sight \\u2014 or Resume to keep eating.
' + '\\u2192 Eyespot Lab'; } } if (G.over && banner) { banner.style.display = 'flex'; const lose = G.cause === 'hunted' ? '\\u2620 HUNTED DOWN' : '\\u2620 STARVED'; banner.innerHTML = '
' + (G.won ? '\\ud83c\\udf89 EVOLVED!' : lose) + '
' + '
DNA ' + p.dna + ' \\u00b7 size ' + Math.round(p.r) + '
' + '
press Reset, then Start
'; } return [String(p.dna), String(Math.round(p.r)), Math.max(0, Math.round(p.hp)), outDisabled, outLoadout]; } """, Output("pg-dna", "children"), Output("pg-size", "children"), Output("pg-hp", "value"), Output("pg-tick", "disabled", allow_duplicate=True), Output("player-loadout", "data", allow_duplicate=True), Input("pg-tick", "n_intervals"), State("pg-cmd", "data"), State("pg-joy", "angle"), State("pg-joy", "distance"), State("pg-player", "value"), State("pg-imgs", "data"), State("pg-eye", "value"), State("pg-vis", "data"), State("player-loadout", "data"), State("pg-diet-data", "data"), prevent_initial_call=True, ) # Show the chosen specimen's natural diet (herbivore/carnivore/omnivore) — the # guard-rail the game applies (herbivore grazes plankton, carnivore eats cells). clientside_callback( """ function(img, diets) { const slug = (img || '').split('/').pop().replace('.svg', ''); const d = (diets || {})[slug] || 'omnivore'; const col = {herbivore: 'green', carnivore: 'red', omnivore: 'grape'}[d] || 'grape'; return [d, col]; } """, Output("pg-diet", "children"), Output("pg-diet", "color"), Input("pg-player", "value"), State("pg-diet-data", "data"), ) # Live-swap the player's silhouette when "Your specimen" changes (mid-game too). clientside_callback( """ function(img, diets) { if (img && window.__pg && window.__pg.p) { window.__pg.p.img = img; const slug = (img || '').split('/').pop().replace('.svg', ''); window.__pg.p.diet = (diets || {})[slug] || 'omnivore'; } return window.dash_clientside.no_update; } """, Output("pg-player", "id"), Input("pg-player", "value"), State("pg-diet-data", "data"), prevent_initial_call=True, ) # Player PREVIEW before Start (and after Reset): show the chosen specimen centred, # rendered in the current eyespot style, so the dish isn't empty until you play. clientside_callback( """ function(_n, img, eyeTier, vis) { if (!window.CV) { return window.dash_clientside.no_update; } if (window.__pgCmd === 'started' && window.__pg && !window.__pg.over) { return window.dash_clientside.no_update; } const host = document.getElementById('pg-arena'); if (!host) { return window.dash_clientside.no_update; } const vv = (vis && vis[eyeTier]) || {view: 520, style: 'muted'}; const style = vv.style === 'radar' ? 'muted' : vv.style; // show the avatar even with no eye host.innerHTML = window.CV.renderArena({ vb: [-260, -260, 520, 520], style: style, cells: [{x: 0, y: 0, r: 120, img: img, kind: 'player', deg: -90, ring: true}], plankton: [], cam: {x: 0, y: 0, vr: 260}, t: 0, }); return window.dash_clientside.no_update; } """, Output("pg-preview-sink", "data"), Input("pg-preview-tick", "n_intervals"), Input("pg-player", "value"), Input("pg-eye", "value"), State("pg-vis", "data"), prevent_initial_call=False, ) ``` :defaultExpanded: false :withExpandedButton: true --- *Source: /petri-dish-game*