feat: scaffold mvp shell and content runtime
This commit is contained in:
55
js/gameplay/difficulty/anchors.js
Normal file
55
js/gameplay/difficulty/anchors.js
Normal 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
|
||||
}
|
||||
47
js/gameplay/difficulty/classify-deadlock.js
Normal file
47
js/gameplay/difficulty/classify-deadlock.js
Normal 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
|
||||
29
js/gameplay/difficulty/evaluate-board.js
Normal file
29
js/gameplay/difficulty/evaluate-board.js
Normal 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
|
||||
138
js/gameplay/difficulty/generate-board.js
Normal file
138
js/gameplay/difficulty/generate-board.js
Normal 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
|
||||
9
js/gameplay/difficulty/index.js
Normal file
9
js/gameplay/difficulty/index.js
Normal 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'
|
||||
74
js/gameplay/difficulty/overlap-graph.js
Normal file
74
js/gameplay/difficulty/overlap-graph.js
Normal 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 ?? [])
|
||||
}
|
||||
36
js/gameplay/difficulty/random.js
Normal file
36
js/gameplay/difficulty/random.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user