Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,21 @@ Click the map to give it keyboard focus.
| Escape | Leave the cottage or bench while the map has focus |
| F / Fullscreen | Fill the browser window at the current map scale; use Exit fullscreen or Escape to return |
| + / − or zoom buttons | Zoom the Townmap between 50% and 200% |
| 0 / percentage button | Reset to the initial map scale (100%) |
| Town / 0 / percentage button | Compact view at one CSS pixel per artwork pixel (100%) |
| Walk | A closer, crisp 2× view centred on Jack, without moving him |
| V | Switch between Town and Walk while the map has focus |
| Overview | Frame working and blocked cottages, including active roommates and shed residents |
| Evidence / Close | Show or hide the shared inspector in any view |
| Click a cottage or its roster button | Open its inspector without walking there |
| Hover a cottage | Read its name, status, harness and current work without enlarging the map |
| Enter cottage / Leave cottage | Visit or exit using buttons |
| Click a resident / Talk to agent | Open their conversation and recorded activity |
| Click a room object or inspector tab | Read the request, clock, journal, to-do list, PR desk, or shelves |
| Click a parcel | Open its PR when a URL is available |

Town starts at 100% on every screen: a cottage is 54 × 66 CSS pixels. Walk uses
200% for a closer view. These scales stay the same when the window, evidence
drawer, or fleet size changes; they are not a percentage of the initial panel width.
Fullscreen fills the browser window and shows more map without enlarging cottages.
Browser tabs stay visible; it never requests native fullscreen or a macOS space.
Details float on
Expand All @@ -78,7 +84,7 @@ blocked residents, including roommates and children. Existing plots stay fixed
through feed updates, fullscreen and resizing; arrivals fill spare lots or add
an annex. Overview changes the camera, with the Details panel taken into account,
and falls back to all visible cottages when none are active. It respects the
current filters and the 50% zoom floor, so very large fleets still need scrolling.
current filters and can fit below the ordinary 50% manual zoom floor.
Branch labels express grouping, not inferred Git ancestry.

