refactor: reorg

This commit is contained in:
2026-04-17 01:05:48 +08:00
parent 5e55b58c43
commit 3f3490fad8
30 changed files with 18 additions and 18 deletions
@@ -0,0 +1,537 @@
import { effectDesertData, cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { PlayerDeck, GameCard } from "../deck/types";
import type {
BuffTable,
CombatEffectEntry,
CombatState,
EffectData,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
PlayerCombatState,
} from "./types";
import {
drawCardsToHand,
addFatigueCards,
discardCard,
exhaustCard,
getEffectData,
discardHand,
} from "./state";
export type DamageResult = {
damageDealt: number;
blockedByDefend: number;
targetDied: boolean;
};
export function applyDamage(
state: CombatState,
targetKey: "player" | string,
amount: number,
sourceKey?: "player" | string,
): DamageResult {
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
let actualDamage = amount;
let blockedByDefend = 0;
if (targetKey === "player") {
const defendStacks = state.player.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
state.player.buffs["defend"] = defendStacks - blockedByDefend;
if (state.player.buffs["defend"] === 0) {
delete state.player.buffs["defend"];
}
}
const damageReduce = state.player.buffs["damageReduce"] ?? 0;
if (damageReduce > 0 && actualDamage > 0) {
actualDamage = Math.max(0, actualDamage - damageReduce);
}
if (actualDamage > 0) {
state.player.hp = Math.max(0, state.player.hp - actualDamage);
state.player.damageTakenThisTurn += actualDamage;
state.player.damagedThisTurn = true;
}
if (blockedByDefend > 0 && defendStacks - blockedByDefend <= 0) {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive && enemy.buffs["defend"] !== undefined) {
// Not relevant for player, skip
}
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: state.player.hp <= 0,
};
}
const enemy = state.enemies[targetKey];
if (!enemy || !enemy.isAlive) {
return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
}
const defendStacks = enemy.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
enemy.buffs["defend"] = defendStacks - blockedByDefend;
if (enemy.buffs["defend"] === 0) {
delete enemy.buffs["defend"];
}
if (defendStacks > 0 && defendStacks - blockedByDefend <= 0) {
enemy.hadDefendBroken = true;
}
}
if (actualDamage > 0) {
enemy.hp = Math.max(0, enemy.hp - actualDamage);
if (enemy.hp <= 0) {
enemy.isAlive = false;
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: !enemy.isAlive,
};
}
export function applyDefend(
targetBuffs: BuffTable,
amount: number,
): void {
if (amount <= 0) return;
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
}
export function applyBuff(
buffs: BuffTable,
effectId: string,
timing: EffectTiming,
stacks: number,
): void {
if (stacks <= 0) return;
buffs[effectId] = (buffs[effectId] ?? 0) + stacks;
}
export function removeBuff(buffs: BuffTable, effectId: string, stacks?: number): number {
const current = buffs[effectId] ?? 0;
if (stacks === undefined || stacks >= current) {
delete buffs[effectId];
return current;
}
buffs[effectId] = current - stacks;
return stacks;
}
export function updateBuffs(buffs: BuffTable): void {
const toDelete: string[] = [];
const toDecrement: string[] = [];
for (const [effectId] of Object.entries(buffs)) {
const effectData = getEffectData(effectId);
if (!effectData) continue;
switch (effectData.timing) {
case "temporary":
toDelete.push(effectId);
break;
case "lingering":
toDecrement.push(effectId);
break;
}
}
for (const id of toDelete) {
delete buffs[id];
}
for (const id of toDecrement) {
buffs[id] = (buffs[id] ?? 0) - 1;
if (buffs[id] <= 0) {
delete buffs[id];
}
}
}
export type ResolveEffectContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export function resolveEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectData,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state } = ctx;
const timing = effect.timing;
switch (timing) {
case "instant":
resolveInstantEffect(ctx, target, effect, stacks, sourceKey, sourceCardId);
break;
case "posture":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "temporary":
case "lingering":
case "permanent":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "card":
addStatusCardToDiscard(state, effect.id, stacks);
break;
case "cardDraw":
addStatusCardToDrawPile(state, effect.id, stacks);
break;
case "cardHand":
addStatusCardToHand(state, effect.id, stacks);
break;
case "item":
case "itemUntilPlayed":
applyItemBuff(state, effect.id, timing, stacks, sourceCardId);
break;
}
}
function applyBuffToTarget(
state: CombatState,
target: EffectTarget,
effectId: string,
stacks: number,
sourceKey?: "player" | string,
): void {
if (target === "self") {
if (sourceKey === "player") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (sourceKey && state.enemies[sourceKey]) {
applyBuff(state.enemies[sourceKey].buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
}
} else if (target === "player" || target === "team") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (target === "target" || target === "all" || target === "random") {
// For attack/defend effects, these are handled by resolveInstantEffect
}
}
function resolveInstantEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectData,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state, rng } = ctx;
switch (effect.id) {
case "attack": {
const damageAmount = stacks;
if (target === "all") {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive) {
applyDamage(state, enemyId, damageAmount, sourceKey);
}
}
} else if (target === "random") {
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
if (aliveEnemies.length > 0) {
const targetId = aliveEnemies[rng.nextInt(aliveEnemies.length)];
applyDamage(state, targetId, damageAmount, sourceKey);
}
} else if (target === "target") {
if (sourceKey && sourceKey !== "player" && state.enemies[sourceKey]?.isAlive) {
applyDamage(state, "player", damageAmount, sourceKey);
}
}
break;
}
case "defend": {
if (target === "self" && sourceKey === "player") {
applyDefend(state.player.buffs, stacks);
} else if (target === "self" && sourceKey && state.enemies[sourceKey]) {
applyDefend(state.enemies[sourceKey].buffs, stacks);
}
break;
}
case "draw": {
drawCardsToHand(state.player.deck, stacks);
break;
}
case "gainEnergy": {
state.player.energy += stacks;
break;
}
case "removeWound": {
removeWoundCards(state.player.deck, stacks);
break;
}
case "tailSting": {
applyDamage(state, "player", stacks, sourceKey);
break;
}
case "rollDamage": {
const rollStacks = sourceKey && sourceKey !== "player"
? state.enemies[sourceKey]?.buffs["roll"] ?? 0
: 0;
if (rollStacks >= 10) {
const damageFromRoll = Math.floor(rollStacks / 10) * 10;
applyDamage(state, "player", Math.floor(damageFromRoll / 10), sourceKey);
removeBuff(state.enemies[sourceKey!].buffs, "roll", damageFromRoll);
}
break;
}
case "crossbow": {
break;
}
case "discard": {
break;
}
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy": {
break;
}
case "drawChoice":
case "transformRandom": {
break;
}
default:
break;
}
}
function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.discardPile.push(card.id);
}
}
function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.drawPile.push(card.id);
}
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find(c => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.hand.push(card.id);
}
}
function applyItemBuff(
state: CombatState,
effectId: string,
timing: EffectTiming,
stacks: number,
sourceCardId?: string,
): void {
if (!sourceCardId) return;
const card = state.player.deck.cards[sourceCardId];
if (!card || !card.sourceItemId) return;
const itemBuff: ItemBuff = {
effectId,
stacks,
timing,
sourceItemId: card.sourceItemId,
targetItemId: card.sourceItemId,
};
state.player.itemBuffs.push(itemBuff);
}
function removeWoundCards(deck: PlayerDeck, count: number): void {
let removed = 0;
for (let i = deck.drawPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.drawPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.drawPile[i]];
deck.drawPile.splice(i, 1);
removed++;
}
}
for (let i = deck.discardPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.discardPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.discardPile[i]];
deck.discardPile.splice(i, 1);
removed++;
}
}
}
export function resolveCardEffects(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): void {
const { state } = ctx;
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return;
const sourceKey: "player" | string = "player";
const effects = card.itemData.onPlay as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (target === "target") {
if (targetEnemyId && state.enemies[targetEnemyId]?.isAlive) {
if (effect.id === "attack") {
const actualDamage = getModifiedAttackDamage(state, cardId, stacks);
applyDamage(state, targetEnemyId, actualDamage, "player");
continue;
}
}
}
resolveEffect(ctx, target, effect, stacks, sourceKey, cardId);
}
}
export function getModifiedAttackDamage(
state: CombatState,
cardId: string,
baseDamage: number,
): number {
let damage = baseDamage;
const attackBuff = state.player.itemBuffs
.filter((b) => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
.filter((b) => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
damage += attackBuff;
return Math.max(0, damage);
}
export function getModifiedDefendAmount(
state: CombatState,
cardId: string,
baseDefend: number,
): number {
let defend = baseDefend;
const defendBuff = state.player.itemBuffs
.filter((b) => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
.filter((b) => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
defend += defendBuff;
return Math.max(0, defend);
}
export function canPlayCard(
state: CombatState,
cardId: string,
): { canPlay: boolean; reason?: string } {
const card = state.player.deck.cards[cardId];
if (!card) return { canPlay: false, reason: "卡牌不存在" };
if (!card.itemData) return { canPlay: false, reason: "状态牌不可打出" };
const handIdx = state.player.deck.hand.indexOf(cardId);
if (handIdx < 0) return { canPlay: false, reason: "卡牌不在手牌中" };
if (card.itemData.costType === "energy") {
if (state.player.energy < card.itemData.costCount) {
return { canPlay: false, reason: "能量不足" };
}
}
return { canPlay: true };
}
export function playCard(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): { success: boolean; reason?: string } {
const { state } = ctx;
const check = canPlayCard(state, cardId);
if (!check.canPlay) return { success: false, reason: check.reason };
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return { success: false, reason: "卡牌无效" };
if (card.itemData.costType === "energy") {
state.player.energy -= card.itemData.costCount;
}
resolveCardEffects(ctx, cardId, targetEnemyId);
if (card.itemData.costType === "uses") {
exhaustCard(state.player.deck, cardId);
} else {
discardCard(state.player.deck, cardId);
}
expireItemBuffsOnCardPlayed(state, cardId);
return { success: true };
}
function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
const card = state.player.deck.cards[cardId];
if (!card || !card.sourceItemId) return;
state.player.itemBuffs = state.player.itemBuffs.filter((buff) => {
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
return false;
}
return true;
});
}
export function areAllEnemiesDead(state: CombatState): boolean {
return state.enemyOrder.every(id => !state.enemies[id].isAlive);
}
export function isPlayerDead(state: CombatState): boolean {
return state.player.hp <= 0;
}
@@ -0,0 +1,74 @@
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectData,
EffectTarget,
EffectTiming,
EncounterData,
EnemyIntentData,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from "./types";
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
reshuffleDiscardIntoDraw,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
export {
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
} from "./effects";
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from "./triggers";
export {
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
} from "./triggers";
export { prompts } from "./prompts";
export { runCombat } from "./procedure";
@@ -0,0 +1,309 @@
import type { IGameContext } from "@/core/game";
import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types";
import type { CombatTriggerRegistry, TriggerContext } from "./triggers";
import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger, dispatchCardDrawnTrigger } from "./triggers";
import { prompts } from "./prompts";
import {
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
import {
applyDamage,
applyDefend,
updateBuffs,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
resolveCardEffects,
removeBuff,
} from "./effects";
export async function runCombat(
game: CombatGameContext,
): Promise<CombatResult> {
const triggerRegistry = createCombatTriggerRegistry();
await game.produceAsync(async (state) => {
state.phase = "playerTurn";
state.player.energy = state.player.maxEnergy;
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
});
while (true) {
const currentState = game.value;
if (currentState.result) {
return currentState.result;
}
if (currentState.phase === "playerTurn") {
await runPlayerTurn(game, triggerRegistry);
} else if (currentState.phase === "enemyTurn") {
await runEnemyTurn(game, triggerRegistry);
} else {
break;
}
if (isPlayerDead(game.value)) {
await game.produceAsync((state) => {
state.result = "defeat";
state.phase = "combatEnd";
});
return "defeat";
}
if (areAllEnemiesDead(game.value)) {
await game.produceAsync((state) => {
state.result = "victory";
state.phase = "combatEnd";
state.loot = generateLoot(state);
});
return "victory";
}
}
return game.value.result ?? "defeat";
}
async function runPlayerTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const triggerCtx = createTriggerContext(game);
await game.produceAsync(async (state) => {
updateBuffs(state.player.buffs);
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
});
while (game.value.phase === "playerTurn") {
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
prompts.playCard,
(cardId, targetId) => {
const state = game.value;
if (!cardId) throw "请选择卡牌";
const check = canPlayCard(state, cardId);
if (!check.canPlay) throw check.reason ?? "无法打出";
const card = state.player.deck.cards[cardId];
if (card?.itemData?.targetType === "single") {
const aliveEnemies = state.enemyOrder.filter((id) => state.enemies[id].isAlive);
if (!targetId && aliveEnemies.length > 0) {
throw "请指定目标";
}
if (targetId && !state.enemies[targetId]?.isAlive) {
throw "目标无效";
}
}
return { action: "play" as const, cardId, targetId };
},
"player"
);
if (action.action === "play" && action.cardId) {
const ctx = createEffectContext(game);
await game.produceAsync(async (state) => {
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
});
if (areAllEnemiesDead(game.value)) {
return;
}
if (isPlayerDead(game.value)) {
return;
}
continue;
}
break;
}
await game.prompt<{ action: "end" }>(
prompts.endTurn,
() => {
return { action: "end" as const };
},
"player"
);
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
await game.produceAsync(async (state) => {
for (const cardId of [...state.player.deck.hand]) {
state.player.cardsDiscardedThisTurn++;
}
discardHand(state.player.deck);
});
await game.produceAsync(async (state) => {
if (state.player.deck.drawPile.length === 0) {
reshuffleWithFatigue(state);
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
}
const drawn = drawCardsToHand(state.player.deck, 5);
for (const cardId of drawn) {
dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry);
}
state.player.energy = state.player.maxEnergy;
});
await game.produceAsync(async (state) => {
state.phase = "enemyTurn";
});
}
async function runEnemyTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const state = game.value;
await game.produceAsync(async (state) => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
updateBuffs(enemy.buffs);
}
});
const triggerCtx = createTriggerContext(game);
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
}
await game.produceAsync(async (state) => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
const intent = getEnemyCurrentIntent(enemy);
if (!intent) continue;
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (effect.id === "attack") {
let damage = stacks;
damage = dispatchOutgoingDamageTrigger(createTriggerContext(game), enemyId, damage, triggerRegistry);
damage = dispatchIncomingDamageTrigger(createTriggerContext(game), "player", damage, triggerRegistry);
const result = applyDamage(state, "player", damage, enemyId);
if (result.damageDealt > 0) {
dispatchDamageTrigger(createTriggerContext(game), "player", result.damageDealt, triggerRegistry);
}
} else if (effect.id === "defend") {
if (target === "self") {
applyDefend(enemy.buffs, stacks);
}
} else {
resolveEnemyEffect(state, enemyId, target, effect, stacks);
}
}
advanceEnemyIntent(enemy);
}
});
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
}
await game.produceAsync(async (state) => {
state.phase = "playerTurn";
state.turnNumber++;
});
}
function resolveEnemyEffect(
state: CombatState,
enemyId: string,
target: string,
effect: { id: string; timing: string },
stacks: number,
): void {
switch (effect.id) {
case "spike":
case "venom":
case "curse":
case "aim":
case "roll":
case "vultureEye":
case "tailSting":
case "energyDrain":
case "molt":
case "storm":
case "static":
case "charge":
case "discard":
state.enemies[enemyId].buffs[effect.id] = (state.enemies[enemyId].buffs[effect.id] ?? 0) + stacks;
break;
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy":
break;
default:
break;
}
}
function reshuffleWithFatigue(state: CombatState): void {
if (state.player.deck.discardPile.length === 0) return;
state.player.deck.drawPile.push(...state.player.deck.discardPile);
state.player.deck.discardPile = [];
addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.player.fatigueAddedCount });
state.player.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE;
for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.player.deck.drawPile[i], state.player.deck.drawPile[j]] = [state.player.deck.drawPile[j], state.player.deck.drawPile[i]];
}
}
function createTriggerContext(game: CombatGameContext): TriggerContext {
return {
state: game.value,
rng: game._rng,
};
}
function createEffectContext(game: CombatGameContext) {
return {
state: game.value,
rng: game._rng,
};
}
function generateLoot(state: CombatState): CombatState["loot"] {
const loot: CombatState["loot"] = [];
let totalGold = 0;
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
totalGold += Math.floor(enemy.maxHp * 0.5);
}
if (totalGold > 0) {
loot.push({ type: "gold", amount: totalGold });
}
return loot;
}
@@ -0,0 +1,12 @@
import { createPromptDef } from "@/core/game";
export const prompts = {
playCard: createPromptDef<[string, string?]>(
"play-card <cardId:string> [targetId:string]",
"选择卡牌并指定目标"
),
endTurn: createPromptDef<[]>(
"end-turn",
"结束回合"
),
};
@@ -0,0 +1,254 @@
import type { GridInventory } from "../grid-inventory/types";
import type { GameItemMeta, PlayerState } from "../progress/types";
import type { PlayerDeck } from "../deck/types";
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
import { enemyDesertData, effectDesertData, cardDesertData } from "../data";
import { createRNG } from "@/utils/rng";
import type {
BuffTable,
CombatState,
CombatPhase,
EffectData,
EffectTiming,
EnemyIntentData,
EnemyState,
EncounterData,
PlayerCombatState,
ItemBuff,
LootEntry,
} from "./types";
const INITIAL_HAND_SIZE = 5;
const DEFAULT_MAX_ENERGY = 3;
const FATIGUE_CARDS_PER_SHUFFLE = 2;
export function createEnemyInstance(
templateId: string,
hp: number,
initBuffs: [EffectData, number][],
idCounter: { value: number },
): EnemyState {
idCounter.value++;
const id = `enemy-${idCounter.value}`;
const maxHp = hp;
const currentHp = hp;
const buffs: BuffTable = {};
for (const [effect, stacks] of initBuffs) {
buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks;
}
const intentData = buildIntentLookup(templateId);
const initialIntent = findInitialIntent(templateId);
return {
id,
templateId,
hp: currentHp,
maxHp,
buffs,
currentIntentId: initialIntent ?? "",
intentData,
isAlive: true,
hadDefendBroken: false,
};
}
function findInitialIntent(enemyTemplateId: string): string | undefined {
for (const row of enemyDesertData) {
if (row.enemy === enemyTemplateId && row.initialIntent) {
return row.intentId;
}
}
return undefined;
}
function buildIntentLookup(enemyTemplateId: string): Record<string, EnemyIntentData> {
const lookup: Record<string, EnemyIntentData> = {};
for (const row of enemyDesertData) {
if (row.enemy === enemyTemplateId) {
lookup[row.intentId] = row as unknown as EnemyIntentData;
}
}
return lookup;
}
export function createPlayerCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
): PlayerCombatState {
const deck = generateDeckFromInventory(inventory);
return {
hp: playerState.currentHp,
maxHp: playerState.maxHp,
energy: DEFAULT_MAX_ENERGY,
maxEnergy: DEFAULT_MAX_ENERGY,
buffs: {},
deck,
damageTakenThisTurn: 0,
damagedThisTurn: false,
cardsDiscardedThisTurn: 0,
itemBuffs: [],
fatigueAddedCount: 0,
};
}
export function createCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
encounter: EncounterData,
): CombatState {
const idCounter = { value: 0 };
const player = createPlayerCombatState(playerState, inventory);
const enemies: Record<string, EnemyState> = {};
const enemyOrder: string[] = [];
const enemyTemplateData: Record<string, EnemyIntentData> = {};
for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) {
const [enemyId, hp, bonusHp] = enemyEntry;
const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId);
const initBuffs: [EffectData, number][] = [];
if (enemyRow) {
for (const [effect, stacks] of enemyRow.initBuffs as unknown as [EffectData, number][]) {
initBuffs.push([effect, stacks]);
}
}
const totalHp = hp + bonusHp;
const enemyInstance = createEnemyInstance(
enemyId,
totalHp,
initBuffs,
idCounter,
);
enemies[enemyInstance.id] = enemyInstance;
enemyOrder.push(enemyInstance.id);
enemyTemplateData[enemyInstance.templateId] = enemyRow as unknown as EnemyIntentData;
}
shuffleDeck(player.deck.drawPile, createRNG(0));
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
return {
enemies,
enemyOrder,
player,
phase: "playerTurn" as CombatPhase,
turnNumber: 1,
result: null,
loot: [],
enemyTemplateData,
};
}
export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
const drawn: string[] = [];
for (let i = 0; i < count; i++) {
if (deck.drawPile.length === 0) {
reshuffleDiscardIntoDraw(deck);
}
if (deck.drawPile.length === 0) break;
const cardId = deck.drawPile.shift()!;
deck.hand.push(cardId);
drawn.push(cardId);
}
return drawn;
}
export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
if (deck.discardPile.length === 0) return;
deck.drawPile.push(...deck.discardPile);
deck.discardPile = [];
for (let i = deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck.drawPile[i], deck.drawPile[j]] = [deck.drawPile[j], deck.drawPile[i]];
}
}
export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number {
let added = 0;
const fatigueDef = cardDesertData.find((c) => c.id === "fatigue");
if (!fatigueDef) return 0;
for (let i = 0; i < count; i++) {
fatigueCounter.value++;
const card = createStatusCard(
`fatigue-${fatigueCounter.value}`,
fatigueDef.name,
fatigueDef.desc,
);
deck.cards[card.id] = card;
deck.drawPile.push(card.id);
added++;
}
return added;
}
export function discardHand(deck: PlayerDeck): void {
const handCards = [...deck.hand];
deck.discardPile.push(...handCards);
deck.hand = [];
}
export function discardCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.discardPile.push(cardId);
}
}
export function exhaustCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.exhaustPile.push(cardId);
}
}
export function getEnemyCurrentIntent(enemy: EnemyState): EnemyIntentData | undefined {
return enemy.intentData[enemy.currentIntentId];
}
export function advanceEnemyIntent(enemy: EnemyState): void {
const current = getEnemyCurrentIntent(enemy);
if (!current) return;
if (enemy.hadDefendBroken && current.brokenIntent.length > 0) {
const idx = Math.floor(Math.random() * current.brokenIntent.length);
enemy.currentIntentId = current.brokenIntent[idx];
enemy.hadDefendBroken = false;
return;
}
if (current.nextIntents.length > 0) {
const idx = Math.floor(Math.random() * current.nextIntents.length);
enemy.currentIntentId = current.nextIntents[idx];
return;
}
enemy.currentIntentId = current.intentId;
}
function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void {
for (let i = drawPile.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[drawPile[i], drawPile[j]] = [drawPile[j], drawPile[i]];
}
}
export function getEffectTiming(effectId: string): EffectTiming | undefined {
const effect = effectDesertData.find(e => e.id === effectId);
return effect?.timing;
}
export function getEffectData(effectId: string): EffectData | undefined {
return effectDesertData.find(e => e.id === effectId) as EffectData | undefined;
}
export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE };
@@ -0,0 +1,346 @@
import { cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
import { applyDamage, removeBuff } from "./effects";
export type TriggerContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export type BuffTriggerBehavior = {
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
onAttacked?: (ctx: TriggerContext, attackerKey: "player" | string, defenderKey: "player" | string, damage: number, stacks: number) => number;
onDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => void;
modifyOutgoingDamage?: (ctx: TriggerContext, sourceKey: "player" | string, damage: number, stacks: number) => number;
modifyIncomingDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => number;
onShuffle?: (ctx: TriggerContext, stacks: number) => void;
onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
};
export type TriggerEvent =
| "onTurnStart"
| "onTurnEnd"
| "onAttacked"
| "onDamage"
| "modifyOutgoingDamage"
| "modifyIncomingDamage"
| "onShuffle"
| "onCardPlayed"
| "onCardDiscarded"
| "onCardDrawn";
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
return {
spike: {
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
const { state } = ctx;
applyDamage(state, attackerKey, stacks, _defenderKey);
return damage;
},
},
aim: {
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
if (stacks > 0) return damage * 2;
return damage;
},
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
const entity = targetKey === "player" ? null : state.enemies[targetKey];
if (entity) {
const loss = Math.min(stacks, damage);
removeBuff(entity.buffs, "aim", loss);
}
},
},
charge: {
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
if (stacks > 0) {
return damage * 2;
}
return damage;
},
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
if (stacks > 0) {
return damage * 2;
}
return damage;
},
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
const entity = targetKey === "player"
? { buffs: state.player.buffs } as { buffs: BuffTable }
: state.enemies[targetKey];
if (entity) {
const loss = Math.min(stacks, damage);
removeBuff(entity.buffs, "charge", loss);
}
},
},
roll: {
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
if (stacks >= 10) {
const { state } = ctx;
const entity = sourceKey === "player"
? { buffs: state.player.buffs } as { buffs: BuffTable }
: state.enemies[sourceKey];
if (entity) {
const spendable = Math.floor(stacks / 10) * 10;
const bonusDamage = Math.floor(spendable / 10);
removeBuff(entity.buffs, "roll", spendable);
return damage + bonusDamage;
}
}
return damage;
},
},
tailSting: {
onTurnEnd(ctx, entityKey, stacks) {
const { state } = ctx;
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
applyDamage(state, "player", stacks, entityKey);
}
},
},
energyDrain: {
onDamage(ctx, targetKey, _damage, _stacks) {
const { state } = ctx;
if (targetKey === "player" && state.player.damagedThisTurn === false) {
// This is the first damage; mark it.
// actual energy drain happens in onTurnStart check
}
},
onTurnStart(ctx, entityKey, _stacks) {
// energyDrain: first damage each turn loses 1 energy
// We just mark that the enemy has this; actual drain is in onDamage
},
},
molt: {
onDamage(ctx, targetKey, _damage, _stacks) {
const { state } = ctx;
if (targetKey !== "player") {
const enemy = state.enemies[targetKey];
if (enemy && enemy.isAlive) {
const moltStacks = enemy.buffs["molt"] ?? 0;
if (moltStacks >= enemy.maxHp) {
enemy.isAlive = false;
}
}
}
},
},
storm: {
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
const { state } = ctx;
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
addStatusCardToHand(state, "static", 1);
}
return damage;
},
},
vultureEye: {
onDamage(ctx, targetKey, damage, stacks) {
const { state } = ctx;
if (targetKey === "player" && damage > 0) {
const vultureEnemies = state.enemyOrder.filter(
(id) => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
);
if (vultureEnemies.length > 0) {
for (const vultureId of vultureEnemies) {
const vulture = state.enemies[vultureId];
const intent = vulture.intentData["attack"];
if (intent) {
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
if (entry[0] === "player" && entry[1].id === "attack") {
applyDamage(state, "player", entry[2], vultureId);
}
}
}
}
}
}
},
},
venom: {
onCardDiscarded(ctx, cardId, stacks) {
const { state } = ctx;
state.player.cardsDiscardedThisTurn++;
const venomCards = state.player.deck.hand.filter((id) => {
const card = state.player.deck.cards[id];
return card && card.itemData === null && card.displayName === "蛇毒";
});
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
applyDamage(state, "player", 6, undefined);
}
},
},
static: {
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
if (targetKey === "player") {
return damage + stacks;
}
return damage;
},
},
discard: {
onShuffle(ctx, stacks) {
// Bandit: shuffle discards random item cards
// Simplified: mark the effect for the procedure to handle
},
},
curse: {
onDamage(ctx, targetKey, damage, stacks) {
// Curse: when attacked, item attack -1 until card from that item is discarded
// This is handled via itemBuffs in effects
},
},
};
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = cardDesertData.find((c) => c.id === effectId);
if (!cardDef) return;
for (let i = 0; i < count; i++) {
const cardId = `status-${effectId}-${Date.now()}-${i}`;
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
state.player.deck.cards[card.id] = card;
state.player.deck.hand.push(card.id);
}
}
export function dispatchTrigger(
ctx: TriggerContext,
event: TriggerEvent,
entityKey: "player" | string,
registry: CombatTriggerRegistry,
): void {
const buffs = entityKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[entityKey]?.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior) continue;
const handler = behavior[event];
if (handler) {
handler(ctx, entityKey, stacks);
}
}
}
export function dispatchAttackedTrigger(
ctx: TriggerContext,
attackerKey: "player" | string,
defenderKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = defenderKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[defenderKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onAttacked) continue;
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchDamageTrigger(
ctx: TriggerContext,
targetKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): void {
const buffs = targetKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[targetKey]?.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onDamage) continue;
behavior.onDamage(ctx, targetKey, damage, stacks);
}
}
export function dispatchOutgoingDamageTrigger(
ctx: TriggerContext,
sourceKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = sourceKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[sourceKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.modifyOutgoingDamage) continue;
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchIncomingDamageTrigger(
ctx: TriggerContext,
targetKey: "player" | string,
damage: number,
registry: CombatTriggerRegistry,
): number {
const buffs = targetKey === "player"
? ctx.state.player.buffs
: ctx.state.enemies[targetKey]?.buffs;
if (!buffs) return damage;
let modifiedDamage = damage;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.modifyIncomingDamage) continue;
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
}
return modifiedDamage;
}
export function dispatchShuffleTrigger(
ctx: TriggerContext,
registry: CombatTriggerRegistry,
): void {
for (const enemyId of ctx.state.enemyOrder) {
const enemy = ctx.state.enemies[enemyId];
if (!enemy.isAlive) continue;
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
const behavior = registry[buffId];
if (!behavior?.onShuffle) continue;
behavior.onShuffle(ctx, stacks);
}
}
}
export function dispatchCardDrawnTrigger(
ctx: TriggerContext,
cardId: string,
registry: CombatTriggerRegistry,
): void {
const buffs = ctx.state.player.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onCardDrawn) continue;
behavior.onCardDrawn(ctx, cardId, stacks);
}
}
@@ -0,0 +1,97 @@
import type { PlayerDeck, GameCard } from "../deck/types";
import type { PlayerState } from "../progress/types";
export type BuffTable = Record<string, number>;
export type EffectTiming = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed";
export type EffectData = {
readonly id: string;
readonly timing: EffectTiming;
};
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
export type EnemyIntentData = {
readonly enemy: string;
readonly intentId: string;
readonly initialIntent: boolean;
readonly nextIntents: readonly string[];
readonly brokenIntent: readonly string[];
readonly initBuffs: readonly [EffectData, number];
readonly effects: readonly ["self" | "player" | "team", EffectData, number];
};
export type EncounterData = {
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly name: string;
readonly description: string;
readonly enemies: readonly [string, number, number];
readonly dialogue: string;
};
export type ItemBuff = {
effectId: string;
stacks: number;
timing: EffectTiming;
sourceItemId: string;
targetItemId: string;
};
export type EnemyState = {
id: string;
templateId: string;
hp: number;
maxHp: number;
buffs: BuffTable;
currentIntentId: string;
intentData: Record<string, EnemyIntentData>;
isAlive: boolean;
hadDefendBroken: boolean;
};
export type PlayerCombatState = {
hp: number;
maxHp: number;
energy: number;
maxEnergy: number;
buffs: BuffTable;
deck: PlayerDeck;
damageTakenThisTurn: number;
damagedThisTurn: boolean;
cardsDiscardedThisTurn: number;
itemBuffs: ItemBuff[];
fatigueAddedCount: number;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry = {
type: "gold" | "item" | "relic";
amount?: number;
itemId?: string;
};
export type CombatState = {
enemies: Record<string, EnemyState>;
enemyOrder: string[];
player: PlayerCombatState;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
enemyTemplateData: Record<string, EnemyIntentData>;
};
export type CombatEffectEntry = [EffectTarget, EffectData, number];
export type CombatEntity = {
buffs: BuffTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
@@ -0,0 +1,188 @@
import type { CellKey, GridInventory, InventoryItem } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import { createRegion, createRegionAxis } from '@/core/region';
import type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
import { cardDesertData } from '../data';
/**
* Generates a unique card ID for a cell within an item.
*/
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
/**
* Collects all cell keys from an item's shape in a deterministic order.
* Iterates the shape grid row by row, left to right, top to bottom.
*/
function getItemCells(item: InventoryItem<GameItemMeta>): CellKey[] {
const cells: CellKey[] = [];
const { shape, transform } = item;
const { grid } = shape;
const { offset, rotation, flipX, flipY } = transform;
// Track local dimensions (may swap on rotation)
let localWidth = grid[0]?.length || 1;
let localHeight = grid.length;
for (let gy = 0; gy < grid.length; gy++) {
for (let gx = 0; gx < grid[gy].length; gx++) {
if (!grid[gy][gx]) continue;
// Start from grid coordinates
let x = gx;
let y = gy;
// Apply rotation (90 degree increments, clockwise)
const rotTimes = ((rotation % 4) + 4) % 4;
for (let r = 0; r < rotTimes; r++) {
const newX = localHeight - 1 - y;
const newY = x;
x = newX;
y = newY;
// Swap dimensions for next iteration
const tmp = localWidth;
localWidth = localHeight;
localHeight = tmp;
}
// Reset local dimensions for fresh computation per cell
localWidth = grid[0]?.length || 1;
localHeight = grid.length;
// Apply flips
if (flipX) {
x = -x;
}
if (flipY) {
y = -y;
}
// Apply offset
const finalX = x + offset.x;
const finalY = y + offset.y;
cells.push(`${finalX},${finalY}`);
}
}
return cells;
}
/**
* Creates a single card from an inventory item.
*/
function createItemCard(
itemId: string,
itemData: GameItemMeta['itemData'],
cellKey: CellKey,
cellIndex: number
): GameCard {
const cardId = generateCardId(itemId, cellIndex);
const cardData = cardDesertData.find(c => c.id === itemData.card.id);
return {
id: cardId,
regionId: '',
position: [],
sourceItemId: itemId,
itemData: cardData ?? null,
cellKey,
displayName: cardData?.name ?? itemData.name,
description: cardData?.desc ?? '',
};
}
/**
* Creates a status card that does not correspond to any inventory item.
* Status cards represent temporary effects like wounds, stuns, etc.
*/
function createStatusCard(
id: string,
displayName: string,
description: string
): GameCard {
return {
id,
regionId: '',
position: [],
sourceItemId: null,
itemData: null,
cellKey: null,
displayName,
description,
};
}
/**
* Generates a complete player deck from the current inventory state.
*/
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
const drawPile: string[] = [];
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
// Generate one card per occupied cell in the item's shape
const cellCount = item.shape.count;
const cells = getItemCells(item);
for (let i = 0; i < cellCount; i++) {
const cellKey = cells[i] ?? `${i},0`;
const card = createItemCard(item.id, itemData, cellKey, i);
cards[card.id] = card;
drawPile.push(card.id);
}
}
return {
cards,
drawPile,
hand: [],
discardPile: [],
exhaustPile: [],
};
}
/**
* Creates region definitions for deck management.
*/
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', [
createRegionAxis('index', 0, 0),
]),
hand: createRegion('hand', [
createRegionAxis('index', 0, 0),
]),
discardPile: createRegion('discardPile', [
createRegionAxis('index', 0, 0),
]),
exhaustPile: createRegion('exhaustPile', [
createRegionAxis('index', 0, 0),
]),
};
}
/**
* Creates an empty player deck structure.
*/
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
drawPile: [],
hand: [],
discardPile: [],
exhaustPile: [],
};
}
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
};
@@ -0,0 +1,8 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from './factory';
@@ -0,0 +1,74 @@
import type { Part } from '@/core/part';
import type { Region } from '@/core/region';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import type { CardDesert } from '../data/cardDesert.csv';
import type { CellKey } from '../grid-inventory/types';
/**
* Metadata for a game card.
* Bridges inventory item data with the card system.
*/
export interface GameCardMeta {
/**
* Source item instance ID that this card was generated from.
* `null` for status cards (e.g. wound, stun) that don't correspond to an inventory item.
*/
sourceItemId: string | null;
/**
* Original card data from cardDesert.csv. `null` for status cards not in the CSV.
*/
itemData: CardDesert | null;
/**
* The cell key ("x,y") this card represents within the source item's shape.
* `null` for status cards.
*/
cellKey: CellKey | null;
/**
* Display name of the card.
* For item cards: derived from itemData.name.
* For status cards: custom name (e.g. "伤口", "眩晕").
*/
displayName: string;
/**
* Card description / ability text.
* For item cards: derived from itemData.desc.
* For status cards: custom description.
*/
description: string;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
*/
export type GameCard = Part<GameCardMeta>;
/**
* Player deck structure containing card pools.
*/
export interface PlayerDeck {
/** All cards indexed by ID */
cards: Record<string, GameCard>;
/** Card IDs in the draw pile */
drawPile: string[];
/** Card IDs in the player's hand */
hand: string[];
/** Card IDs in the discard pile */
discardPile: string[];
/** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: string[];
}
/**
* Region structure for deck management.
*/
export interface DeckRegions {
/** Draw pile region */
drawPile: Region;
/** Hand region */
hand: Region;
/** Discard pile region */
discardPile: Region;
/** Exhaust pile region */
exhaustPile: Region;
}
@@ -0,0 +1,13 @@
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
export {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './transform';
@@ -0,0 +1,233 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
import {
checkBoardCollision,
checkBounds,
flipXTransform,
flipYTransform,
rotateTransform,
transformShape,
} from '../utils/shape-collision';
import type { CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
/**
* Creates a new empty grid inventory.
* Note: When used inside `.produce()`, call this before returning the draft.
*/
export function createGridInventory<TMeta = Record<string, unknown>>(width: number, height: number): GridInventory<TMeta> {
return {
width,
height,
items: new Map<string, InventoryItem<TMeta>>(),
occupiedCells: new Set<CellKey>(),
};
}
/**
* Builds a Set of occupied cell keys from a shape and transform.
*/
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map(c => `${c.x},${c.y}` as CellKey));
}
/**
* Validates whether an item can be placed at the given transform.
* Checks bounds and collision with all other items.
*/
export function validatePlacement<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>['shape'],
transform: Transform2D
): PlacementResult {
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: '超出边界' };
}
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: '与已有物品重叠' };
}
return { valid: true };
}
/**
* Places an item onto the grid.
* **Mutates directly** — call inside a `.produce()` callback.
* Does not validate; call `validatePlacement` first.
*/
export function placeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, item: InventoryItem<TMeta>): void {
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
}
inventory.items.set(item.id, item);
}
/**
* Removes an item from the grid by its ID.
* **Mutates directly** — call inside a `.produce()` callback.
*/
export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, itemId: string): void {
const item = inventory.items.get(itemId);
if (!item) return;
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.delete(cellKey);
}
inventory.items.delete(itemId);
}
/**
* Moves an item to a new position with a new transform.
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function moveItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
newTransform: Transform2D
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
// Temporarily remove item's cells for validation
const oldCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of oldCells) {
inventory.occupiedCells.delete(cellKey);
}
// Validate new position
const validation = validatePlacement(inventory, item.shape, newTransform);
if (!validation.valid) {
// Restore old cells
for (const cellKey of oldCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: false, reason: validation.reason };
}
// Apply new transform
item.transform = newTransform;
// Add new cells
const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: true };
}
/**
* Rotates an item by the given degrees (typically 90, 180, or 270).
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function rotateItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
degrees: number
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
const rotatedTransform = rotateTransform(item.transform, degrees);
return moveItem(inventory, itemId, rotatedTransform);
}
/**
* Flips an item horizontally or vertically.
* **Mutates directly** — call inside a `.produce()` callback.
* Validates before applying; returns result indicating success.
*/
export function flipItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
axis: 'x' | 'y'
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
const flippedTransform = axis === 'x'
? flipXTransform(item.transform)
: flipYTransform(item.transform);
return moveItem(inventory, itemId, flippedTransform);
}
/**
* Returns a copy of the occupied cells set.
*/
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>): Set<CellKey> {
return new Set(inventory.occupiedCells);
}
/**
* Finds the item occupying the given cell, if any.
*/
export function getItemAtCell<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
x: number,
y: number
): InventoryItem<TMeta> | undefined {
const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(cellKey)) {
return undefined;
}
for (const item of inventory.items.values()) {
const cells = getShapeCellKeys(item.shape, item.transform);
if (cells.has(cellKey)) {
return item;
}
}
return undefined;
}
/**
* Gets all items adjacent to the given item (orthogonally, not diagonally).
* Returns a Map of itemId -> item for deduplication.
*/
export function getAdjacentItems<TMeta extends Record<string, unknown> = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string
): Map<string, InventoryItem<TMeta>> {
const item = inventory.items.get(itemId);
if (!item) {
return new Map();
}
const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(',').map(Number);
const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey,
`${cx},${cy + 1}` as CellKey,
`${cx},${cy - 1}` as CellKey,
];
for (const neighborKey of neighbors) {
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) {
const [nx, ny] = neighborKey.split(',').map(Number);
const neighborItem = getItemAtCell(inventory, nx, ny);
if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
}
}
}
}
return adjacent;
}
@@ -0,0 +1,56 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
/**
* String key representing a grid cell in "x,y" format.
*/
export type CellKey = `${number},${number}`;
/**
* Simple 2D coordinate for grid cells.
*/
export interface CellCoordinate {
x: number;
y: number;
}
/**
* An item placed on the grid inventory.
* @template TMeta - Optional metadata type for game-specific data
*/
export interface InventoryItem<TMeta = Record<string, unknown>> {
/** Unique item identifier */
id: string;
/** Reference to the item's shape definition */
shape: ParsedShape;
/** Current transformation (position, rotation, flips) */
transform: Transform2D;
/** Optional metadata for game-specific data */
meta?: TMeta;
}
/**
* Result of a placement validation check.
*/
export type PlacementResult = { valid: true } | { valid: false; reason: string };
/**
* Result of a mutation operation (move, rotate, flip).
*/
export type MutationResult = { success: true } | { success: false; reason: string };
/**
* Grid inventory state.
* Designed to be mutated directly inside a `mutative .produce()` callback.
* @template TMeta - Optional metadata type for items
*/
export interface GridInventory<TMeta = Record<string, unknown>> {
/** Board width in cells */
width: number;
/** Board height in cells */
height: number;
/** Map of itemId -> InventoryItem for all placed items */
items: Map<string, InventoryItem<TMeta>>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
}
@@ -0,0 +1,543 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import encounterDesertCsvAccessor, { type EncounterDesert } from '../data/encounterDesert.csv';
import { MapNodeType, MapLayerType } from './types';
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
const encounterDesertCsv = encounterDesertCsvAccessor();
/** Pre-indexed encounters by type */
const encountersByType = buildEncounterIndex();
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
const index = new Map<string, EncounterDesert[]>();
for (const encounter of encounterDesertCsv) {
const type = encounter.type;
if (!index.has(type)) {
index.set(type, []);
}
index.get(type)!.push(encounter);
}
return index;
}
/** Map from MapNodeType to encounter type key */
const NODE_TYPE_TO_ENCOUNTER: Partial<Record<MapNodeType, string>> = {
[MapNodeType.Minion]: 'minion',
[MapNodeType.Elite]: 'elite',
[MapNodeType.Event]: 'event',
[MapNodeType.Camp]: 'camp',
[MapNodeType.Shop]: 'shop',
[MapNodeType.Curio]: 'curio',
};
/** Default map generation configuration */
const DEFAULT_CONFIG: MapGenerationConfig = {
totalLayers: 10,
wildLayerNodeCount: 3,
settlementLayerNodeCount: 4,
wildNodeTypeWeights: {
[MapNodeType.Minion]: 50,
[MapNodeType.Elite]: 25,
[MapNodeType.Event]: 25,
},
};
/**
* Layer structure definition.
* Pattern: Start → Wild → Wild → Settlement → Wild → Wild → Settlement → Wild → Wild → End
*/
const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count: number }> = [
{ layerType: 'start', count: 1 },
{ layerType: MapLayerType.Wild, count: 3 },
{ layerType: MapLayerType.Wild, count: 3 },
{ layerType: MapLayerType.Settlement, count: 4 },
{ layerType: MapLayerType.Wild, count: 3 },
{ layerType: MapLayerType.Wild, count: 3 },
{ layerType: MapLayerType.Settlement, count: 4 },
{ layerType: MapLayerType.Wild, count: 3 },
{ layerType: MapLayerType.Wild, count: 3 },
{ layerType: 'end', count: 1 },
];
/**
* Fisher-Yates shuffle algorithm for unbiased random permutation.
* Mutates the array in place and returns it.
*/
function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Picks a random encounter for the given node type.
* Returns undefined if no matching encounter exists.
*/
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
if (!encounterType) return undefined;
const pool = encountersByType.get(encounterType);
if (!pool || pool.length === 0) return undefined;
return pool[rng.nextInt(pool.length)];
}
/** Total number of layers */
const TOTAL_LAYERS = 10;
/**
* Generates a random point crawl map with layered directional graph.
*
* Structure:
* - 10 layers: Start → Wild×2 → Settlement → Wild×2 → Settlement → Wild×2 → End
* - Wild layers have exactly 3 nodes (minion/elite/event)
* - Settlement layers have exactly 4 nodes (camp/shop/curio + 1 random)
* - Each settlement layer has at least 1 of each: camp, shop, curio
* - Wild nodes connect to 1 wild node or 2 settlement nodes
*
* @param seed Random seed for reproducibility
*/
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
const rng = new Mulberry32RNG(seed ?? Date.now());
const actualSeed = rng.getSeed();
const layers: MapLayer[] = [];
const nodes = new Map<string, MapNode>();
// Pre-generate optimal types for wild layer pairs (layers 1-2, 4-5, 7-8)
const wildPairTypes = new Map<number, MapNodeType[]>();
const wildPairs = [
{ layer1: 1, layer2: 2 },
{ layer1: 4, layer2: 5 },
{ layer1: 7, layer2: 8 },
];
for (const pair of wildPairs) {
const [types1, types2] = generateOptimalWildPair(rng);
wildPairTypes.set(pair.layer1, types1);
wildPairTypes.set(pair.layer2, types2);
}
// Step 1: create layers and nodes
for (let i = 0; i < TOTAL_LAYERS; i++) {
const structure = LAYER_STRUCTURE[i];
const nodeIds: string[] = [];
const layerNodes: MapNode[] = [];
// Pre-generate settlement types if this is a settlement layer
let settlementTypes: MapNodeType[] | undefined;
if (structure.layerType === MapLayerType.Settlement) {
settlementTypes = generateSettlementTypes(rng);
}
for (let j = 0; j < structure.count; j++) {
const id = `node-${i}-${j}`;
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j, settlementTypes, j);
const encounter = pickEncounterForNode(type, rng);
const node: MapNode = {
id,
layerIndex: i,
type,
childIds: [],
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
};
nodes.set(id, node);
nodeIds.push(id);
layerNodes.push(node);
}
layers.push({ index: i, nodeIds, layerType: structure.layerType, nodes: layerNodes });
}
// Step 2: generate edges between consecutive layers
const parentIndex = new Map<string, string[]>();
for (let i = 0; i < TOTAL_LAYERS - 1; i++) {
const sourceLayer = layers[i];
const targetLayer = layers[i + 1];
generateLayerEdges(sourceLayer, targetLayer, nodes, rng);
// Build reverse index: for each target node, record its parents
for (const srcNode of sourceLayer.nodes) {
for (const childId of srcNode.childIds) {
if (!parentIndex.has(childId)) {
parentIndex.set(childId, []);
}
parentIndex.get(childId)!.push(srcNode.id);
}
}
}
return { layers, nodes, seed: actualSeed, parentIndex };
}
/**
* Resolves the node type based on layer type and position.
*/
function resolveNodeType(
layerType: MapLayerType | 'start' | 'end',
_nodeIndex: number,
_layerCount: number,
rng: RNG,
preGeneratedTypes?: MapNodeType[],
nodeIndex?: number,
settlementTypes?: MapNodeType[],
settlementIndex?: number
): MapNodeType {
switch (layerType) {
case 'start':
return MapNodeType.Start;
case 'end':
return MapNodeType.End;
case MapLayerType.Wild:
// Use pre-generated types if available (from optimal pair generation)
if (preGeneratedTypes && nodeIndex !== undefined) {
return preGeneratedTypes[nodeIndex];
}
return pickWildNodeType(rng);
case MapLayerType.Settlement:
// Use pre-generated settlement types if available
if (settlementTypes && settlementIndex !== undefined) {
return settlementTypes[settlementIndex];
}
return MapNodeType.Camp; // fallback
default:
return MapNodeType.Minion;
}
}
/**
* Picks a random type for a wild node based on configured weights.
* Default: minion: 50%, elite: 25%, event: 25%
*/
function pickWildNodeType(rng: RNG): MapNodeType {
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
const roll = rng.nextInt(100);
if (roll < weights[MapNodeType.Minion]) return MapNodeType.Minion;
if (roll < weights[MapNodeType.Minion] + weights[MapNodeType.Elite]) return MapNodeType.Elite;
return MapNodeType.Event;
}
/**
* Generates random types for a pair of wild layers (3 nodes each).
* Returns two arrays of 3 node types each.
*/
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
const layer1Types: MapNodeType[] = [];
const layer2Types: MapNodeType[] = [];
for (let i = 0; i < 3; i++) {
layer1Types.push(pickWildNodeType(rng));
layer2Types.push(pickWildNodeType(rng));
}
return [layer1Types, layer2Types];
}
/**
* Counts repetitions in a wild layer pair.
* - sameLayer: number of duplicate types within each layer
* - adjacent: number of positions where layer1[i] === layer2[i]
* - total: sum of both
*/
function countRepetitions(
layer1Types: MapNodeType[],
layer2Types: MapNodeType[]
): { sameLayer: number; adjacent: number; total: number } {
// Count same-layer repetitions
let sameLayer = 0;
// For layer 1: count duplicates
const layer1Count = new Map<MapNodeType, number>();
for (const type of layer1Types) {
layer1Count.set(type, (layer1Count.get(type) || 0) + 1);
}
for (const count of layer1Count.values()) {
if (count > 1) sameLayer += count - 1;
}
// For layer 2: count duplicates
const layer2Count = new Map<MapNodeType, number>();
for (const type of layer2Types) {
layer2Count.set(type, (layer2Count.get(type) || 0) + 1);
}
for (const count of layer2Count.values()) {
if (count > 1) sameLayer += count - 1;
}
// Count adjacent repetitions (wild[i] → wild[i] connection)
let adjacent = 0;
for (let i = 0; i < 3; i++) {
if (layer1Types[i] === layer2Types[i]) {
adjacent++;
}
}
return { sameLayer, adjacent, total: sameLayer + adjacent };
}
/**
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
*/
function generateOptimalWildPair(
rng: RNG,
attempts = 3
): [MapNodeType[], MapNodeType[]] {
let bestLayer1: MapNodeType[] = [];
let bestLayer2: MapNodeType[] = [];
let bestScore = Infinity;
for (let i = 0; i < attempts; i++) {
const [layer1, layer2] = generateWildPair(rng);
const score = countRepetitions(layer1, layer2);
if (score.total < bestScore) {
bestScore = score.total;
bestLayer1 = layer1;
bestLayer2 = layer2;
}
// Perfect score is 0, no need to continue
if (bestScore === 0) break;
}
return [bestLayer1, bestLayer2];
}
/**
* Generates settlement node types ensuring at least 1 of each: camp, shop, curio.
* The 4th node is randomly chosen from the three.
* Returns shuffled array of 4 node types.
*/
function generateSettlementTypes(rng: RNG): MapNodeType[] {
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
const randomType = requiredTypes[rng.nextInt(3)];
const types = [...requiredTypes, randomType];
return fisherYatesShuffle(types, rng);
}
/**
* Assigns settlement node types ensuring at least 1 of each: camp, shop, curio.
* The 4th node is randomly chosen from the three.
* @deprecated Use generateSettlementTypes() during node creation instead.
*/
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void {
// Shuffle node order to randomize which position gets which type
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
// Assign camp, shop, curio to first 3 shuffled positions
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
for (let i = 0; i < 3; i++) {
nodes[shuffledIndices[i]].type = requiredTypes[i];
}
// Assign random type to 4th position
const randomType = requiredTypes[rng.nextInt(3)];
nodes[shuffledIndices[3]].type = randomType;
}
/**
* Generates edges between two consecutive layers based on layer types.
*/
function generateLayerEdges(
sourceLayer: MapLayer,
targetLayer: MapLayer,
nodes: Map<string, MapNode>,
rng: RNG
): void {
// Settlement types are now pre-generated during node creation
// No need to assign them here anymore
const sourceType = sourceLayer.layerType;
const targetType = targetLayer.layerType;
if (sourceType === 'start' && targetType === MapLayerType.Wild) {
connectStartToWild(sourceLayer, targetLayer);
} else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Wild) {
connectWildToWild(sourceLayer, targetLayer);
} else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Settlement) {
connectWildToSettlement(sourceLayer, targetLayer, rng);
} else if (sourceType === MapLayerType.Settlement && targetType === MapLayerType.Wild) {
connectSettlementToWild(sourceLayer, targetLayer, rng);
} else if (sourceType === MapLayerType.Wild && targetType === 'end') {
connectWildToEnd(sourceLayer, targetLayer);
}
}
/**
* Start connects to all 3 wild nodes in the first wild layer.
*/
function connectStartToWild(
startLayer: MapLayer,
wildLayer: MapLayer
): void {
const startNode = startLayer.nodes[0];
startNode.childIds = [...wildLayer.nodeIds];
}
/**
* Each wild node connects to exactly 1 wild node in the next layer (1-to-1 mapping).
* Uses direct ordering to avoid crossing edges.
*/
function connectWildToWild(
sourceLayer: MapLayer,
targetLayer: MapLayer
): void {
// Direct 1-to-1 mapping: wild[i] → wild[i]
// This guarantees no crossings since order is preserved
for (let i = 0; i < sourceLayer.nodes.length; i++) {
sourceLayer.nodes[i].childIds = [targetLayer.nodeIds[i]];
}
}
/**
* Each wild node connects to 2 settlement nodes.
* Ensures all 4 settlement nodes are covered.
* Total: 3 wilds × 2 = 6 edges, 4 settlements to cover
*/
function connectWildToSettlement(
wildLayer: MapLayer,
settlementLayer: MapLayer,
_rng: RNG
): void {
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
// This creates a "chain" where middle settlements are shared.
// This pattern guarantees no crossings because target indices are always
// non-decreasing when sorted by source indices.
wildLayer.nodes[0].childIds = [settlementLayer.nodeIds[0], settlementLayer.nodeIds[1]];
wildLayer.nodes[1].childIds = [settlementLayer.nodeIds[1], settlementLayer.nodeIds[2]];
wildLayer.nodes[2].childIds = [settlementLayer.nodeIds[2], settlementLayer.nodeIds[3]];
}
/**
* Settlement nodes connect to wild nodes:
* - First and last settlement connect to 1 wild each
* - Middle two settlements connect to 2 wilds each
* Total: 1 + 2 + 2 + 1 = 6 edges, 3 wild nodes to cover
*
* Uses a non-crossing pattern: settlements and wilds are connected
* in order to avoid edge crossings.
*/
function connectSettlementToWild(
settlementLayer: MapLayer,
wildLayer: MapLayer,
_rng: RNG
): void {
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
// This pattern guarantees no crossings because when edges are sorted by
// source index, the minimum target index is non-decreasing.
settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[0]];
settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]];
settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]];
settlementLayer.nodes[3].childIds = [wildLayer.nodeIds[2]];
}
/**
* All 3 wild nodes in the last wild layer connect to End.
*/
function connectWildToEnd(
wildLayer: MapLayer,
endLayer: MapLayer
): void {
const endNode = endLayer.nodes[0];
for (let i = 0; i < wildLayer.nodes.length; i++) {
wildLayer.nodes[i].childIds = [endNode.id];
}
}
// -- Query helpers --
/** Returns the node with the given ID, or undefined. */
export function getNode(map: PointCrawlMap, nodeId: string): MapNode | undefined {
return map.nodes.get(nodeId);
}
/** Returns child nodes of the given node. */
export function getChildren(map: PointCrawlMap, node: MapNode): MapNode[] {
return node.childIds
.map(id => map.nodes.get(id))
.filter((n): n is MapNode => n !== undefined);
}
/** Returns parent nodes of the given node (reverse lookup). */
export function getParents(map: PointCrawlMap, node: MapNode): MapNode[] {
// Use pre-built reverse index if available
if (map.parentIndex) {
const parentIds = map.parentIndex.get(node.id);
if (!parentIds) return [];
return parentIds
.map(id => map.nodes.get(id))
.filter((n): n is MapNode => n !== undefined);
}
// Fallback: scan parent layer (legacy support)
const parents: MapNode[] = [];
const parentLayer = map.layers[node.layerIndex - 1];
if (!parentLayer) return parents;
for (const parentId of parentLayer.nodeIds) {
const parent = map.nodes.get(parentId);
if (parent?.childIds.includes(node.id)) {
parents.push(parent);
}
}
return parents;
}
/**
* Returns true if there is a directed path from `fromId` to `toId`.
*/
export function hasPath(map: PointCrawlMap, fromId: string, toId: string): boolean {
const visited = new Set<string>();
const stack = [fromId];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toId) return true;
if (visited.has(current)) continue;
visited.add(current);
const node = map.nodes.get(current);
if (node) {
for (const childId of node.childIds) {
if (!visited.has(childId)) stack.push(childId);
}
}
}
return false;
}
/**
* Finds all directed paths from `fromId` to `toId`.
* Returns arrays of node IDs representing each path.
* Beware: can be exponential in large maps.
*/
export function findAllPaths(map: PointCrawlMap, fromId: string, toId: string): string[][] {
const paths: string[][] = [];
const dfs = (current: string, path: string[]) => {
if (current === toId) {
paths.push([...path, current]);
return;
}
const node = map.nodes.get(current);
if (!node) return;
path.push(current);
for (const childId of node.childIds) {
dfs(childId, path);
}
path.pop();
};
dfs(fromId, []);
return paths;
}
@@ -0,0 +1,5 @@
export { MapNodeType, MapLayerType } from './types';
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
export { generatePointCrawlMap } from './generator';
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
@@ -0,0 +1,86 @@
/**
* Types of nodes that can appear on the point crawl map.
*/
export enum MapNodeType {
Start = 'start',
End = 'end',
Minion = 'minion',
Elite = 'elite',
Event = 'event',
Camp = 'camp',
Shop = 'shop',
Curio = 'curio',
}
/**
* Semantic type of a layer.
*/
export enum MapLayerType {
Wild = 'wild',
Settlement = 'settlement',
}
/**
* A single node on the map.
*/
export interface MapNode {
/** Unique identifier */
id: string;
/** Which layer this node belongs to */
layerIndex: number;
/** Semantic type of the node */
type: MapNodeType;
/** IDs of nodes in the next layer this node connects to */
childIds: string[];
/** Encounter data assigned to this node (from encounter CSV) */
encounter?: {
name: string;
description: string;
};
}
/**
* A horizontal layer of nodes at the same progression stage.
*/
export interface MapLayer {
/** Layer index (0 = start, last = end) */
index: number;
/** Ordered IDs of nodes in this layer */
nodeIds: string[];
/** Semantic type of the layer */
layerType: MapLayerType | 'start' | 'end';
/** Direct references to nodes in this layer (for performance) */
nodes: MapNode[];
}
/**
* A fully generated point crawl map.
*/
export interface PointCrawlMap {
/** Layers from start to end */
layers: MapLayer[];
/** All nodes keyed by ID */
nodes: Map<string, MapNode>;
/** RNG seed used for generation (for reproducibility) */
seed: number;
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
parentIndex?: Map<string, string[]>;
}
/**
* Configuration for map generation.
*/
export interface MapGenerationConfig {
/** Total number of layers (including start and end) */
totalLayers: number;
/** Number of nodes in each wild layer */
wildLayerNodeCount: number;
/** Number of nodes in each settlement layer */
settlementLayerNodeCount: number;
/** Probability weights for wild node types (should sum to 100) */
wildNodeTypeWeights: {
[MapNodeType.Minion]: number;
[MapNodeType.Elite]: number;
[MapNodeType.Event]: number;
};
}
@@ -0,0 +1,334 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import { generatePointCrawlMap, getNode } from '../map/generator';
import type { MapNode } from '../map/types';
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
import type { GridInventory } from '../grid-inventory/types';
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import { heroItemFighter1Data } from '../data';
// Re-export types
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './types';
// -- Constants --
const INVENTORY_WIDTH = 6;
const INVENTORY_HEIGHT = 4;
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
/** Starter items to give the player at the beginning of a run. */
const STARTER_ITEM_NAMES = ['治疗药剂', '绷带', '水袋', '短刀', '剑'];
// -- Run creation --
/**
* Creates a new run state with a generated map, player stats, and starter inventory.
*
* @param seed RNG seed for reproducibility. If omitted, uses current timestamp.
* @param rng Optional RNG instance for controlled randomness (overrides seed).
*/
export function createRunState(seed?: number, rng?: RNG): RunState {
const actualSeed = seed ?? new Mulberry32RNG().nextInt(2 ** 31);
const map = generatePointCrawlMap(actualSeed);
// Find the start node
const startNode = map.layers[0].nodes[0];
// Create empty inventory
const inventory = createGridInventory<GameItemMeta>(INVENTORY_WIDTH, INVENTORY_HEIGHT);
const idCounter = { value: 0 };
// Place starter items
for (const itemName of STARTER_ITEM_NAMES) {
const itemData = findItemByName(itemName);
if (!itemData) continue;
const shape = parseShapeString(itemData.shape);
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
if (!itemInstance) {
// Inventory too small; item skipped
}
}
return {
seed: actualSeed,
map,
player: {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
},
inventory,
currentNodeId: startNode.id,
currentEncounter: {
nodeId: startNode.id,
resolved: false,
},
resolvedNodeIds: new Set<string>(),
_idCounter: idCounter,
};
}
// -- Movement --
/**
* Checks whether the player can move to the target node.
* The target must be a child of the current node.
*/
export function canMoveTo(runState: RunState, targetNodeId: string): boolean {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
/**
* Moves the player to the target node.
* The target must be a direct child of the current node.
*
* @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure.
*/
export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult {
if (!canMoveTo(runState, targetNodeId)) {
return { success: false, reason: '无法移动到该节点' };
}
const targetNode = getNode(runState.map, targetNodeId);
if (!targetNode) {
return { success: false, reason: '目标节点不存在' };
}
// Update current position
runState.currentNodeId = targetNodeId;
// Create new encounter state for the target node
const isResolved = runState.resolvedNodeIds.has(targetNodeId);
runState.currentEncounter = {
nodeId: targetNodeId,
resolved: isResolved,
};
return { success: true };
}
// -- Encounter management --
/**
* Marks the current encounter as resolved with optional result data.
*/
export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult {
if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' };
}
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = result;
runState.resolvedNodeIds.add(runState.currentNodeId);
// Apply result effects
if (result) {
if (result.goldEarned) {
runState.player.gold += result.goldEarned;
}
if (result.hpLost) {
runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost);
}
if (result.hpGained) {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained);
}
}
return { success: true };
}
/**
* Checks whether the encounter at the given node has been resolved.
*/
export function isEncounterResolved(runState: RunState, nodeId: string): boolean {
return runState.resolvedNodeIds.has(nodeId);
}
// -- Player stats --
/**
* Damages the player. Clamps HP to 0 minimum.
*/
export function damagePlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.max(0, runState.player.currentHp - amount);
}
/**
* Heals the player. Clamps HP to maxHp.
*/
export function healPlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount);
}
/**
* Sets the player's maximum HP and adjusts current HP proportionally.
*/
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = newMax - runState.player.maxHp;
runState.player.maxHp = newMax;
runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.player.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(runState: RunState, amount: number): RunMutationResult {
if (amount <= 0) {
return { success: false, reason: '金额必须大于零' };
}
if (runState.player.gold < amount) {
return { success: false, reason: '金币不足' };
}
runState.player.gold -= amount;
return { success: true };
}
// -- Inventory management --
/**
* Adds an item from CSV data to the inventory.
* Finds the first available valid position.
*
* @returns The placed item instance, or undefined if no valid position exists.
*/
export function addItemFromCsv(
runState: RunState,
itemData: HeroItemFighter1
): GameItem | undefined {
const shape = parseShapeString(itemData.shape);
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
}
/**
* Removes an item from the inventory by its instance ID.
*/
export function removeItem(runState: RunState, instanceId: string): void {
gridRemoveItem(runState.inventory, instanceId);
}
// -- Query helpers --
/**
* Returns the node the player is currently at.
*/
export function getCurrentNode(runState: RunState): MapNode | undefined {
return getNode(runState.map, runState.currentNodeId);
}
/**
* Returns all reachable child nodes from the current position.
*/
export function getReachableChildren(runState: RunState): MapNode[] {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return [];
return currentNode.childIds
.map(id => getNode(runState.map, id))
.filter((n): n is MapNode => n !== undefined);
}
/**
* Returns available children that haven't been resolved yet.
*/
export function getUnresolvedChildren(runState: RunState): MapNode[] {
return getReachableChildren(runState).filter(
node => !runState.resolvedNodeIds.has(node.id)
);
}
/**
* Checks if the current encounter is the start node (no encounter).
*/
export function isAtStartNode(runState: RunState): boolean {
return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(runState: RunState): boolean {
const endLayer = runState.map.layers[runState.map.layers.length - 1];
return endLayer?.nodes[0]?.id === runState.currentNodeId;
}
// -- Internal helpers --
/**
* Finds a hero item by name from the CSV data.
*/
function findItemByName(name: string): HeroItemFighter1 | undefined {
return heroItemFighter1Data.find(item => item.name === name);
}
/**
* Generates a unique item instance ID.
*/
function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}
/**
* Tries to place an item in the inventory by scanning all possible positions.
* Returns the placed item instance, or undefined if no valid position exists.
*/
function tryPlaceItemInInventory(
inventory: GridInventory<GameItemMeta>,
itemData: HeroItemFighter1,
shape: ParsedShape,
idCounter: { value: number }
): GameItem | undefined {
const instanceId = generateInstanceId(idCounter);
// Try to find a valid position
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform = {
...IDENTITY_TRANSFORM,
offset: { x, y },
};
const validation = validatePlacement(inventory, shape, transform);
if (validation.valid) {
const item: GameItem = {
id: instanceId,
shape,
transform,
meta: {
itemData,
shape,
},
};
placeItem(inventory, item);
return item;
}
}
}
return undefined;
}
@@ -0,0 +1,87 @@
import type { PointCrawlMap } from '../map/types';
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
import type { ParsedShape } from '../utils/parse-shape';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
/**
* Result of an encounter (combat, event, etc.).
*/
export interface EncounterResult {
/** Gold earned from the encounter */
goldEarned?: number;
/** HP lost during the encounter */
hpLost?: number;
/** HP gained (e.g., from camp heal) */
hpGained?: number;
/** Item IDs rewarded */
itemRewards?: string[];
}
/**
* Runtime state of an encounter at a specific node.
*/
export interface EncounterState {
/** The node ID where this encounter is located */
nodeId: string;
/** Whether the encounter has been resolved */
resolved: boolean;
/** Optional result data after resolution */
result?: EncounterResult;
}
/**
* Metadata attached to each inventory item instance.
* Bridges CSV item data with the grid inventory system.
*/
export interface GameItemMeta {
/** Original CSV item data */
itemData: HeroItemFighter1;
/** Parsed shape for grid placement */
shape: ParsedShape;
}
/**
* An item instance in the game inventory.
* Extends InventoryItem with game-specific metadata.
*/
export type GameItem = InventoryItem<GameItemMeta>;
/**
* Player runtime state.
*/
export interface PlayerState {
/** Maximum HP */
maxHp: number;
/** Current HP */
currentHp: number;
/** Current gold */
gold: number;
}
/**
* Full run state for a game session.
* Designed to be used inside `MutableSignal.produce()` callbacks.
*/
export interface RunState {
/** RNG seed used for map generation */
seed: number;
/** Generated point crawl map */
map: PointCrawlMap;
/** Player HP and gold */
player: PlayerState;
/** Grid inventory with placed items */
inventory: GridInventory<GameItemMeta>;
/** Current node ID where the player is located */
currentNodeId: string;
/** State of the encounter at the current node */
currentEncounter: EncounterState;
/** Set of node IDs whose encounters have been resolved */
resolvedNodeIds: Set<string>;
/** Internal counter for generating unique item instance IDs */
_idCounter: { value: number };
}
/**
* Result of a mutation operation on the run state.
*/
export type RunMutationResult = { success: true } | { success: false; reason: string };
@@ -0,0 +1,109 @@
/**
* Parsed shape result containing the grid and sizing information.
*/
export interface ParsedShape {
/** 2D boolean grid representing filled cells */
grid: boolean[][];
/** Grid width (number of columns) */
width: number;
/** Grid height (number of rows) */
height: number;
/** Number of occupied (filled) cells */
count: number;
/** Origin X coordinate within the grid */
originX: number;
/** Origin Y coordinate within the grid */
originY: number;
}
/**
* Parses a movement string into a 2D boolean grid.
* Rules:
* o: Fill current cell and set origin
* n, s, e, w: Move one step and fill the cell
* r: Return to the previous cell
*/
export function parseShapeString(input: string): ParsedShape {
let curX = 0;
let curY = 0;
let originX = 0;
let originY = 0;
let originSet = false;
// Track unique filled coordinates
const filledPoints = new Set<string>();
// Stack to track movement history for the 'r' command
const history: { x: number; y: number }[] = [];
const fill = (x: number, y: number) => {
filledPoints.add(`${x},${y}`);
history.push({ x, y });
};
for (const char of input.toLowerCase()) {
switch (char) {
case 'o':
if (!originSet) {
originX = curX;
originY = curY;
originSet = true;
}
fill(curX, curY);
break;
case 'n':
curY -= 1;
fill(curX, curY);
break;
case 's':
curY += 1;
fill(curX, curY);
break;
case 'e':
curX += 1;
fill(curX, curY);
break;
case 'w':
curX -= 1;
fill(curX, curY);
break;
case 'r':
if (history.length > 1) {
history.pop(); // Remove current position
const prev = history[history.length - 1];
curX = prev.x;
curY = prev.y;
}
break;
}
}
if (filledPoints.size === 0) {
return { grid: [[]], width: 0, height: 1, count: 0, originX: 0, originY: 0 };
}
// Calculate bounding box to normalize the array
const coords = Array.from(filledPoints).map(p => p.split(',').map(Number));
const minX = Math.min(...coords.map(c => c[0]));
const maxX = Math.max(...coords.map(c => c[0]));
const minY = Math.min(...coords.map(c => c[1]));
const maxY = Math.max(...coords.map(c => c[1]));
const width = maxX - minX + 1;
const height = maxY - minY + 1;
// Initialize the grid
const grid: boolean[][] = Array.from({ length: height }, () =>
Array(width).fill(false)
);
// Map the points into the grid using offsets
for (const [x, y] of coords) {
grid[y - minY][x - minX] = true;
}
// Normalize origin coordinates relative to the grid
const normalizedOriginX = originX - minX;
const normalizedOriginY = originY - minY;
return { grid, width, height, count: filledPoints.size, originX: normalizedOriginX, originY: normalizedOriginY };
}
@@ -0,0 +1,227 @@
import type { ParsedShape } from './parse-shape';
/**
* Represents a 2D point in grid coordinates.
*/
export interface Point2D {
x: number;
y: number;
}
/**
* 2D transformation to apply to a shape.
*/
export interface Transform2D {
/** Translation offset in grid units */
offset: Point2D;
/** Rotation in degrees (0, 90, 180, 270) */
rotation: number;
/** Whether to flip horizontally */
flipX: boolean;
/** Whether to flip vertically */
flipY: boolean;
}
/**
* Default transform (identity).
*/
export const IDENTITY_TRANSFORM: Transform2D = {
offset: { x: 0, y: 0 },
rotation: 0,
flipX: false,
flipY: false,
};
/**
* Gets all occupied cell coordinates from a shape.
*/
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
const cells: Point2D[] = [];
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
cells.push({ x, y });
}
}
}
return cells;
}
/**
* Applies a 2D transformation to a point.
*/
export function transformPoint(
point: Point2D,
transform: Transform2D,
shapeWidth: number,
shapeHeight: number
): Point2D {
let { x, y } = point;
// Apply flips
if (transform.flipX) {
x = shapeWidth - 1 - x;
}
if (transform.flipY) {
y = shapeHeight - 1 - y;
}
// Apply rotation (around origin 0,0)
const rotation = ((transform.rotation % 360) + 360) % 360;
let rotatedX = x;
let rotatedY = y;
switch (rotation) {
case 90:
rotatedX = y;
rotatedY = -x;
break;
case 180:
rotatedX = -x;
rotatedY = -y;
break;
case 270:
rotatedX = -y;
rotatedY = x;
break;
}
// Apply offset
return {
x: rotatedX + transform.offset.x,
y: rotatedY + transform.offset.y,
};
}
/**
* Transforms a shape and returnss its occupied cells in world coordinates.
*/
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] {
const cells = getOccupiedCells(shape);
return cells.map(cell =>
transformPoint(cell, transform, shape.width, shape.height)
);
}
/**
* Checks if two transformed shapes collide (share any occupied cell).
*/
export function checkCollision(
shapeA: ParsedShape,
transformA: Transform2D,
shapeB: ParsedShape,
transformB: Transform2D
): boolean {
const cellsA = transformShape(shapeA, transformA);
const cellsB = transformShape(shapeB, transformB);
const setA = new Set(cellsA.map(c => `${c.x},${c.y}`));
for (const cell of cellsB) {
if (setA.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape collides with any occupied cells on a board.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param occupiedCells Set of occupied board cells in "x,y" format
*/
export function checkBoardCollision(
shape: ParsedShape,
transform: Transform2D,
occupiedCells: Set<string>
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
return true;
}
}
return false;
}
/**
* Checks if a transformed shape is within bounds.
* @param shape The shape to check
* @param transform The transform to apply to the shape
* @param boardWidth Board width
* @param boardHeight Board height
*/
export function checkBounds(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) {
return false;
}
}
return true;
}
/**
* Validates that a placement is both in bounds and collision-free.
* @returns Object with `valid` flag and optional `reason` string
*/
export function validatePlacement(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number,
occupiedCells: Set<string>
): { valid: true } | { valid: false; reason: string } {
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
return { valid: false, reason: '超出边界' };
}
if (checkBoardCollision(shape, transform, occupiedCells)) {
return { valid: false, reason: '与已有形状重叠' };
}
return { valid: true };
}
/**
* Rotates a transform by the given degrees.
* @param current The current transform
* @param degrees Degrees to rotate (typically 90, 180, or 270)
*/
export function rotateTransform(current: Transform2D, degrees: number): Transform2D {
return {
...current,
rotation: ((current.rotation + degrees) % 360 + 360) % 360,
};
}
/**
* Flips a transform horizontally.
*/
export function flipXTransform(current: Transform2D): Transform2D {
return {
...current,
flipX: !current.flipX,
};
}
/**
* Flips a transform vertically.
*/
export function flipYTransform(current: Transform2D): Transform2D {
return {
...current,
flipY: !current.flipY,
};
}