feat: scaffold mvp shell and content runtime

This commit is contained in:
manpengan
2026-03-29 00:36:28 +08:00
parent 25a38cbf05
commit c118e24bd1
25 changed files with 2903 additions and 7 deletions

View File

@@ -0,0 +1,55 @@
const BOUNDS = {
left: 32,
right: 358,
top: 130,
bottom: 560,
}
export function createAnchorGrid(columns = 6, rows = 8) {
const anchors = []
const width = BOUNDS.right - BOUNDS.left
const height = BOUNDS.bottom - BOUNDS.top
const stepX = width / (columns - 1)
const stepY = height / (rows - 1)
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
anchors.push({
id: `anchor_${row}_${column}`,
x: BOUNDS.left + column * stepX,
y: BOUNDS.top + row * stepY,
})
}
}
return anchors
}
export function getLayerDistribution(density, layers) {
const presets = {
low: [0.4, 0.35, 0.25],
medium: [0.3, 0.3, 0.25, 0.15],
medium_high: [0.28, 0.27, 0.25, 0.2],
high: [0.25, 0.25, 0.25, 0.25],
}
const base = presets[density] ?? presets.medium
const sliced = base.slice(0, layers)
const total = sliced.reduce((sum, value) => sum + value, 0)
return sliced.map((value) => value / total)
}
export function allocateLayerCounts(totalPieces, density, layers) {
const distribution = getLayerDistribution(density, layers)
const counts = distribution.map((value) => Math.floor(totalPieces * value))
let assigned = counts.reduce((sum, value) => sum + value, 0)
while (assigned < totalPieces) {
for (let index = 0; index < counts.length && assigned < totalPieces; index += 1) {
counts[index] += 1
assigned += 1
}
}
return counts
}

View File

@@ -0,0 +1,47 @@
import { getClickablePieces } from './overlap-graph.js'
function getPotentialMatches(slot, clickablePieces) {
return clickablePieces.filter((piece) => {
const sameCount = slot.filter((slottedPiece) => slottedPiece.elementId === piece.elementId).length
return sameCount >= 2
})
}
export function classifyDeadlock(runtimeState, boardState) {
const slot = runtimeState.slot ?? []
const bypass = runtimeState.bypass ?? []
const clickablePieces = getClickablePieces(boardState)
if (slot.length < 7) {
return {
type: 'none',
clickablePieces,
}
}
const matchablePieces = getPotentialMatches(slot, clickablePieces)
if (matchablePieces.length > 0) {
return {
type: 'none',
clickablePieces,
matchablePieces,
}
}
if (bypass.length > 0) {
return {
type: 'soft',
clickablePieces,
matchablePieces: [],
}
}
return {
type: 'hard',
clickablePieces,
matchablePieces: [],
}
}
export default classifyDeadlock

View File

@@ -0,0 +1,29 @@
import { getClickablePieces } from './overlap-graph.js'
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
export function evaluateBoard(boardState, levelPreset) {
const pieces = (boardState.pieces ?? []).filter((piece) => !piece.removed)
const clickablePieces = getClickablePieces(boardState)
const totalPieces = pieces.length
const initialClickableRatio = totalPieces === 0 ? 0 : clickablePieces.length / totalPieces
const targetPassRate = levelPreset?.targetPassRate ?? 0.75
const visibilityBonus = (initialClickableRatio - 0.3) * 0.35
const densityPenalty = levelPreset?.density === 'high' ? 0.05 : levelPreset?.density === 'medium_high' ? 0.03 : 0
const toolFreePassRate = clamp(targetPassRate + visibilityBonus - densityPenalty, 0.45, 0.99)
const toolAssistPassRate = clamp(toolFreePassRate + 0.12, toolFreePassRate, 1)
const avgTurnsToFinish = totalPieces / 1.2
return {
totalPieces,
initialClickableRatio,
simulatedPassRate: toolFreePassRate,
avgTurnsToFinish,
toolFreePassRate,
toolAssistPassRate,
}
}
export default evaluateBoard

View File

