THE PHONE: the game was unplayable on one, and an audit says so

Moving the station card off the ticket last commit only fixed desktop.
An automated occlusion sweep — enter every station, elementFromPoint at
the card's centre, check who is actually painted there — confirmed all
13 cards clear at 1280px, and then found the same collision alive and
well at 375px, where a 240px card and a 272px ticket cannot both sit at
the top of a 375px screen. Three real problems, all of them "nobody has
opened this on a phone since the touch controls shipped":

1. THE PANELS. Both were still desktop width. Narrowed to 50vw/41vw on
   small screens with the type scaled down, and the ticket capped at
   46vh and scrollable so a chatty customer cannot shove the card off
   the bottom of the world.

2. THE CAMERA. Every scene is composed left-to-right — the spice rack,
   the coal bed, the drawer — and a perspective camera holds its
   VERTICAL fov, so a portrait phone silently crops the sides off all of
   it. On a 375x812 screen the outer spice jars were simply not on the
   display, and you cannot drag a jar you cannot see. Narrow viewports
   now widen the fov to hold the horizontal extent instead — capped at
   74 degrees, because the honest 16:9 lock wants 112 and that is not a
   camera, it is a peephole (tried it; the bench went small and distant
   in the middle of an empty floor). Desktop is untouched at 42.

3. THE TICKET COVERED THE BENCH. Even framed correctly, the ask sat on
   top of three of the six spice jars. It now folds on a tap to the day,
   the customer and the demo button, with 'tap to read' underneath, and
   unfolds again on the next order because you have not read that one.

Verified at 375x812: no occlusion at any station, no horizontal
overflow, outer jars at 0.75 NDC and reachable, ticket 376px → 97px
folded. Desktop back to fov 42 and unchanged: grill 9.4, pan 8.0, eggs
10.0, chips 10.0, curry 10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 20:05:01 +10:00
parent 0e092e2551
commit 00e3f1694e
3 changed files with 75 additions and 0 deletions

View File

@ -147,6 +147,25 @@ export class App {
const h = Math.max(1, window.innerHeight); const h = Math.max(1, window.innerHeight);
this.renderer.setSize(w, h, false); this.renderer.setSize(w, h, false);
this.camera.aspect = w / h; this.camera.aspect = w / h;
// Every scene is composed left-to-right — the spice rack, the coal bed, the
// drawer of cutlery. A perspective camera holds its VERTICAL fov, so a
// portrait phone silently crops the sides off all of it: on a 375x812
// screen the outer spice jars were simply not on the display, and you
// cannot drag a jar you cannot see. So below 1:1 we widen the vertical fov
// to hold the HORIZONTAL extent the scenes were framed for instead.
const DESIGN_ASPECT = 16 / 9;
const BASE_FOV = 42;
// Capped, because holding the FULL 16:9 width on a 0.46 phone needs 112
// degrees, and that is not a camera, it is a peephole — the bench went
// small and distant in the middle of an empty floor. 74 gets the outer
// props back on screen while still looking like a lens.
const MAX_FOV = 74;
if (this.camera.aspect < DESIGN_ASPECT) {
const halfW = Math.tan((BASE_FOV * Math.PI) / 360) * DESIGN_ASPECT;
this.camera.fov = Math.min(MAX_FOV, (Math.atan(halfW / this.camera.aspect) * 360) / Math.PI);
} else {
this.camera.fov = BASE_FOV;
}
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();
this.view?.resize?.(w, h); this.view?.resize?.(w, h);
} }

View File

@ -185,6 +185,13 @@ export class Game {
this.ticket = new Panel('ticket'); this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root); this.ticketEl = el('div', 'ticket-body', this.ticket.root);
// On a phone the ticket covers half the bench — and you cannot drag a
// spice jar you cannot reach. Tap it to fold it down to the day and the
// customer; tap again to read the whole ask. Desktop ignores this.
this.ticket.root.addEventListener('click', (e) => {
if ((e.target as HTMLElement).tagName === 'BUTTON') return; // the demo button still works
if (window.matchMedia('(max-width: 600px)').matches) this.ticket.root.classList.toggle('folded');
});
this.tempPanel = new Panel('temp-toggle'); this.tempPanel = new Panel('temp-toggle');
this.tempPanel.hide(); this.tempPanel.hide();
@ -1298,6 +1305,7 @@ export class Game {
} }
private renderTicket(): void { private renderTicket(): void {
this.ticket.root.classList.remove('folded');
const o = this.order; const o = this.order;
this.ticketEl.innerHTML = ''; this.ticketEl.innerHTML = '';
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`); el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);

View File

@ -895,3 +895,51 @@ body:has(#ui > .title) #touchpad { display: none; }
background: #f0e2c0; background: #f0e2c0;
border-radius: 1px; border-radius: 1px;
} }
/* Small screens: the ask and the live words have to SHARE 375px, and both
were still desktop width the card landed straight back under the ticket
on every phone. Narrow both, shrink the type, and cap the ticket's height
so a chatty customer cannot push the card off the bottom of the world. */
@media (max-width: 600px) {
.ticket {
width: 50vw;
padding: 10px 11px 9px;
font-size: 11px;
max-height: 46vh;
overflow-y: auto;
}
.ticket-face {
width: 34px;
height: 34px;
top: 7px;
right: 8px;
}
.slicer-card {
width: 41vw;
top: 12px;
right: 10px;
padding: 10px 11px;
}
.slicer-ask { font-size: 11px; }
.slicer-thick,
.slicer-wobble { font-size: 10px; }
.drawer-hint { font-size: 9px; }
.taste-axis { width: 34px; font-size: 8px; }
}
/* Folded ticket (phones): the day, who is asking, and the demo button. The
rest is one tap away, and the bench underneath is reachable again. */
@media (max-width: 600px) {
.ticket { cursor: pointer; }
.ticket.folded { max-height: none; }
.ticket.folded .ticket-body > *:not(.ticket-day):not(.ticket-who):not(.ticket-face):not(.watch-btn):not(.ticket-critic) {
display: none;
}
.ticket.folded::after {
content: 'tap to read';
display: block;
margin-top: 4px;
font: 9px ui-monospace, monospace;
opacity: 0.5;
}
}