refactor: reorg
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user