Rooms and residents are generated from cottage and task identity. Return visits keep the furniture in place. A new explicit task gets a new home. HubTown sets the cozy base with switchboards and pigeonholes. AppTown fills its desks with computers, phones and charging leads. MemTown lines its walls with bookshelves. VaultTown is a clock-and-lock repair shop, with pendulums, key racks and scattered repair tools. Unknown projects get neutral homes.
Expand Down
12 changes: 12 additions & 0 deletions scripts/browser-viewport-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ try {
// where a former fixed scroll offset would legitimately clamp at the edge.
await evaluate('window.originalPanel=document.getElementById("panel"); window.nativeCalls=[]; document.getElementById("map-shell").requestFullscreen=()=>{nativeCalls.push("request");return Promise.resolve();}; document.exitFullscreen=()=>{nativeCalls.push("exit");return Promise.resolve();}; window.originalWindow=[innerWidth,innerHeight]; const v=document.getElementById("map-viewport"); v.scrollTop=Math.max(0,(window.cottageState().viewport.geometry.mapHeight-v.clientHeight)/2);');
const normal = await state();
near(normal.viewport.scale,1,'Native Town scale',1e-8);
const beforeWalk=await evaluate('window.cottageState().scene.player');
await click('#camera-walk');
near((await state()).viewport.scale,2,'Walk scale',1e-8);
assert.deepEqual(await evaluate('window.cottageState().scene.player'),beforeWalk,'Walk frames Jack without teleporting him');
assert.equal(await evaluate('document.getElementById("camera-walk").getAttribute("aria-pressed")'),'true');
await key('v');
near((await state()).viewport.scale,1,'V returns to Town scale',1e-8);
// Restore the original center before exercising fullscreen's preservation.
await evaluate(`document.getElementById('map-viewport').scrollTop=${normal.viewport.center.y}-document.getElementById('map-viewport').clientHeight/2`);
pass('Town and Walk use native and double pixels without moving Jack');
await click('#fullscreen-toggle');
await until('window.cottageState().display.fullscreen','In-window fullscreen did not open');
const full = await state();
Expand Down Expand Up @@ -164,6 +175,7 @@ try {
frame.remove();return result;
})()`);
near(result.scale,result.initial,'Responsive fullscreen scale',1e-8);
near(result.initial,1,'Responsive startup uses native pixels',1e-8);
assert.equal(result.overflow,false); assert.equal(result.closeVisible,true);
assert.ok(result.panel.left>=0 && result.panel.right<=result.width);
assert.ok(result.panel.top>=0 && result.panel.bottom<=result.height);
Expand Down
16 changes: 14 additions & 2 deletions src/map-display.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {fullscreenButtonCopy} from './fullscreen.mjs';
import {nextZoom, zoomKey, ZOOM_LEVELS} from './viewport.mjs';

/** Fullscreen and panel chrome live outside the inspector's streaming content. */
export function createMapDisplay({camera, canvas, getMode, onOverview, getWorkspaceView=()=> 'town', focusWorkspace}) {
export function createMapDisplay({camera, canvas, getMode, getWalkPoint, onOverview, getWorkspaceView=()=> 'town', focusWorkspace}) {
const $ = id => document.getElementById(id);
const root = $('map-shell'), viewport = $('map-viewport'), details = $('details-shell');
const panel = $('panel');
const outside = [...root.parentElement.children].filter(element => element !== root);
const fullscreen = $('fullscreen-toggle'), toggle = $('details-toggle'), close = $('details-close');
const zoomControls = $('map-zoom'), minus = $('zoom-out'), plus = $('zoom-in'), reset = $('zoom-reset');
const townView = $('camera-town'), walkView = $('camera-walk');
let active = false, detailsOpen = true, pageScroll = null;
let frame = 0;
let fullscreenReading = null;
Expand Down Expand Up @@ -70,12 +71,23 @@ export function createMapDisplay({camera, canvas, getMode, onOverview, getWorksp
const zoom = camera.state.zoom;
zoomControls.hidden = getMode() === 'room';
minus.disabled = zoom <= ZOOM_LEVELS[0]; plus.disabled = zoom === ZOOM_LEVELS.at(-1);
reset.textContent = zoom + '%'; reset.setAttribute('aria-label', 'Map zoom ' + zoom + '%. Reset to 100%');
reset.textContent = zoom + '%'; reset.setAttribute('aria-label', 'Map zoom ' + zoom + '%. Reset to Town view');
townView.setAttribute('aria-pressed', String(camera.state.view === 'town'));
walkView.setAttribute('aria-pressed', String(camera.state.view === 'walk'));
}
function changeView(view) {
camera.setView(view, view === 'walk' ? getWalkPoint?.() : undefined);
syncZoom();
$('overview-status').textContent = view === 'walk' ? 'Walk view. Camera follows Jack at 200%.' : 'Town view at 100%. Hover cottages for details.';
}
function changeZoom(action) {
if (action === 'view') { changeView(camera.state.view === 'walk' ? 'town' : 'walk'); return; }
camera.setZoom(action === 'reset' ? 100 : nextZoom(camera.state.zoom, action === 'in' ? 1 : -1));
syncZoom();
}
for (const [button, view] of [[townView, 'town'], [walkView, 'walk']]) {
button.addEventListener('click', () => { changeView(view); focusMap(); });
}
for (const [button, action] of [[minus, 'out'], [plus, 'in'], [reset, 'reset']]) {
button.addEventListener('click', () => { changeZoom(action); focusMap(); });
}
Expand Down
2 changes: 1 addition & 1 deletion src/observatory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ export function createObservatory(api){
}
if(followId)hint('On the bench with '+(byId(followId)?.name||'your agent')+' · Move or press Escape to leave');
else if(mode==='town'&&gramophone&&distance(player,gramophone)<24)hint('E · '+(extras.state.music.enabled?'Stop':'Play')+' the village gramophone');
else if(mode==='town')hint('Arrow keys / WASD to walk · E to talk · + / − zoom · 0 reset · F fullscreen · Click to inspect');
else if(mode==='town')hint('WASD / arrows to walk · E to talk · V Town / Walk · + / − zoom · 0 Town · F fullscreen · Hover or click cottages');
}
function draw(time){
const dt=Math.min(.05,lastFrame?time-lastFrame:1/60);lastFrame=time;
Expand Down
7 changes: 3 additions & 4 deletions src/town-layout.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/** The old two-district width remains the reference for 100% cottage scale. */
/** Minimum packing width; camera scale is independent of district layout. */
export const MAP_REFERENCE_WIDTH = 768;

/** Choose a packing budget once. Floating Details must not permanently narrow the world. */
export function townWidthBudget({windowWidth, standardWidth}) {
const scale = Math.max(1, standardWidth) / MAP_REFERENCE_WIDTH;
export function townWidthBudget({windowWidth}) {
const clearWidth = windowWidth - 24;
return Math.round(Math.max(MAP_REFERENCE_WIDTH, Math.min(1920, clearWidth / scale)));
return Math.round(Math.max(MAP_REFERENCE_WIDTH, Math.min(1920, clearWidth)));
}

const townOf = agent => agent.town || agent.role || 'WildTown';
Expand Down
15 changes: 12 additions & 3 deletions src/town.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,19 @@
#room-canvas{position:absolute;top:0;left:0;width:100%;height:100%;background:#171e22}
.map-viewport.is-indoors{overflow:hidden}
.map-viewport.is-indoors .map-surface{display:none}
.map-zoom{display:inline-flex;gap:2px;align-items:center}
.map-zoom{display:inline-flex;gap:2px;align-items:center;flex-wrap:wrap}
.map-zoom button{min-width:34px;padding:8px}
#camera-walk{margin-right:6px}
#zoom-reset{min-width:62px;font-variant-numeric:tabular-nums}
.details-shell{min-width:0;min-height:0}
.details-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:1px solid #44524f;border-bottom:0;background:#273238}
.split.details-closed{grid-template-columns:minmax(0,1fr)}
#perspective-stage{position:relative;min-width:0;min-height:0}
.cottage-peek{position:absolute;z-index:5;width:270px;max-width:calc(100% - 16px);padding:9px 11px;border:1px solid #9eaa83;border-radius:4px;background:#202e2bf5;box-shadow:0 4px 16px #101b2255;pointer-events:none;font:12px/1.4 ui-sans-serif,system-ui;color:#e4ebe2;overflow-wrap:anywhere}
.cottage-peek strong,.cottage-peek span{display:block}
.cottage-peek strong{color:#e8d39b;font-size:13px}
.cottage-peek span{color:#b5c6b8;margin-top:2px}
.cottage-peek p{margin:5px 0 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
#command-view{position:absolute;inset:0;overflow:auto;outline:none;border-radius:3px}
#command-view[hidden],.details-shell[hidden]{display:none!important}
.split.is-fullscreen{position:fixed;inset:0;width:100%;height:100dvh;max-width:none;display:block;overflow:hidden;z-index:100;background:#171d23}
Expand Down Expand Up @@ -262,9 +268,11 @@ <h1><a class="brand-link" href="https://github.com/verygoodplugins/cottagecode"
<button id="sound-toggle" type="button" aria-pressed="false">Sound off</button>
<button id="music-toggle" type="button" aria-pressed="false" aria-label="Play village music">♫ Music off</button>
<div class="map-zoom" id="map-zoom" role="group" aria-label="Townmap zoom">
<button id="camera-town" type="button" aria-pressed="true" title="Compact town · original pixel size (0)">Town</button>
<button id="camera-walk" type="button" aria-pressed="false" aria-keyshortcuts="V" title="Closer view around Jack · double pixel size (V)">Walk</button>
<button id="map-overview" type="button" aria-label="Overview: frame working and blocked cottages" title="Frame working and blocked cottages">Overview</button>
<button id="zoom-out" type="button" aria-label="Zoom out" aria-keyshortcuts="-" title="Zoom out (−)">−</button>
<button id="zoom-reset" type="button" aria-label="Map zoom 100%. Reset to 100%" aria-keyshortcuts="0" title="Reset zoom (0)">100%</button>
<button id="zoom-reset" type="button" aria-label="Map zoom 100%. Reset to Town view" aria-keyshortcuts="0" title="Town view · original pixel size (0)">100%</button>
<button id="zoom-in" type="button" aria-label="Zoom in" aria-keyshortcuts="+ =" title="Zoom in (+)">+</button>
</div>
<span id="overview-status" class="sr-only" role="status"></span>
Expand All @@ -291,9 +299,10 @@ <h1><a class="brand-link" href="https://github.com/verygoodplugins/cottagecode"
<div id="command-attention"></div>
<div id="perspective-stage">
<div id="map-viewport" class="map-viewport">
<div class="map-surface" id="map-surface"><canvas id="town" tabindex="0" role="img" aria-label="Townmap: arrow keys or WASD to walk, walk into doors to enter, E near an agent to talk, click cottages to inspect, plus and minus to zoom, 0 to reset zoom, F for fullscreen"></canvas></div><canvas id="room-canvas" tabindex="0" role="img" aria-label="Cottage interior. Arrow keys to walk, walk through the doorway to leave, E near your host to talk or inspect objects, Escape to leave." hidden></canvas>
<div class="map-surface" id="map-surface"><canvas id="town" tabindex="0" role="img" aria-label="Townmap: arrow keys or WASD to walk, walk into doors to enter, E near an agent to talk, click cottages to inspect, V to switch Town and Walk views, plus and minus to zoom, 0 for Town view, F for fullscreen"></canvas></div><canvas id="room-canvas" tabindex="0" role="img" aria-label="Cottage interior. Arrow keys to walk, walk through the doorway to leave, E near your host to talk or inspect objects, Escape to leave." hidden></canvas>
</div>
<section id="command-view" tabindex="-1" aria-label="Command workspace" hidden></section>
<div id="cottage-peek" class="cottage-peek" role="tooltip" hidden><strong></strong><span></span><p></p></div>
</div>
<p class="scene-status" id="scene-status">Arrow keys / WASD to walk · Walk into doors · E to talk · F for fullscreen · Click to inspect</p>
<div class="stage-foot">
Expand Down
38 changes: 33 additions & 5 deletions src/town.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -580,10 +580,11 @@ cv.width = W; cv.height = 0;
ctx.imageSmoothingEnabled = false;
let mapDisplay;
const mapViewport = createMapViewport({canvas:cv,viewport:document.getElementById('map-viewport'),
surface:document.getElementById('map-surface'),referenceWidth:MAP_REFERENCE_WIDTH,getObscured:()=>mapDisplay?.obscured()||{}});
const packingWidth=townWidthBudget({windowWidth:window.innerWidth,standardWidth:document.getElementById('map-viewport').clientWidth});
surface:document.getElementById('map-surface'),getObscured:()=>mapDisplay?.obscured()||{}});
const packingWidth=townWidthBudget({windowWidth:window.innerWidth});
stableLayout=createTownLayout({maxWidth:packingWidth});
mapDisplay = createMapDisplay({camera:mapViewport,canvas:cv,getMode:()=>observatory?.mode||'town',
getWalkPoint:()=>observatory?.player,
getWorkspaceView:()=>commandCenter?.state.view||'town',
focusWorkspace:()=>document.getElementById('command-view').focus({preventScroll:true}),onOverview:()=>{
observatory?.unfollow();
Expand Down Expand Up @@ -1750,7 +1751,7 @@ function draw(){
t = performance.now()/1000;
frameDt=Math.min(.05,lastDrawAt?t-lastDrawAt:1/60);lastDrawAt=t;
// Clock labels age independently from feed polling, including a paused feed.
if(t-lastCommandClock>=1){lastCommandClock=t;updateCommandCenter();}
if(t-lastCommandClock>=1){lastCommandClock=t;updateCommandCenter();if(!cottagePeek.hidden)renderCottagePeek();}
const villageLight=observatory?.lightAt(t);
bedtimeFrames=bedtime.update(plots,villageLight,{time:t,reduce,present:agents,source:layoutSource||feedNamespace(ENDPOINT,builtInDemo)});
const bedtimeLabel=villageLifeLabel(villageLight,bedtimeFrames);
Expand Down Expand Up @@ -1975,8 +1976,35 @@ function openBest(ag){
a.click();
}
}
cv.addEventListener("mousemove", e=>{ hoverId = hit(e); cv.style.cursor = hoverId||hitTrade(e) ? "pointer":"default"; });
cv.addEventListener("mouseleave", ()=>{ hoverId = null; });
// Readable details belong to the interface, not to enlarged artwork pixels.
const cottagePeek=document.getElementById('cottage-peek');
const perspectiveStage=document.getElementById('perspective-stage');
function hideCottagePeek(){cottagePeek.hidden=true;cv.removeAttribute('aria-describedby');}
function renderCottagePeek(){
const agent=agents.find(agent=>agent.id===hoverId);
if(!agent||observatory?.mode==='room'){hideCottagePeek();return;}
cottagePeek.querySelector('strong').textContent=agent.name||agent.id;
cottagePeek.querySelector('span').textContent=[townKey(agent),STATUS_TEXT[agent.status]||'unknown',
agent.harness||agent.model,feedStale?'feed stale':null].filter(Boolean).join(' · ');
cottagePeek.querySelector('p').textContent=agent.activity||agent.task||agent.branch||'Click to inspect this cottage';
cottagePeek.hidden=false;cv.setAttribute('aria-describedby','cottage-peek');
}
cv.addEventListener("mousemove", e=>{
hoverId=hit(e);cv.style.cursor=hoverId||hitTrade(e)?'pointer':'default';
renderCottagePeek();
if(cottagePeek.hidden)return;
const stage=perspectiveStage.getBoundingClientRect(),view=document.getElementById('map-viewport').getBoundingClientRect();
const w=cottagePeek.offsetWidth,h=cottagePeek.offsetHeight;
const left=Math.max(8,Math.min(e.clientX-stage.left+14,stage.width-w-8));
const below=e.clientY-stage.top+18;
const top=below+h<=view.bottom-stage.top-8?below:e.clientY-stage.top-h-12;
cottagePeek.style.left=left+'px';cottagePeek.style.top=Math.max(view.top-stage.top+8,top)+'px';
});
cv.addEventListener("mouseleave", ()=>{hoverId=null;hideCottagePeek();});
document.getElementById('map-shell').addEventListener('click',hideCottagePeek,true);
document.getElementById('map-shell').addEventListener('keydown',hideCottagePeek,true);
document.getElementById('map-shell').addEventListener('scroll',hideCottagePeek,true);
window.addEventListener('resize',hideCottagePeek);
cv.addEventListener("click", e=>{
const site=hitTrade(e);if(site){commandCenter?.inspectTrade(site);return;}
if(observatory?.handleClick(e))return;
Expand Down
Loading
Loading