@@ -0,0 +1,138 @@
import { createAnchorGrid, allocateLayerCounts } from './anchors.js'
import { evaluateBoard } from './evaluate-board.js'
import { buildOverlapGraph } from './overlap-graph.js'
import { createRng, shuffleWithRng } from './random.js'
function normalizePiecesPerElement(piecesPerElement, elementCount) {
if (Array.isArray(piecesPerElement)) {
return piecesPerElement.slice(0, elementCount)
}
return Array.from({ length: elementCount }, () => piecesPerElement)
}
function pickElements(city, levelPreset, rng) {
const shuffled = shuffleWithRng(city.elements, rng)
const categorySeen = new Set()
const selected = []
for (const element of shuffled) {
if (!categorySeen.has(element.category)) {
selected.push(element)
categorySeen.add(element.category)
}
if (selected.length === levelPreset.elementCount) {
return selected
}
}
for (const element of shuffled) {
if (!selected.includes(element)) {
selected.push(element)
}
if (selected.length === levelPreset.elementCount) {
break
}
}
return selected
}
function createPiecePool(selectedElements, counts) {
const pieces = []
let sequence = 1
selectedElements.forEach((element, elementIndex) => {
const count = counts[elementIndex]
for (let offset = 0; offset < count; offset += 1) {
pieces.push({
id: `piece_${String(sequence).padStart(4, '0')}`,
elementId: element.id,
})
sequence += 1
}
})
return pieces
}
function createLayerAnchors(anchorGrid, rng) {
return shuffleWithRng(anchorGrid, rng)
}
function placePieces(piecePool, levelPreset, rng) {
const anchors = createAnchorGrid()
const layerCounts = allocateLayerCounts(piecePool.length, levelPreset.density, levelPreset.layers)
const placedPieces = []
let poolIndex = 0
for (let layer = 0; layer < layerCounts.length; layer += 1) {
const layerAnchors = createLayerAnchors(anchors, rng)
const previousLayerPieces = placedPieces.filter((piece) => piece.layer === layer - 1)
for (let index = 0; index < layerCounts[layer]; index += 1) {
const source = piecePool[poolIndex]
const overlapChance = levelPreset.density === 'high'
? 0.85
: levelPreset.density === 'medium_high'
? 0.7
: levelPreset.density === 'medium'
? 0.55
: 0.35
let anchor = layerAnchors[index % layerAnchors.length]
if (layer > 0 && previousLayerPieces.length > 0 && rng() < overlapChance) {
const target = previousLayerPieces[Math.floor(rng() * previousLayerPieces.length)]
anchor = { x: target.x + 6, y: target.y + 6 }
}
placedPieces.push({
...source,
layer,
x: Math.round(anchor.x + (rng() - 0.5) * 12),
y: Math.round(anchor.y + (rng() - 0.5) * 12),
width: 64,
height: 64,
rotation: Math.round((rng() - 0.5) * 12),
removed: false,
})
poolIndex += 1
}
}
return placedPieces
}
export function generateBoard({ cityId, levelId, seed, contentSystem }) {
const city = contentSystem.getCity(cityId)
const levelPreset = contentSystem.getLevelPreset(cityId, levelId)
if (!city || !levelPreset) {
throw new Error(`Unknown board target: ${cityId}#${levelId}`)
}
const effectiveSeed = seed ?? levelPreset.seedBase
const rng = createRng(effectiveSeed)
const selectedElements = pickElements(city, levelPreset, rng)
const counts = normalizePiecesPerElement(levelPreset.piecesPerElement, levelPreset.elementCount)
const piecePool = createPiecePool(selectedElements, counts)
const pieces = placePieces(shuffleWithRng(piecePool, rng), levelPreset, rng)
const overlapGraph = buildOverlapGraph(pieces)
const boardState = {
boardId: `${cityId}-${levelId}-${effectiveSeed}`,
cityId,
levelId,
seed: effectiveSeed,
pieces,
overlapGraph,
metrics: {},
}
boardState.metrics = evaluateBoard(boardState, levelPreset)
return boardState
}
export default generateBoard

View File

@@ -0,0 +1,9 @@
export { classifyDeadlock } from './classify-deadlock.js'
export { evaluateBoard } from './evaluate-board.js'
export { generateBoard } from './generate-board.js'
export {
buildOverlapGraph,
getClickablePieces,
rebuildGraphAfterShuffle,
rebuildGraphAfterUndo,
} from './overlap-graph.js'

View File

@@ -0,0 +1,74 @@
function getPieceArea(piece) {
return piece.width * piece.height
}
function overlapArea(left, right) {
const overlapWidth = Math.max(0, Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x))
const overlapHeight = Math.max(0, Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y))
return overlapWidth * overlapHeight
}
function overlapsCenterZone(lower, upper) {
const centerWidth = lower.width * 0.5
const centerHeight = lower.height * 0.5
const centerBox = {
x: lower.x + lower.width * 0.25,
y: lower.y + lower.height * 0.25,
width: centerWidth,
height: centerHeight,
}
return overlapArea(centerBox, upper) > 0
}
export function buildOverlapGraph(pieces) {
const graph = Object.fromEntries(pieces.map((piece) => [piece.id, []]))
for (let leftIndex = 0; leftIndex < pieces.length; leftIndex += 1) {
for (let rightIndex = 0; rightIndex < pieces.length; rightIndex += 1) {
if (leftIndex === rightIndex) {
continue
}
const blocker = pieces[leftIndex]
const blocked = pieces[rightIndex]
if (blocker.layer <= blocked.layer) {
continue
}
const ratio = overlapArea(blocker, blocked) / getPieceArea(blocked)
if (ratio >= 0.2 && overlapsCenterZone(blocked, blocker)) {
graph[blocker.id].push(blocked.id)
}
}
}
return graph
}
export function getClickablePieces(boardState) {
const pieces = (boardState.pieces ?? []).filter((piece) => !piece.removed)
const indegree = Object.fromEntries(pieces.map((piece) => [piece.id, 0]))
const graph = boardState.overlapGraph ?? {}
for (const blockedIds of Object.values(graph)) {
for (const blockedId of blockedIds) {
if (blockedId in indegree) {
indegree[blockedId] += 1
}
}
}
return pieces.filter((piece) => indegree[piece.id] === 0)
}
export function rebuildGraphAfterShuffle(boardState) {
return buildOverlapGraph(boardState.pieces ?? [])
}
export function rebuildGraphAfterUndo(boardState) {
return buildOverlapGraph(boardState.pieces ?? [])
}

