import { hexagon, random, randomTracks, trace, key, opposite, rotateBoard, analyze, available, Game, DIRS } from './core.js'; export const countBends = route => route.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length; const radiusAt = (q, r) => Math.max(Math.abs(q), Math.abs(r), Math.abs(q + r)); export function pointsInward(tile) { const [dq, dr] = DIRS[tile.dir]; return radiusAt(tile.q + dq, tile.r + dr) < radiusAt(tile.q, tile.r); } // Reverse insertion preserves a clearing order while favoring complete journeys. // Only the construction orientation is constrained; rotating can change a route. export function generateExperienceLevel(seed, { radius = 3, target = 12, allFree = false, maxBends = 18, minHeroBends = 6, clearBlockers = false, startRotation = 0, placementTurns = [] } = {}) { const rng = random(seed); let level = { version: 1, id: `experience-${seed}`, name: '弯路巡游', seed, rotationEnabled: true, cells: hexagon(radius), tiles: [], moveLimit: target }; level.cells.forEach(c => { c.tracks = randomTracks(rng); }); const boards = new Map(), pools = new Map(); for (const turns of new Set([0, ...placementTurns])) { const board = rotateBoard(level, turns), candidates = []; boards.set(turns, board); for (const cell of level.cells) for (let dir = 0; dir < 6; dir++) { const t = { q: cell.q, r: cell.r, dir }; const route = trace(board, t, [], true), bends = countBends(route); if (route.status === 'exit' && bends <= maxBends) candidates.push({ t, bends, cells: route.steps.length }); } pools.set(turns, candidates); } for (let n = 0; n < target; n++) { const occupied = new Set(level.tiles.map(t => key(t.q, t.r))), choices = []; const turns = placementTurns[n] || 0, board = boards.get(turns); for (const c of pools.get(turns)) { if (occupied.has(key(c.t.q, c.t.r)) || (n === 0 && c.bends < minHeroBends)) continue; const tile = { ...c.t, id: `t${n}`, color: n % 4 }, tiles = [...level.tiles, tile]; if (trace(board, tile, tiles).status !== 'exit') continue; if (allFree && tiles.some(t => trace(board, t, tiles).status !== 'exit')) continue; if (clearBlockers && tiles.some(t => { const route = trace(board, t, tiles); return route.status === 'blocked' && !trace(board, t, tiles, true).blockers.includes(route.blocker); })) continue; choices.push({ tile, weight: rng() * 4 + c.bends * .55 + c.cells * .15 }); } if (!choices.length) break; choices.sort((a, b) => b.weight - a.weight); level.tiles.push(choices[Math.floor(rng() * Math.min(3, choices.length))].tile); } level = rotateBoard(level, startRotation); level.moveLimit = level.tiles.length; return level; } // Structural evidence for a specific complete replay, not a player enjoyment score. export function inspectExperience(level, actions = analyze(level).actions) { const game = new Game(level), paths = [], reveals = [], unlocks = [], turns = [], hidden = []; let dry = 0, maxDryRotations = 0, stage = 0; for (let index = 0; index < actions.length; index++) { const action = actions[index], before = game.tiles.map(t => ({ t, route: trace(game.level, t, game.tiles) })); for (const { t, route } of before) if (route.status === 'blocked' && !trace(game.level, t, game.tiles, true).blockers.includes(route.blocker)) hidden.push({ action: index, id: t.id, blocker: route.blocker }); if (action.type === 'rotate') { const result = game.rotate(action.direction); if (result.status !== 'rotated') throw new Error(`Invalid rotation at ${index}`); dry++; maxDryRotations = Math.max(maxDryRotations, dry); stage++; const opened = []; for (const { t, route } of before) { const after = trace(game.level, t, game.tiles); if (route.status !== 'exit' && after.status === 'exit') { opened.push(t.id); if (pointsInward(t)) reveals.push({ action: index, id: t.id, direction: action.direction, before: route.status, bends: countBends(after), cells: after.steps.length }); } } turns.push({ action: index, direction: action.direction, remaining: game.tiles.length, opened, free: available(game.level, game.tiles).map(t => t.id) }); } else { dry = 0; const result = game.tap(action.id); if (result.status !== 'exit') throw new Error(`Invalid removal ${action.id} at ${index}`); paths.push({ id: action.id, action: index, stage, bends: countBends(result), cells: result.steps.length }); for (const { t, route } of before) if (route.status === 'blocked' && trace(game.level, t, game.tiles).status === 'exit') { const after = trace(game.level, t, game.tiles); unlocks.push({ action: index, removed: action.id, id: t.id, bends: countBends(after), cells: after.steps.length, onRoute: trace(game.level, t, [...game.tiles, result.tile], true).blockers.includes(action.id) }); } } } return { won: game.won, paths, reveals, unlocks, turns, maxDryRotations, hidden, maxBends: Math.max(0, ...paths.map(p => p.bends)), longPaths: paths.filter(p => p.bends >= 6).length }; }