clone

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

Present first-entry world cards across INDEX and Workshop

Dami committed 115c953
commit
115c9537249b244f1c3d021e21d213b5113029ab
parent
7740bcbb1cf861bfe4aa87d6b76c5f6fed3eba75
author
Dami <dami.etoile@gmail.com>
5 files changed, +126 -16
Msrc/main.js11+++-
Msrc/render/world-card.js30++++++++++--
Msrc/stage.js78++++++++++++++++++++++++++----
Msrc/stage/events.js21++++++++
Mtests/world-card.test.js2+
M src/main.js +9 -2
@@ -68,7 +68,7 @@ const giftController = createGiftScene({
6868 },6969 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');} } }7070 });71-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(() => { stage.announce('LINK IS COPIED'); stage.showCopyConfirmation?.(); }).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) });71+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', { origin: 'workshop', worldId: packId }); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => { stage.announce('LINK IS COPIED'); stage.showCopyConfirmation?.(); }).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) });7272 stage.setControllers({ title: titleController, gift: giftController, workshop: workshopController });7373 let soundOn = !audio.isMuted();7474 soundButton.textContent = soundOn ? '◖' : '○';
@@ -96,8 +96,15 @@ let indexLayout;
9696 let indexNavigator;9797 function refreshIndex(focusId) {9898 const previous = focusId || indexNavigator?.current?.node?.id;99+ const cursorByWorld = indexNavigator?.cursorSnapshot?.() || {};100+ const currentCursor = indexNavigator?.current;101+ if (currentCursor?.worldId && currentCursor.nodeId) cursorByWorld[currentCursor.worldId] = currentCursor.nodeId;99102 indexLayout = decorateIndex(createIndexLayout(catalog), progress);100- indexNavigator = createIndexNavigator(indexLayout, { startNodeId: previous, locked: 'indicate' });103+ indexNavigator = createIndexNavigator(indexLayout, {104+ startNodeId: previous,105+ cursorByWorld,106+ locked: 'indicate',107+ });101108 stage.setIndex(indexLayout, indexNavigator);102109 }103110 function activePalette() { return currentLevels()[levelNo]?.palette || {}; }
M src/render/world-card.js +26 -4
@@ -59,6 +59,18 @@ function fitFont(ctx, text, maxWidth, size, minSize, weight = 800) {
5959 return `${weight} ${Math.max(minSize, current)}px ${FONT}`;6060 }6161 62+function fitWrappedText(ctx, text, maxWidth, size, minSize, maxLines = 2) {63+ let current = size;64+ let lines;65+ do {66+ ctx.font = `500 ${current}px ${FONT}`;67+ lines = wrapText(ctx, text, maxWidth);68+ if (lines.length <= maxLines || current <= minSize) return lines;69+ current -= 1;70+ } while (current >= minSize);71+ return lines;72+}73+6274 function worldValue(world, key, fallback = '') {6375 return String(world?.[key] ?? fallback).trim();6476 }
@@ -79,6 +91,7 @@ function drawKeycap(ctx, x, y, options = {}) {
7991 const totalWidth = keyWidth + 10 + labelWidth;8092 const left = x - totalWidth / 2;8193 const pressOffset = options.press ? 3 : 0;94+ const visual = { x: left, y: y - 10, width: totalWidth, height: 20 };8295 ctx.fillStyle = colorsFor(options).lip;8396 if (!options.press) ctx.fillRect(left + 4, y - 10, keyWidth, 20);8497 ctx.fillStyle = colorsFor(options).fg;
@@ -92,7 +105,13 @@ function drawKeycap(ctx, x, y, options = {}) {
92105 ctx.font = `600 10px ${FONT}`;93106 ctx.fillText(label, left + keyWidth + 10, y + 1 + pressOffset);94107 ctx.restore();95- return { x: left, y: y - 10, width: totalWidth, height: 20 };108+ return {109+ x: left,110+ y: y - (options.coarsePointer ? 22 : 10),111+ width: totalWidth,112+ height: options.coarsePointer ? 44 : 20,113+ visual,114+ };96115 }97116 98117 function colorsFor(options) {
@@ -165,15 +184,18 @@ export function drawWorldCard(ctx, world, options = {}) {
165184 ctx.font = titleFont;166185 ctx.fillText(name, width / 2, titleY);167186 168- const taglineLines = wrapText(ctx, tagline, innerWidth).slice(0, 2);187+ const taglineLines = fitWrappedText(ctx, tagline, innerWidth, mobile ? 12 : 13, 9);169188 ctx.fillStyle = colors.muted;170- ctx.font = `500 ${mobile ? 12 : 13}px ${FONT}`;171189 taglineLines.forEach((line, index) => ctx.fillText(line, width / 2, titleY + 35 + index * 16));172190 const bylineY = titleY + 35 + taglineLines.length * 16 + 19;173191 ctx.font = `600 11px ${FONT}`;174192 ctx.fillText(`by ${author}`, width / 2, bylineY);175193 const enterY = card.y + card.height - (mobile ? 48 : 52);176- const enter = drawKeycap(ctx, width / 2, enterY, { colors, press: options.press === true });194+ const enter = drawKeycap(ctx, width / 2, enterY, {195+ colors,196+ press: options.press === true,197+ coarsePointer: mobile,198+ });177199 ctx.restore();178200 179201 return {
M src/stage.js +68 -10
@@ -13,6 +13,7 @@ import { pauseMenuItems } from "./stage/dispatch.js";
1313 import { createWinFlow, WIN_ENTRANCE_END } from './stage/win-flow.js';1414 import { SCENE_MOTION_TIMING, sceneMotionPhase } from './stage/scene-motion.js';1515 import { installStageEvents } from './stage/events.js';16+import { drawWorldCard } from './render/world-card.js';1617 1718 const SCENES = {1819 title: titleScene,
@@ -134,6 +135,8 @@ export function createStage(options = {}) {
134135 let indexPopStart = 0;135136 let indexPopNodeId = null;136137 let indexWorldTransition = null;138+ const worldCardsSeen = new Set();139+ let worldCard = null;137140 let sceneMotion = { phase: 'settled', scene: null, target: null, data: null, openedAt: 0, exitStartedAt: null, generation: 0, timer: null };138141 let copyFloatStart = 0;139142 const reducedMotion =
@@ -256,23 +259,30 @@ export function createStage(options = {}) {
256259 indexLockedNode = result?.locked ? result.node : null;257260 if (result?.moved && (direction === "left" || direction === "right"))258261 indexFacing = direction;262+ if (result?.moved && result.node?.worldId !== result.from?.worldId)263+ recordWorldTransition(result, direction === 'left' ? -1 : 1);259264 return result;260265 }266+ function recordWorldTransition(result, delta) {267+ const world = result.world || indexLayout?.getWorld?.(result.node?.worldId);268+ indexWorldTransition = {269+ from: result.from?.worldId || null,270+ to: world?.id || result.node?.worldId || null,271+ nodeId: result.node?.id || null,272+ delta: Math.sign(delta),273+ startedAt: visualNow(),274+ duration: 280,275+ };276+ options.onWorldTransition?.(indexWorldTransition);277+ announce(`World ${world?.number || ''}: ${world?.name || world?.id || 'World'}`.trim());278+ requestWorldCard(world, 'index');279+ }261280 function moveIndexWorld(delta) {262281 if (scene !== 'index' || !indexNavigator) return false;263282 const result = indexNavigator.moveWorld(delta);264283 if (result.moved) {265284 indexLockedNode = null;266- indexWorldTransition = {267- from: result.from?.worldId || null,268- to: result.world?.id || result.node?.worldId || null,269- nodeId: result.node?.id || null,270- delta: Math.sign(delta),271- startedAt: visualNow(),272- duration: 280,273- };274- options.onWorldTransition?.(indexWorldTransition);275- announce(`World ${result.world?.number || result.node?.worldIndex || ''}: ${result.world?.name || result.node?.packName || result.node?.worldId || 'World'}`.trim());285+ recordWorldTransition(result, delta);276286 } else {277287 indexLockedNode = null;278288 announce(delta < 0 ? 'Already at the first world' : 'Already at the last world');
@@ -280,6 +290,22 @@ export function createStage(options = {}) {
280290 render();281291 return result.moved;282292 }293+ function requestWorldCard(world, origin = 'index') {294+ if (!world?.id || worldCardsSeen.has(world.id)) return false;295+ worldCardsSeen.add(world.id);296+ worldCard = { world, origin, openedAt: visualNow(), geometry: null };297+ announce(`${world.name || 'World'}. ${world.tagline || ''} by ${world.author || 'unknown author'}. Press Enter to enter.`.trim());298+ render();299+ if (!reducedMotion) visualFrame(animateVisuals);300+ return true;301+ }302+ function dismissWorldCard() {303+ if (!worldCard) return false;304+ worldCard = null;305+ announce('INDEX');306+ render();307+ return true;308+ }283309 function animateVisuals(now) {284310 if (sceneMotion.phase === 'opening' && now - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {285311 sceneMotion = { ...sceneMotion, phase: 'settled' };
@@ -410,7 +436,26 @@ export function createStage(options = {}) {
410436 });411437 const active = indexGeometry.nodes?.find((node) => node.active);412438 if (active) catCenter = { x: active.centerX, y: active.centerY };439+ const activeWorld = indexNavigator.current?.world;440+ if (activeWorld) {441+ const completed = Number.isFinite(activeWorld.completedCount) ? activeWorld.completedCount : 0;442+ const total = Number.isFinite(activeWorld.levelCount) ? activeWorld.levelCount : 0;443+ canvas.setAttribute(444+ "aria-label",445+ `World ${activeWorld.number || 1}: ${activeWorld.name || activeWorld.id || 'World'}, ${completed} of ${total} proofs complete. Q and E switch worlds.`,446+ );447+ }413448 drawSceneExitShutter(visualNow());449+ if (worldCard) {450+ worldCard.geometry = drawWorldCard(ctx, worldCard.world, {451+ width: w,452+ height: h,453+ irisProgress: (visualNow() - worldCard.openedAt) / SCENE_MOTION_TIMING.entry,454+ reducedMotion,455+ coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,456+ });457+ canvas.setAttribute("aria-label", `${worldCard.world.name || 'World'}: ${worldCard.world.tagline || ''} by ${worldCard.world.author || 'unknown author'}. Press Enter to enter.`.trim());458+ }414459 } else if (scene === "pause") {415460 renderPauseScene(ctx, {416461 width: w, height: h, now: visualNow(), boardState, boardGame,
@@ -458,6 +503,7 @@ export function createStage(options = {}) {
458503 }459504 function enterScene(id, data = {}) {460505 if (id !== 'win') winFlow.cancel();506+ if (id !== 'index') worldCard = null;461507 irisCenter = catCenter;462508 if (id === "workshop") workshopOrigin = data.origin || scene;463509 if (id === "gift") giftOrigin = data.origin || scene;
@@ -508,6 +554,10 @@ export function createStage(options = {}) {
508554 : id === "workshop" && workshop?.accessibilityText ? workshop.accessibilityText()509555 : `${spec.eyebrow || ""} ${titleEl.textContent}`.trim());510556 render();557+ if (id === 'index' && indexNavigator && data.worldCard !== false) {558+ const world = data.worldId ? indexLayout?.getWorld?.(data.worldId) : indexNavigator.current?.world;559+ requestWorldCard(world, data.origin === 'workshop' ? 'workshop' : 'index');560+ }511561 if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause" || id === "gift" || id === "workshop"))512562 visualFrame(animateVisuals);513563 if (id === 'win' || id === 'pause') {
@@ -673,6 +723,10 @@ export function createStage(options = {}) {
673723 function dispatchOverlayIntent(intent = {}) {674724 const type = intent.type || intent;675725 if (sceneMotion.phase === 'exiting') return true;726+ if (scene === 'index' && worldCard) {727+ if (type === 'confirm' || type === 'menu') return dismissWorldCard();728+ return true;729+ }676730 if (scene === "pause") {677731 if (pauseMotionPhase === "exiting") return true;678732 if (type === "move") {
@@ -747,6 +801,7 @@ export function createStage(options = {}) {
747801 getWorkshop: () => workshop,748802 getIndexGeometry: () => indexGeometry,749803 getIndexNavigator: () => indexNavigator,804+ getWorldCard: () => worldCard,750805 clearIndexLockedNode: () => { indexLockedNode = null; },751806 getPauseMotionPhase: () => pauseMotionPhase,752807 getPauseItems: () => pauseItems,
@@ -799,6 +854,9 @@ export function createStage(options = {}) {
799854 get indexWorldTransition() {800855 return indexWorldTransition;801856 },857+ get worldCard() {858+ return worldCard;859+ },802860 root,803861 };804862 return Object.freeze(api);
M src/stage/events.js +21 -0
@@ -18,6 +18,7 @@ export function installStageEvents({
1818 getWorkshop,1919 getIndexGeometry,2020 getIndexNavigator,21+ getWorldCard,2122 clearIndexLockedNode,2223 getPauseMotionPhase,2324 getPauseItems,
@@ -43,6 +44,15 @@ export function installStageEvents({
4344 if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;4445 // Gameplay intents for title, proof, and win use the shared input router.4546 if (scene === "title" || scene === "proof" || scene === "win") return;47+ if (scene === "index" && getWorldCard?.()) {48+ if (event.key === "Enter" || event.key === "Escape") {49+ noteInput?.({ type: event.key === "Enter" ? "confirm" : "menu", source: "keyboard" });50+ dispatchOverlayIntent({ type: event.key === "Enter" ? "confirm" : "menu" });51+ event.preventDefault();52+ event.stopPropagation();53+ }54+ return;55+ }4656 if (scene === "index" && (event.key === "q" || event.key === "Q" || event.key === "e" || event.key === "E")) {4757 const delta = event.key.toLowerCase() === "e" ? 1 : -1;4858 noteInput?.({ type: "world", delta, source: "keyboard" });
@@ -164,6 +174,17 @@ export function installStageEvents({
164174 }165175 if (scene === "index" && getIndexGeometry()?.nodes?.length) {166176 const geometry = getIndexGeometry();177+ const card = getWorldCard?.();178+ if (card?.geometry?.enter) {179+ event.preventDefault?.();180+ event.stopImmediatePropagation?.();181+ const target = card.geometry.enter;182+ if (x >= target.x && x <= target.x + target.width && y >= target.y && y <= target.y + target.height) {183+ noteInput?.({ type: "confirm", source: "touch" });184+ dispatchOverlayIntent({ type: "confirm" });185+ }186+ return;187+ }167188 const workshopHit = geometry.footerActions?.workshop;168189 if (workshopHit && x >= workshopHit.x && x <= workshopHit.x + workshopHit.width &&169190 y >= workshopHit.y && y <= workshopHit.y + workshopHit.height) {
M tests/world-card.test.js +2 -0