View File

@@ -0,0 +1,36 @@
export function hashSeed(input) {
const value = String(input)
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
export function createRng(seed) {
let state = typeof seed === 'number' ? seed >>> 0 : hashSeed(seed)
return function next() {
state += 0x6D2B79F5
let value = state
value = Math.imul(value ^ (value >>> 15), value | 1)
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
}
}
export function shuffleWithRng(items, rng) {
const result = [...items]
for (let index = result.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(rng() * (index + 1))
const temp = result[index]
result[index] = result[swapIndex]
result[swapIndex] = temp
}
return result
}

View File

@@ -0,0 +1,189 @@
import { generateBoard, getClickablePieces } from '../difficulty/index.js'
function cloneBoardState(boardState) {
return {
...boardState,
pieces: boardState.pieces.map((piece) => ({ ...piece })),
overlapGraph: Object.fromEntries(
Object.entries(boardState.overlapGraph ?? {}).map(([pieceId, blockedIds]) => [pieceId, [...blockedIds]]),
),
metrics: { ...(boardState.metrics ?? {}) },
}
}
function getPieceById(boardState, pieceId) {
return boardState.pieces.find((piece) => piece.id === pieceId) ?? null
}
function createSlotEntry(piece) {
return {
pieceId: piece.id,
elementId: piece.elementId,
}
}
function removeMatchedEntries(slot, elementId) {
let remaining = 3
return slot.filter((entry) => {
if (entry.elementId === elementId && remaining > 0) {
remaining -= 1
return false
}
return true
})
}
function getMatchingTriples(slot) {
const counts = slot.reduce((map, entry) => {
map.set(entry.elementId, (map.get(entry.elementId) ?? 0) + 1)
return map
}, new Map())
const matchedElementId = [...counts.entries()].find(([, count]) => count >= 3)?.[0] ?? null
return matchedElementId
}
function getStateSnapshot(state, boardState) {
return {
cityId: state.cityId,
levelId: state.levelId,
seed: state.seed,
status: state.status,
slot: [...state.slot],
bypass: [...state.bypass],
removedPieceIds: [...state.removedPieceIds],
boardState,
}
}
export function createGameSession({
cityId,
levelId,
seed,
contentSystem,
boardState,
}) {
const resolvedBoard = boardState ? cloneBoardState(boardState) : generateBoard({ cityId, levelId, seed, contentSystem })
const state = {
cityId,
levelId,
seed: resolvedBoard.seed,
status: 'playing',
slot: [],
bypass: [],
removedPieceIds: [],
}
function getState() {
return getStateSnapshot(state, resolvedBoard)
}
function isBoardCleared() {
return resolvedBoard.pieces.every((piece) => piece.removed) && state.slot.length === 0
}
function getClickable() {
return getClickablePieces(resolvedBoard)
}
function pickPiece(pieceId) {
if (state.status !== 'playing') {
return {
status: state.status,
state: getState(),
}
}
const piece = getPieceById(resolvedBoard, pieceId)
if (!piece || piece.removed) {
return {
status: 'invalid',
state: getState(),
}
}
const clickableIds = new Set(getClickable().map((clickablePiece) => clickablePiece.id))
if (!clickableIds.has(pieceId)) {
return {
status: 'blocked',
state: getState(),
}
}
piece.removed = true
state.slot.push(createSlotEntry(piece))
const matchedElementId = getMatchingTriples(state.slot)
if (matchedElementId) {
const matchedIds = state.slot
.filter((entry) => entry.elementId === matchedElementId)
.slice(0, 3)
.map((entry) => entry.pieceId)
state.slot = removeMatchedEntries(state.slot, matchedElementId)
state.removedPieceIds.push(...matchedIds)
if (isBoardCleared()) {
state.status = 'won'
return {
status: 'won',
matchedElementId,
matchedIds,
state: getState(),
}
}
return {
status: 'matched',
matchedElementId,
matchedIds,
state: getState(),
}
}
if (state.slot.length >= 7) {
state.status = 'failed'
return {
status: 'failed',
state: getState(),
}
}
return {
status: 'picked',
state: getState(),
}
}
function restart(nextSeed = seed ?? resolvedBoard.seed) {
const freshSession = createGameSession({
cityId,
levelId,
seed: nextSeed,
contentSystem,
})
return freshSession
}
return {
cityId,
levelId,
seed: resolvedBoard.seed,
getBoardState() {
return resolvedBoard
},
getState,
getClickablePieces() {
return getClickable()
},
pickPiece,
restart,
}
}
export default createGameSession