clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

feat(stage): expose Workshop from Title and INDEX

Dami committed be7f4a6
commit
be7f4a60205ed7bc801386307ba97a4547a13784
parent
60444516c7994283d1c6df82d431f87f46655f3c
author
Dami <dami.etoile@gmail.com>
4 files changed, +127 -24
Msrc/main.js11++-
Msrc/render/index.js21+++++-
Msrc/stage.js96+++++++++++++++++++++++++-----
Mtests/index-render.test.js23+++++++
M src/main.js +7 -4
@@ -51,11 +51,11 @@ const packService = createPackService({ catalog, library });
5151 const audio = createAudioSystem(); audio.installGestureListeners(document);5252 const juice = createJuice({ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches });5353 const progress = createProgressStore();54-const titleController = createTitleScene({ onSolved: () => stage.show('index'), onSkip: () => stage.show('index') });54+const titleController = createTitleScene({ onSolved: () => stage.show('index'), onSkip: () => stage.show('index'), onWorkshop: () => stage.show('workshop', { origin: 'title' }) });5555 const stage = createStage({ levels: () => currentLevels(), title: titleController, onSelectLevel: id => { const ok = loadLevel(currentLevels().findIndex(level => level.id === id)); if (ok) scheduleInitialWin(); return ok; }, onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });5656 let giftOrigin = 'url';5757 const giftController = createGiftScene({ onAction: result => { if (result.action === 'keep' || result.action === 'replace' || result.action === 'copy') { const installed=packService.installPack(result.pack,{origin:giftOrigin,...(result.install||{})}); if(installed.ok){ showModStatus(`Pack installed / ${installed.pack.name}`); const first = currentLevels().findIndex(level => level.packId === installed.pack.id); if (first >= 0) loadLevel(first); refreshIndex(currentLevels()[first]?.id); workshopController.refresh(); stage.show('index'); } else {workshopController.showErrors(installed.errors);stage.show('workshop');} } } });58-const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.show('pause'), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index'); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => showModStatus('LINK IS COPIED')).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });58+const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.closeWorkshop(), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index'); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => showModStatus('LINK IS COPIED')).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });5959 stage.setControllers({ title: titleController, gift: giftController, workshop: workshopController });6060 let soundOn = !audio.isMuted();6161 soundButton.textContent = soundOn ? '◖' : '○';
@@ -117,7 +117,10 @@ function navigateWithinPack(delta) {
117117 const indexes = packLevelIndexes();118118 const position = indexes.indexOf(levelNo);119119 if (position < 0 || !indexes.length) return;120- loadLevel(indexes[(position + delta + indexes.length) % indexes.length]);120+ if (loadLevel(indexes[(position + delta + indexes.length) % indexes.length])) {121+ stage.show('proof');122+ scheduleInitialWin();123+ }121124 }122125 function nextLevelInPack() {123126 const indexes = packLevelIndexes();
@@ -234,4 +237,4 @@ input.attach({ stage: stage.canvas });
234237 // self-hosted face instead of relying on CSS font-display to repaint it.235238 await document.fonts.load('800 20px Recursive');236239 await document.fonts.load('600 20px Recursive');237-refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict}); stage.show('gift'); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');240+refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict}); stage.show('gift',{origin:'title'}); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');
M src/render/index.js +17 -4
@@ -99,7 +99,17 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
9999 ctx.fillStyle = colors.board; ctx.fill();100100 ctx.strokeStyle = signal; ctx.lineWidth = Math.max(2, geometry.cell * 0.045); ctx.stroke();101101 ctx.font = `${Math.max(18, geometry.cell * 0.42)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;102- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(options.cat || '🐈', center.x, center.y);102+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;103+ if (options.catFacing === 'right') {104+ ctx.save();105+ ctx.translate(center.x, 0);106+ ctx.scale(-1, 1);107+ ctx.translate(-center.x, 0);108+ ctx.fillText(options.cat || '🐈', center.x, center.y);109+ ctx.restore();110+ } else {111+ ctx.fillText(options.cat || '🐈', center.x, center.y);112+ }103113 } else {104114 roundRect(ctx, left + geometry.cell * 0.04, top + geometry.cell * 0.04, tileWidth, tileHeight, geometry.cell * 0.025);105115 ctx.fillStyle = colors.lip; ctx.fill();
@@ -154,7 +164,7 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
154164 const height = cell * usedRows;155165 const x = options.x ?? (canvasWidth - width) / 2;156166 const y = options.y ?? hudHeight + (availableHeight - height) / 2 + margin;157- const geometry = { x, y, cell, width, height, columns: 5, rows: usedRows, sheetId: sheet.id, packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [] };167+ const geometry = { x, y, cell, width, height, columns: 5, rows: usedRows, sheetId: sheet.id, packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [], footerActions: {} };158168 const current = currentNode(navigator);159169 160170 ctx.save();
@@ -193,11 +203,12 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
193203 const groups = [194204 { key: 'ENTER', label: 'play', width: Math.max(56, cell * 0.9) },195205 ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', width: Math.max(42, cell * 0.65) }] : []),206+ { key: 'SPACE', label: 'workshop', width: Math.max(58, cell * 0.95) },196207 ];197208 const groupGap = Math.max(8, cell * 0.12);198209 const labelGap = Math.max(5, cell * 0.08);199- const labelWidth = Math.max(26, cell * 0.42);200- const keyRowWidth = groups.reduce((total, group) => total + group.width + labelGap + labelWidth, 0) + groupGap * Math.max(0, groups.length - 1);210+ const labelWidthFor = group => group.label === 'workshop' ? Math.max(62, cell * 0.72) : Math.max(26, cell * 0.42);211+ const keyRowWidth = groups.reduce((total, group) => total + group.width + labelGap + labelWidthFor(group), 0) + groupGap * Math.max(0, groups.length - 1);201212 const keyStart = canvasWidth - margin - keyRowWidth;202213 const bannerWidth = Math.max(84, keyStart - margin * 1.2);203214 ctx.font = fitFont(ctx, banner, bannerWidth, Math.max(12, cell * 0.15), 800);
@@ -206,6 +217,8 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
206217 let keyX = keyStart;207218 for (const group of groups) {208219 const keycapY = footerY - keyHeight / 2;220+ const labelWidth = labelWidthFor(group);221+ if (group.label === 'workshop') geometry.footerActions.workshop = { x: keyX, y: Math.max(0, keycapY), width: group.width + labelGap + labelWidth, height: Math.max(0, Math.floor(Math.min(keyHeight, canvasHeight - keycapY) * 100) / 100) };209222 roundRect(ctx, keyX, keycapY, group.width, keyHeight, keyHeight * 0.18);210223 ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();211224 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;
M src/stage.js +80 -16
@@ -47,6 +47,7 @@ export function createStage(options = {}) {
4747 let indexNavigator = null;4848 let indexGeometry = null;4949 let indexLockedNode = null;50+ let indexFacing = "left";5051 let title = options.title || null;5152 let gift = options.gift || null;5253 let workshop = options.workshop || null;
@@ -86,6 +87,8 @@ export function createStage(options = {}) {
8687 document.body.append(root);8788 let scene = "proof";8889 let pausedFrom = "proof";90+ let workshopOrigin = "pause";91+ let giftOrigin = "title";8992 let selected = 0;9093 const pauseItems = [9194 "RESUME",
@@ -375,6 +378,13 @@ export function createStage(options = {}) {
375378 if (line && lineY <= maxBottom) ctx.fillText(line, x, lineY);376379 ctx.restore();377380 }381+ function moveIndex(direction) {382+ const result = indexNavigator?.move(direction);383+ indexLockedNode = result?.locked ? result.node : null;384+ if (result?.moved && (direction === "left" || direction === "right"))385+ indexFacing = direction;386+ return result;387+ }378388 function animateVisuals(now) {379389 render();380390 const winActive = scene === "win" && winStart && now - winStart < 620;
@@ -523,7 +533,8 @@ export function createStage(options = {}) {
523533 indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {524534 availableWidth: w,525535 availableHeight: h,526- cat: boardGlyphs.MIAU || "🐱",536+ cat: boardGlyphs.MIAU || "🐈",537+ catFacing: indexFacing,527538 popNodeId: indexPopNodeId,528539 popProgress: indexPopStart529540 ? Math.min(1, (performance.now() - indexPopStart) / 520)
@@ -538,7 +549,8 @@ export function createStage(options = {}) {
538549 indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {539550 availableWidth: w,540551 availableHeight: h,541- cat: boardGlyphs.MIAU || "🐱",552+ cat: boardGlyphs.MIAU || "🐈",553+ catFacing: indexFacing,542554 popNodeId: indexPopNodeId,543555 popProgress: indexPopStart544556 ? Math.min(1, (performance.now() - indexPopStart) / 520)
@@ -775,6 +787,8 @@ export function createStage(options = {}) {
775787 }776788 function show(id, data = {}) {777789 irisCenter = catCenter;790+ if (id === "workshop") workshopOrigin = data.origin || scene;791+ if (id === "gift") giftOrigin = data.origin || scene;778792 if (id !== "index") indexLockedNode = null;779793 if (id === "win") {780794 winStart = performance.now();
@@ -851,6 +865,16 @@ export function createStage(options = {}) {
851865 root.dataset.scene = scene;852866 render();853867 }868+ function closeWorkshop() {869+ const origin = workshopOrigin;870+ show(origin === "workshop" ? "pause" : origin);871+ return api;872+ }873+ function closeGift() {874+ const origin = giftOrigin;875+ show(origin === "gift" ? "title" : origin);876+ return api;877+ }854878 function setBoard(state, game, palette = {}, glyphs = {}, frame = null) {855879 if (previousMoves !== null && state?.moves !== previousMoves)856880 stepPopStart = performance.now();
@@ -917,7 +941,7 @@ export function createStage(options = {}) {
917941 if (scene === "workshop") workshop?.handleInput?.("escape");918942 else if (scene === "gift") {919943 gift?.input("escape");920- show("title");944+ closeGift();921945 } else if (scene === "index") show("pause");922946 else hide();923947 render();
@@ -953,7 +977,7 @@ export function createStage(options = {}) {
953977 ? "right"954978 : "noop",955979 );956- if (result.action === "later") show("title");980+ if (result.action === "later") closeGift();957981 render();958982 event.preventDefault();959983 return;
@@ -1013,20 +1037,14 @@ export function createStage(options = {}) {
10131037 return;10141038 }10151039 if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {1016- const result = indexNavigator?.move(1017- event.key === "ArrowRight" ? "right" : "down",1018- );1019- indexLockedNode = result?.locked ? result.node : null;1040+ const result = moveIndex(event.key === "ArrowRight" ? "right" : "down");10201041 selected++;10211042 render();10221043 event.preventDefault();10231044 return;10241045 }10251046 if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {1026- const result = indexNavigator?.move(1027- event.key === "ArrowLeft" ? "left" : "up",1028- );1029- indexLockedNode = result?.locked ? result.node : null;1047+ const result = moveIndex(event.key === "ArrowLeft" ? "left" : "up");10301048 selected = Math.max(0, selected - 1);10311049 render();10321050 event.preventDefault();
@@ -1037,14 +1055,59 @@ export function createStage(options = {}) {
10371055 event.preventDefault();10381056 }10391057 }1058+ function handleIndexArrowCapture(event) {1059+ if (event.target?.matches?.("input,textarea,select,[contenteditable]"))1060+ return;1061+ if (event.key === " " && (scene === "index" || scene === "title")) {1062+ event.preventDefault();1063+ event.stopImmediatePropagation();1064+ show("workshop", { origin: scene });1065+ return;1066+ }1067+ if (scene !== "index") return;1068+ const directions = {1069+ ArrowRight: "right",1070+ ArrowDown: "down",1071+ ArrowLeft: "left",1072+ ArrowUp: "up",1073+ };1074+ const direction = directions[event.key];1075+ if (!direction) return;1076+ event.preventDefault();1077+ event.stopImmediatePropagation();1078+ moveIndex(direction);1079+ selected =1080+ direction === "left" || direction === "up"1081+ ? Math.max(0, selected - 1)1082+ : selected + 1;1083+ render();1084+ }10401085 addEventListener("resize", resize);10411086 resize();1087+ addEventListener("keydown", handleIndexArrowCapture, true);10421088 document.addEventListener("keydown", handleKey);10431089 canvas.addEventListener("pointerup", (event) => {10441090 const rect = canvas.getBoundingClientRect();10451091 const x = event.clientX - rect.left,10461092 y = event.clientY - rect.top;1093+ if (scene === "title") {1094+ const titleHit = title?.hit?.(x, y);1095+ if (titleHit?.kind === "workshop" || titleHit?.kind === "skip") return;1096+ if (titleHit?.kind === "menu") { show("pause"); return; }1097+ }10471098 if (scene === "index" && indexGeometry?.nodes?.length) {1099+ const workshopHit = indexGeometry.footerActions?.workshop;1100+ if (1101+ workshopHit &&1102+ x >= workshopHit.x &&1103+ x <= workshopHit.x + workshopHit.width &&1104+ y >= workshopHit.y &&1105+ y <= workshopHit.y + workshopHit.height1106+ ) {1107+ show("workshop", { origin: "index" });1108+ render();1109+ return;1110+ }10481111 const hit = indexGeometry.nodes.find(10491112 (node) =>10501113 x >= node.x &&
@@ -1078,7 +1141,7 @@ export function createStage(options = {}) {
10781141 }10791142 if (scene === "gift") {10801143 const result = gift?.hit(x, y);1081- if (result?.action === "later") show("title");1144+ if (result?.action === "later") closeGift();10821145 }10831146 if (scene === "workshop") workshop?.hit?.(x, y);10841147 if (
@@ -1127,8 +1190,7 @@ export function createStage(options = {}) {
11271190 }11281191 if (scene === "index") {11291192 if (type === "move" && indexNavigator) {1130- const result = indexNavigator.move(intent.dir);1131- indexLockedNode = result?.locked ? result.node : null;1193+ const result = moveIndex(intent.dir);11321194 render();11331195 return true;11341196 }
@@ -1167,7 +1229,7 @@ export function createStage(options = {}) {
11671229 ? "escape"11681230 : intent.dir || type,11691231 );1170- if (result.action === "later") show("title");1232+ if (result.action === "later") closeGift();11711233 render();11721234 return true;11731235 }
@@ -1182,6 +1244,8 @@ export function createStage(options = {}) {
11821244 setControllers,11831245 setActions,11841246 setPauseStatus,1247+ closeWorkshop,1248+ closeGift,11851249 announce,11861250 handleIntent,11871251 render,
M tests/index-render.test.js +23 -0
@@ -128,3 +128,26 @@ test('drawIndex pops a solved node and emits stars, with a reduced motion cut',
128128 assert.equal(reduced.nodes[0].sparks, false);129129 assert.equal(reducedContext.calls.some(call => call[0] === 'fillText' && call[1] === '★'), false);130130 });131+132+test('drawIndex mirrors the native-left cat only for right-facing cursor travel', () => {133+ const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);134+ const navigator = createIndexNavigator(layout);135+ const leftContext = context();136+ drawIndex(leftContext, layout, navigator, { catFacing: 'left' });137+ assert.equal(leftContext.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);138+ const rightContext = context();139+ drawIndex(rightContext, layout, navigator, { catFacing: 'right' });140+ assert.ok(rightContext.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));141+});142+143+test('drawIndex returns an exact Workshop footer hit rectangle at desktop and mobile widths', () => {144+ const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);145+ const navigator = createIndexNavigator(layout);146+ for (const width of [390, 1280]) {147+ const geometry = drawIndex(context(width, 420), layout, navigator, { availableWidth: width, availableHeight: 420 });148+ const hit = geometry.footerActions.workshop;149+ assert.ok(hit, `workshop hit at ${width}px`);150+ assert.ok(hit.x >= 0 && hit.x + hit.width <= width, `workshop footer stays within ${width}px`);151+ assert.ok(hit.y >= 0 && hit.y + hit.height <= 420, `workshop footer y stays within ${width}px`);152+ }153+});