83 lines
5.1 KiB
JavaScript
83 lines
5.1 KiB
JavaScript
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 };
|
|
}
|