diff --git a/src/fullscreen.mjs b/src/fullscreen.mjs new file mode 100644 index 0000000..e6a6469 --- /dev/null +++ b/src/fullscreen.mjs @@ -0,0 +1,31 @@ +/** + * Townmap fullscreen helpers. Prefer the Fullscreen API on the stage; + * fall back to a CSS body class when the API is missing or denied. + */ + +export function isMapFullscreen({ + fullscreenElement = null, + cssFallback = false, +} = {}) { + return Boolean(fullscreenElement) || Boolean(cssFallback); +} + +export function fullscreenButtonCopy(active) { + return { + label: active ? "Exit fullscreen" : "Fullscreen", + ariaLabel: active + ? "Exit fullscreen townmap" + : "Enter fullscreen townmap", + }; +} + +/** Pure decision for the next toggle action. */ +export function nextFullscreenAction({ + fullscreenElement = null, + cssFallback = false, + canRequest = true, +} = {}) { + if (fullscreenElement) return "exit-api"; + if (cssFallback) return "exit-css"; + return canRequest ? "enter-api" : "enter-css"; +} diff --git a/src/interaction.mjs b/src/interaction.mjs index 2e19fe5..32e5a9b 100644 --- a/src/interaction.mjs +++ b/src/interaction.mjs @@ -9,13 +9,27 @@ export function cottageDoors(plots, visible = () => true) { /** A resident is interactable only while their own cottage still has a * rendered plot. Actors survive scene refreshes for animation continuity, so - * the plot list is the authoritative visibility boundary. */ + * the plot list is the authoritative visibility boundary. Roommates stay + * talkable beside the stoop even when the host is indoors. */ export function residentTargets(actors, plots) { const rendered = new Set((plots || []) .filter(plot => plot?.agent && plot.hidden !== true && plot.rendered !== false && plot.visible !== false) .map(plot => plot.agent.id)); - return [...(actors || [])].flatMap(([id, actor]) => + const byId = new Map(actors || []); + const hosts = [...byId].flatMap(([id, actor]) => rendered.has(id) && !actor?.indoors ? [{id, x:actor.x + 5, y:actor.y + 14}] : []); + const mates = (plots || []).flatMap(plot => { + if (!rendered.has(plot.agent?.id)) return []; + const actor = byId.get(plot.agent.id); + const baseX = actor ? actor.x : plot.x + 22; + const baseY = actor ? actor.y : plot.y + 52; + return (plot.agent.roommates || []) + .filter((mate) => mate.status !== "offline") + .map((mate, i) => ({ + id: mate.id, x: baseX + 12 + i * 6, y: baseY + 14, + })); + }); + return hosts.concat(mates); } export function crossedDoor(from, to, doors, direction = 'in') { diff --git a/src/observatory.mjs b/src/observatory.mjs index b774a97..c575d15 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -152,7 +152,15 @@ export function createObservatory(api){ matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',e=>{reduce=e.matches;if(reduce)transition=1;}); let storage=null;try{storage=localStorage;}catch{} const byId=id=>latestAgents.find(a=>a.id===id); - const plotFor=id=>{for(const p of api.getPlots()){if(p.agent.id===id)return p;const k=p.kids?.find(k=>k.agent.id===id);if(k)return {...k,agent:k.agent,parentPlot:p};}return null;}; + const plotFor=id=>{ + for(const p of api.getPlots()){ + if(p.agent.id===id)return p; + if((p.agent.roommates||[]).some(mate=>mate.id===id))return p; + const k=p.kids?.find(k=>k.agent.id===id); + if(k)return {...k,agent:k.agent,parentPlot:p}; + } + return null; + }; const roomFor=a=>{const key=a.id+'|'+(a.taskId||'');if(!rooms.has(key))rooms.set(key,createInterior(a));return rooms.get(key);}; const extras=createVillageExtras({clearKeys:()=>keys.clear(),getPostcard(id){ const agent=byId(id);if(!agent)return null; @@ -169,7 +177,12 @@ export function createObservatory(api){ return conversations.get(key); }; const capabilityFor=a=>isPracticeDemo(a,api.isBuiltInDemo?.())?{available:true,mode:'respond',source:'demo',demo:true}:PUBLIC_DEMO?{available:false,reason:'This is a sample village.'}:conversationCapability(a,api.getEndpoint(),{stale:feedStale}); - const visible=a=>!prFilter||prStage(a.pr)===prFilter; + const visible=a=>{ + if(!prFilter) return true; + if(prStage(a.pr)===prFilter) return true; + // Shared cottages stay visible/enterable when any roommate matches the filter. + return (a.roommates||[]).some(mate=>prStage((byId(mate.id)||mate).pr)===prFilter); + }; const hint=text=>{if(text!==lastHint){$('scene-status').textContent=text;lastHint=text;}}; function placePlayer(){ if(player)return; @@ -287,7 +300,13 @@ export function createObservatory(api){ keys.add(key);e.preventDefault(); } if((key==='e'||key==='Enter')&&!e.repeat){e.preventDefault();interact();} - if(key==='Escape'){e.preventDefault();if(mode==='room')leave();else{followId=null;replaying=false;setMode('town');renderPanel(selected);}} + if((key==='f')&&!e.repeat&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();void api.toggleMapFullscreen?.();} + if(key==='Escape'){ + e.preventDefault(); + if(mode==='room')leave(); + else if(api.isMapFullscreen?.())void api.exitMapFullscreen(); + else{followId=null;replaying=false;setMode('town');renderPanel(selected);} + } } canvas.addEventListener('keydown',onKey);roomCanvas.addEventListener('keydown',onKey); window.addEventListener('keyup',e=>keys.delete(e.key.length===1?e.key.toLowerCase():e.key)); diff --git a/src/roommates.mjs b/src/roommates.mjs new file mode 100644 index 0000000..ebd9e8d --- /dev/null +++ b/src/roommates.mjs @@ -0,0 +1,105 @@ +/** + * Agents that share an absolute worktreePath share one cottage plot. + * Decorative roommate sprites only — each agent remains selectable. + */ + +import { isAbsolutePath } from "./occupancy.mjs"; + +export function roommateKey(agent) { + const path = typeof agent?.worktreePath === "string" ? agent.worktreePath.trim() : ""; + return isAbsolutePath(path) ? path : ""; +} + +/** Stable plot anchor: earliest id. Status and children must not move the cottage. */ +function preferHost(a, b) { + const left = String(a?.id || ""); + const right = String(b?.id || ""); + return left <= right ? a : b; +} + +/** + * Cottage-plot agents only (not shed kids). Shared checkouts collapse to one + * host with a `roommates` array of the other agents on that path. + */ +export function plotAgentsForLayout(agents = []) { + const list = Array.isArray(agents) ? agents.filter(Boolean) : []; + const ids = new Set(list.map((a) => a.id)); + const candidates = list.filter((a) => !a.parent || !ids.has(a.parent)); + const groups = new Map(); + const result = []; + + for (const agent of candidates) { + const key = roommateKey(agent); + if (!key) { + result.push({ ...agent, roommates: [], plotKey: agent.id }); + continue; + } + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(agent); + } + + for (const [key, members] of groups.entries()) { + if (members.length === 1) { + result.push({ ...members[0], roommates: [], plotKey: "wt:" + key }); + continue; + } + const host = members.reduce(preferHost); + const roommates = members + .filter((m) => m.id !== host.id) + .sort((a, b) => String(a.id).localeCompare(String(b.id))); + // Stable slot identity survives host membership churn. + result.push({ ...host, roommates, plotKey: "wt:" + key }); + } + + return result; +} + +/** Lodger ids that must not receive their own plot. */ +export function roommateLodgerIds(agents = []) { + const ids = new Set(); + for (const host of plotAgentsForLayout(agents)) { + for (const mate of host.roommates || []) ids.add(mate.id); + } + return ids; +} + +/** Plot host id for an agent that may be a roommate. */ +export function plotHostId(agents, agentId) { + if (!agentId) return ""; + for (const host of plotAgentsForLayout(agents)) { + if (host.id === agentId) return host.id; + if ((host.roommates || []).some((mate) => mate.id === agentId)) return host.id; + } + return agentId; +} + +/** Household ids on a shared plot (host + roommates). */ +export function plotHouseholdIds(host) { + return new Set([ + host?.id, + ...((host?.roommates || []).map((mate) => mate.id)), + ].filter(Boolean)); +} + +/** True when the host or any roommate is actively working. */ +export function householdWorking(agent) { + if (!agent) return false; + if (agent.status === "working") return true; + return (agent.roommates || []).some((mate) => mate.status === "working"); +} + +/** + * Prefer a household member whose PR matches `matchPr`, else any outstanding PR. + * `matchPr` receives an agent and returns true when it should win. + */ +export function householdDispatchAgent(host, matchPr = null) { + const members = [host, ...(host?.roommates || [])].filter(Boolean); + if (typeof matchPr === "function") { + const match = members.find((mate) => matchPr(mate)); + if (match) return match; + } + const open = members.find( + (mate) => mate.pr && mate.pr.state && mate.pr.state !== "none" && mate.pr.state !== "unknown", + ); + return open || host; +} diff --git a/src/town.html b/src/town.html index 8bcdcd9..e8b4ea1 100644 --- a/src/town.html +++ b/src/town.html @@ -114,6 +114,18 @@ .map-viewport:has(#room-canvas:not([hidden])){overflow:hidden} .map-viewport:has(#room-canvas:not([hidden])) #town{display:none} #room-canvas:not([hidden]){margin-top:0} + .stage:fullscreen,.stage:-webkit-full-screen{box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;padding:12px;border:none;border-radius:0;background:#171d23} + .stage:fullscreen .map-viewport,.stage:-webkit-full-screen .map-viewport{flex:1 1 auto;height:auto;min-height:0} + .stage:fullscreen .stage-foot,.stage:fullscreen .feed,.stage:fullscreen #folklore-hint, + .stage:-webkit-full-screen .stage-foot,.stage:-webkit-full-screen .feed,.stage:-webkit-full-screen #folklore-hint{display:none!important} + body.is-map-fullscreen{overflow:hidden} + body.is-map-fullscreen .app{max-width:none;width:100%;height:100vh;height:100dvh;padding:0;gap:0} + body.is-map-fullscreen header,body.is-map-fullscreen #panel,body.is-map-fullscreen .cottage-directory, + body.is-map-fullscreen .stage-foot,body.is-map-fullscreen .feed,body.is-map-fullscreen #folklore-hint, + body.is-map-fullscreen .pr-tally,body.is-map-fullscreen .village-settings,body.is-map-fullscreen #sound-settings{display:none!important} + body.is-map-fullscreen .split{grid-template-columns:minmax(0,1fr);height:100%;gap:0} + body.is-map-fullscreen .stage{height:100%;display:flex;flex-direction:column;border:none;border-radius:0;padding:10px 12px;background:#171d23} + body.is-map-fullscreen .map-viewport{flex:1 1 auto;height:auto;min-height:0} .scene-status{font-size:11px;color:#c9c9a9;margin:9px 0 1px;min-height:18px} #sound-settings{font-size:12px;color:#bdd2c4;margin-bottom:8px} .village-settings{margin:0 0 10px;color:#c9d4c7;font-size:12px} @@ -237,6 +249,7 @@
Arrow keys / WASD to walk · Walk into doors · E to talk · Click to inspect
+Arrow keys / WASD to walk · Walk into doors · E to talk · F for fullscreen · Click to inspect
+Toggle settled for haunted cottages. Time → Night shows pale ghosts. Shared worktrees crowd one stoop.