clone

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

main
miau-is-true / src / scenes / map.js
map.js 17.5K
1/**2 * Data model for the walkable INDEX scene.3 *4 * The renderer owns the ink, tiles, and cat. This module only turns the5 * catalog's stable pack/level records into serpentine sheets and provides the6 * small amount of progress and cursor state the scene needs.7 */8import { cloneData } from '../data/clone.js';9export { PROGRESS_PREFIX, getProgressKey, createProgressStore } from './progress.js';1011function nonEmpty(value, label) {12 if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} must be a non-empty string`);13 return value;14}1516export const INDEX_COLUMNS = 5;17export const INDEX_ROWS = 3;18export const INDEX_NODES_PER_SHEET = INDEX_COLUMNS * INDEX_ROWS;19function levelEntries(source) {20 if (Array.isArray(source)) return source;21 if (source && typeof source.getLevels === 'function') return source.getLevels();22 if (source && Array.isArray(source.levels)) return source.levels;23 return [];24}2526function normalizeEntry(level, fallbackPackId = 'core', fallbackPackName = 'Miau Core') {27 const id = typeof level.id === 'string' ? level.id : '';28 const colon = id.indexOf(':');29 const packId = nonEmpty(level.packId || (colon > 0 ? id.slice(0, colon) : fallbackPackId), 'packId');30 const levelId = nonEmpty(level.localId || (colon > 0 ? id.slice(colon + 1) : id), 'levelId');31 return {32 id: id || `${packId}:${levelId}`,33 packId,34 levelId,35 packName: level.packName || fallbackPackName || packId,36 title: level.title || `Proof ${levelId}`,37 hint: level.hint || '',38 level,39 };40}4142function packRecords(source, flat) {43 if (source && typeof source.getPacks === 'function') {44 const byPack = new Map();45 for (const level of flat) {46 const entry = normalizeEntry(level);47 const entries = byPack.get(entry.packId) || [];48 entries.push(entry);49 byPack.set(entry.packId, entries);50 }51 return source.getPacks().map((pack, registrationIndex) => ({52 ...pack,53 registrationIndex,54 entries: (byPack.get(pack.id) || []).map(entry => ({55 ...entry,56 packName: entry.packName || pack.manifest?.name || pack.name || pack.id,57 })),58 }));59 }60 const records = new Map();61 for (const level of flat) {62 const entry = normalizeEntry(level);63 if (!records.has(entry.packId)) records.set(entry.packId, {64 id: entry.packId,65 origin: level.origin || 'local',66 registrationIndex: records.size,67 manifest: { name: entry.packName },68 entries: [],69 });70 records.get(entry.packId).entries.push(entry);71 }72 return [...records.values()];73}7475function worldMetadata(record, entries, registrationIndex) {76 const manifest = record.manifest || {};77 const world = manifest.world || record.world || {};78 const first = entries[0];79 return {80 id: record.id,81 number: registrationIndex + 1,82 worldId: record.id,83 packId: record.id,84 name: manifest.name || record.name || first?.packName || record.id,85 author: manifest.author || record.author || null,86 glyph: world.glyph || null,87 tagline: world.tagline || null,88 accent: world.accent || null,89 origin: record.origin || 'local',90 registrationIndex,91 sheets: [],92 levelCount: entries.length,93 completedCount: 0,94 };95}9697function groupedEntries(source) {98 const flat = levelEntries(source);99 return packRecords(source, flat).map((record, registrationIndex) => {100 const entries = record.entries || [];101 return { ...worldMetadata(record, entries, registrationIndex), entries };102 });103}104105function nodeCoordinate(pathIndex) {106 const row = Math.floor(pathIndex / INDEX_COLUMNS);107 const offset = pathIndex % INDEX_COLUMNS;108 return { row, column: row % 2 === 0 ? offset : INDEX_COLUMNS - 1 - offset };109}110111function makeSheet(group, sheetEntries, sheetIndex, globalSheetIndex) {112 const sheetId = `${group.id}:sheet-${sheetIndex + 1}`;113 const nodes = sheetEntries.map((entry, pathIndex) => {114 const coordinate = nodeCoordinate(pathIndex);115 return {116 id: entry.id,117 packId: entry.packId,118 worldId: group.id,119 levelId: entry.levelId,120 packName: entry.packName,121 title: entry.title,122 hint: entry.hint,123 sheetId,124 sheetIndex,125 globalSheetIndex,126 pathIndex,127 number: pathIndex + 1,128 row: coordinate.row,129 column: coordinate.column,130 x: coordinate.column,131 y: coordinate.row,132 level: cloneData(entry.level),133 };134 });135 const byCell = new Map(nodes.map(node => [`${node.row},${node.column}`, node]));136 for (const node of nodes) {137 const at = (row, column) => byCell.get(`${row},${column}`)?.id || null;138 node.previousId = nodes[node.pathIndex - 1]?.id || null;139 node.nextId = nodes[node.pathIndex + 1]?.id || null;140 node.neighbors = {141 left: at(node.row, node.column - 1),142 right: at(node.row, node.column + 1),143 up: at(node.row - 1, node.column),144 down: at(node.row + 1, node.column),145 };146 }147 return {148 id: sheetId,149 packId: group.id,150 worldId: group.id,151 packName: group.name,152 sheetIndex,153 globalSheetIndex,154 columns: INDEX_COLUMNS,155 rows: INDEX_ROWS,156 nodes,157 previousSheetId: null,158 nextSheetId: null,159 previousWorldId: null,160 nextWorldId: null,161 };162}163164/**165 * Lay out catalog levels in registration order. Every pack owns a world and166 * its own sheet range; any range over fifteen proofs continues within that167 * same world.168 */169export function createIndexLayout(source) {170 const groups = groupedEntries(source);171 const sheetSpecs = [];172 for (const group of groups) {173 for (let offset = 0; offset < group.entries.length; offset += INDEX_NODES_PER_SHEET) {174 sheetSpecs.push({ group, sheetEntries: group.entries.slice(offset, offset + INDEX_NODES_PER_SHEET), sheetIndex: Math.floor(offset / INDEX_NODES_PER_SHEET) });175 }176 }177 const sheets = sheetSpecs.map((spec, globalSheetIndex) => makeSheet(spec.group, spec.sheetEntries, spec.sheetIndex, globalSheetIndex));178 const sheetsByWorld = new Map();179 for (const sheet of sheets) {180 const own = sheetsByWorld.get(sheet.worldId) || [];181 own.push(sheet);182 sheetsByWorld.set(sheet.worldId, own);183 }184 for (const group of groups) {185 const own = sheetsByWorld.get(group.id) || [];186 group.sheets = own.map(sheet => sheet.id);187 for (let index = 0; index < own.length; index++) {188 const sheet = own[index];189 sheet.previousSheetId = own[index - 1]?.id || null;190 sheet.nextSheetId = own[index + 1]?.id || null;191 const previousSheet = own[index - 1];192 const nextSheet = own[index + 1];193 if (previousSheet?.nodes.length && sheet.nodes.length) {194 sheet.nodes[0].previousId = previousSheet.nodes.at(-1).id;195 previousSheet.nodes.at(-1).nextId = sheet.nodes[0].id;196 }197 if (nextSheet?.nodes.length && sheet.nodes.length) {198 sheet.nodes.at(-1).nextId = nextSheet.nodes[0].id;199 nextSheet.nodes[0].previousId = sheet.nodes.at(-1).id;200 }201 }202 }203 const worlds = groups.map(({ entries: _entries, ...group }, index) => ({204 ...group,205 number: index + 1,206 previousWorldId: groups[index - 1]?.id || null,207 nextWorldId: groups[index + 1]?.id || null,208 sheets: [...group.sheets],209 }));210 const worldIndex = new Map(worlds.map((world, index) => [world.id, index]));211 for (const sheet of sheets) {212 const index = worldIndex.get(sheet.worldId);213 sheet.previousWorldId = worlds[index - 1]?.id || null;214 sheet.nextWorldId = worlds[index + 1]?.id || null;215 }216 const nodes = sheets.flatMap(sheet => sheet.nodes);217 return withLookupHelpers({218 columns: INDEX_COLUMNS,219 rows: INDEX_ROWS,220 worlds,221 sheets,222 nodes,223 });224}225226function withLookupHelpers(layout) {227 const byWorld = new Map(layout.worlds.map(world => [world.id, world]));228 const bySheet = new Map(layout.sheets.map(sheet => [sheet.id, sheet]));229 const byNode = new Map(layout.nodes.map(node => [node.id, node]));230 const byWorldNodes = new Map();231 for (const node of layout.nodes) {232 const nodes = byWorldNodes.get(node.worldId) || [];233 nodes.push(node);234 byWorldNodes.set(node.worldId, nodes);235 }236 return {237 ...layout,238 getWorld(worldId) { return byWorld.get(worldId) || null; },239 getSheet(sheetId) { return bySheet.get(sheetId) || null; },240 getNode(nodeId) { return byNode.get(nodeId) || null; },241 getWorldNodes(worldId) { return byWorldNodes.get(worldId) || []; },242 };243}244245function solvedLookup(progress, node) {246 if (!progress) return false;247 if (typeof progress.isSolved === 'function') return progress.isSolved(node.packId, node.levelId);248 if (typeof progress.get === 'function') return Boolean(progress.get(node.packId, node.levelId));249 const key = `${node.packId}/${node.levelId}`;250 return progress instanceof Set ? progress.has(key) : Boolean(progress[key]);251}252253/** Add solved/locked state without mutating the layout or progress store. */254export function decorateIndex(layout, progress) {255 const priorByPack = new Map();256 const completedByWorld = new Map();257 const sheets = layout.sheets.map(sheet => {258 const nodes = sheet.nodes.map(node => ({ ...node, neighbors: { ...node.neighbors } }));259 return {260 ...sheet,261 nodes: nodes.map(node => {262 const solved = solvedLookup(progress, node);263 if (solved) completedByWorld.set(node.worldId, (completedByWorld.get(node.worldId) || 0) + 1);264 const prior = priorByPack.get(node.packId);265 const locked = !solved && Boolean(prior) && !prior.solved;266 const result = { ...node, solved, locked, state: solved ? 'solved' : (locked ? 'locked' : 'available') };267 priorByPack.set(node.packId, result);268 return result;269 }),270 };271 });272 const worlds = layout.worlds.map(world => ({273 ...world,274 completedCount: completedByWorld.get(world.id) || 0,275 }));276 return withLookupHelpers({ ...layout, worlds, sheets, nodes: sheets.flatMap(sheet => sheet.nodes) });277}278279function findNode(layout, nodeId) {280 return layout.getNode?.(nodeId) || layout.nodes.find(node => node.id === nodeId) || null;281}282283function candidateFor(node, direction, layout) {284 // Arrow and gamepad directions are spatial. `next` and `previous` remain285 // explicit path traversal for continue/campaign callers, including sheet286 // boundaries where no visible neighbor exists.287 if (direction === 'next') {288 const local = findNode(layout, node.nextId);289 if (local) return local;290 const world = layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId);291 const nextWorld = world && layout.worlds[world.number];292 return nextWorld ? layout.getWorldNodes?.(nextWorld.id)?.[0] || layout.nodes.find(item => item.worldId === nextWorld.id) || null : null;293 }294 if (direction === 'previous') {295 const local = findNode(layout, node.previousId);296 if (local) return local;297 const world = layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId);298 const previousWorld = world && layout.worlds[world.number - 2];299 const candidates = previousWorld ? layout.getWorldNodes?.(previousWorld.id) || layout.nodes.filter(item => item.worldId === previousWorld.id) : [];300 return candidates.at(-1) || null;301 }302 const deltas = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };303 const delta = deltas[direction];304 if (!delta) return null;305 // Only path edges are navigable. This keeps the arrows on the inked306 // serpentine route instead of jumping between visually adjacent nodes that307 // are separated by an empty grid cell in the drawn path.308 for (const adjacentId of [node.previousId, node.nextId]) {309 const candidate = findNode(layout, adjacentId);310 if (!candidate) continue;311 if (candidate.sheetId !== node.sheetId) {312 // A sheet boundary is the one intentional spatial jump: the first313 // node can page back with left, and the final node can page forward in314 // the direction its serpentine row is travelling.315 const isFirst = node.pathIndex === 0 && adjacentId === node.previousId;316 const sheet = layout.getSheet?.(node.sheetId) || layout.sheets.find(item => item.id === node.sheetId);317 const isLast = adjacentId === node.nextId && node.nextId && sheet?.nodes.at(-1)?.id === node.id;318 const forward = node.row % 2 === 0 ? 'right' : 'left';319 if ((isFirst && direction === 'left') || (isLast && direction === forward)) return candidate;320 continue;321 }322 if (candidate.column - node.column === delta[0]323 && candidate.row - node.row === delta[1]) return candidate;324 }325 const world = layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId);326 const worldIndex = world ? world.number - 1 : -1;327 const worldNodes = world ? layout.getWorldNodes?.(world.id) || layout.nodes.filter(item => item.worldId === world.id) : [];328 const first = worldNodes[0];329 const last = worldNodes.at(-1);330 if (direction === 'left' && node.id === first?.id && worldIndex > 0) {331 const previousNodes = layout.getWorldNodes?.(layout.worlds[worldIndex - 1].id) || layout.nodes.filter(item => item.worldId === layout.worlds[worldIndex - 1].id);332 return previousNodes.at(-1) || null;333 }334 const forward = node.row % 2 === 0 ? 'right' : 'left';335 if (direction === forward && node.id === last?.id && worldIndex + 1 < layout.worlds.length) {336 return layout.getWorldNodes?.(layout.worlds[worldIndex + 1].id)?.[0] || layout.nodes.find(item => item.worldId === layout.worlds[worldIndex + 1].id) || null;337 }338 return null;339}340341/**342 * Cursor state for the map scene. `locked: 'indicate'` returns a locked node343 * so the renderer can shake it; `locked: 'skip'` walks to the next available344 * proof in that direction. Entering a locked node always returns a reason.345 */346export function createIndexNavigator(layout, options = {}) {347 let currentId = findNode(layout, options.startNodeId)?.id || layout.nodes[0]?.id || null;348 let currentSheetId = options.startSheetId || findNode(layout, currentId)?.sheetId || layout.sheets[0]?.id || null;349 const mode = options.locked || 'indicate';350 const cursorByWorld = new Map();351352 function current() { return findNode(layout, currentId); }353 function state() {354 const node = current();355 const world = node ? layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId) || null : null;356 return {357 node,358 nodeId: node?.id || null,359 sheet: layout.getSheet?.(currentSheetId) || layout.sheets.find(sheet => sheet.id === currentSheetId) || null,360 sheetId: currentSheetId,361 world,362 worldId: world?.id || null,363 worldIndex: world ? world.number - 1 : -1,364 };365 }366 function moveWorld(delta) {367 const before = current();368 if (!before || !Number.isInteger(delta) || delta === 0) return { moved: false, boundary: true, node: before, from: before };369 const worldNumber = layout.getWorld?.(before.worldId)?.number;370 const worldIndex = Number.isInteger(worldNumber) ? worldNumber - 1 : -1;371 const targetIndex = worldIndex + Math.sign(delta);372 const target = layout.worlds[targetIndex];373 if (!target) return { moved: false, boundary: true, node: before, from: before, reason: 'boundary' };374 cursorByWorld.set(before.worldId, before.id);375 const remembered = findNode(layout, cursorByWorld.get(target.id));376 const targetNodes = layout.getWorldNodes?.(target.id) || layout.nodes.filter(node => node.worldId === target.id);377 const destination = remembered || targetNodes.find(node => !node.locked) || targetNodes[0] || null;378 if (!destination) return { moved: false, boundary: true, node: before, from: before, reason: 'empty-world' };379 currentId = destination.id;380 currentSheetId = destination.sheetId;381 return { moved: true, boundary: false, world: target, node: destination, from: before };382 }383 function move(direction, moveOptions = {}) {384 const before = current();385 if (!before) return { moved: false, locked: false, node: null, from: null };386 const policy = moveOptions.locked || mode;387 let candidate = candidateFor(before, direction, layout);388 let skipped = 0;389 while (candidate?.locked && policy === 'skip' && skipped < layout.nodes.length) {390 skipped++;391 candidate = candidateFor(candidate, direction, layout);392 }393 if (!candidate) return { moved: false, locked: false, node: before, from: before };394 if (candidate.locked && policy !== 'skip') {395 return { moved: false, locked: true, node: candidate, from: before, reason: 'locked' };396 }397 currentId = candidate.id;398 currentSheetId = candidate.sheetId;399 return { moved: true, locked: false, skipped, node: candidate, from: before };400 }401 function enter() {402 const node = current();403 if (!node) return { ok: false, reason: 'empty' };404 if (node.locked) return { ok: false, reason: 'locked', node };405 return { ok: true, packId: node.packId, levelId: node.levelId, level: cloneData(node.level), node };406 }407 function restoreCursorSnapshot(snapshot) {408 const entries = snapshot instanceof Map409 ? [...snapshot.entries()]410 : snapshot && typeof snapshot === 'object' ? Object.entries(snapshot) : [];411 cursorByWorld.clear();412 for (const [worldId, nodeId] of entries) {413 const node = findNode(layout, nodeId);414 if (node?.worldId === worldId) cursorByWorld.set(worldId, node.id);415 }416 return cursorSnapshot();417 }418 function cursorSnapshot() {419 return Object.fromEntries(cursorByWorld);420 }421 if (options.cursorByWorld) restoreCursorSnapshot(options.cursorByWorld);422 return {423 get current() { return state(); },424 move,425 moveWorld,426 enter,427 cursorSnapshot,428 restoreCursorSnapshot,429 setNode(nodeId) {430 const node = findNode(layout, nodeId);431 if (!node) return false;432 currentId = node.id; currentSheetId = node.sheetId; return true;433 },434 };435}436437export default createIndexLayout;