Files
HexTrails/src/experience-generator.js
T

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 };
}