import { HostileNpcSpriteConfig } from '../components/HostileNpcAnimator'; import { AnimationState, Encounter, FacingDirection, HostileNpcRenderAnimation, SceneDirective, SceneHostileNpc, SceneHostileNpcChange, StoryOption, WorldType, } from '../types'; import { resolveRoleCombatStats } from './attributeCombat'; import { resolveCompatibilityTemplateWorldType } from './customWorldRuntime'; import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets'; export const METERS_TO_PIXELS = 48; export const PLAYER_BASE_X_METERS = 0; export const MAX_HOSTILE_NPCS_PER_ENCOUNTER = 3; export const HOSTILE_NPCS_BY_WORLD: Record = { [WorldType.WUXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.WUXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig), [WorldType.XIANXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.XIANXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig), [WorldType.CUSTOM]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig), }; export const MONSTERS_BY_WORLD = HOSTILE_NPCS_BY_WORLD; const UPPER_BACK_OFFSET_X_METERS = Number((56 / METERS_TO_PIXELS).toFixed(2)); const LOWER_BACK_OFFSET_X_METERS = Number((34 / METERS_TO_PIXELS).toFixed(2)); const UPPER_BACK_OFFSET_Y_PX = 66; const LOWER_BACK_OFFSET_Y_PX = 10; const FRONT_HOSTILE_NPC_ANCHOR_X: Record = { [WorldType.WUXIA]: 3.2, [WorldType.XIANXIA]: 3.6, [WorldType.CUSTOM]: 3.4, }; type HostileNpcFormationSlot = Pick; function getUniqueHostileNpcIds(hostileNpcIds: string[]) { const seen = new Set(); const uniqueIds: string[] = []; hostileNpcIds.forEach(monsterId => { const normalizedId = monsterId.trim(); if (!normalizedId || seen.has(normalizedId)) return; seen.add(normalizedId); uniqueIds.push(normalizedId); }); return uniqueIds; } function shuffleItems(items: T[]) { const next = [...items]; for (let index = next.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(Math.random() * (index + 1)); const currentItem = next[index]; const swapItem = next[swapIndex]; if (currentItem === undefined || swapItem === undefined) { continue; } next[index] = swapItem; next[swapIndex] = currentItem; } return next; } function getMaxSceneHostileNpcCount(worldType: WorldType) { return getHostileNpcFormationSlots(worldType, MAX_HOSTILE_NPCS_PER_ENCOUNTER).length; } function getHostileNpcFormationSlots( worldType: WorldType, monsterCount: number, ): HostileNpcFormationSlot[] { const resolvedWorldType = resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA; const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType]; const centerSlot = { xMeters: frontX, yOffset: 0 }; const lowerBackSlot = { xMeters: Number((frontX + LOWER_BACK_OFFSET_X_METERS).toFixed(2)), yOffset: LOWER_BACK_OFFSET_Y_PX, }; const upperBackSlot = { xMeters: Number((frontX + UPPER_BACK_OFFSET_X_METERS).toFixed(2)), yOffset: UPPER_BACK_OFFSET_Y_PX, }; if (monsterCount <= 1) { return [centerSlot]; } if (monsterCount === 2) { return [lowerBackSlot, upperBackSlot]; } return [centerSlot, lowerBackSlot, upperBackSlot]; } export function chooseEncounterMonsterCount(maxAvailableCount: number) { const clampedCount = Math.max(0, Math.min(MAX_HOSTILE_NPCS_PER_ENCOUNTER, maxAvailableCount)); if (clampedCount <= 1) return clampedCount; const weightedCounts = [ { count: 1, weight: 0.6 }, { count: 2, weight: 0.3 }, { count: 3, weight: 0.1 }, ].filter(entry => entry.count <= clampedCount); const totalWeight = weightedCounts.reduce((sum, entry) => sum + entry.weight, 0); let roll = Math.random() * totalWeight; for (const entry of weightedCounts) { roll -= entry.weight; if (roll <= 0) { return entry.count; } } return weightedCounts[weightedCounts.length - 1]?.count ?? 1; } export function pickEncounterHostileNpcIds(availableMonsterIds: string[]) { const pool = getUniqueHostileNpcIds(availableMonsterIds); if (pool.length === 0) return []; const targetCount = chooseEncounterMonsterCount(pool.length); return shuffleItems(pool).slice(0, targetCount); } export function resolveEncounterHostileNpcIds( availableMonsterIds: string[], requestedMonsterIds: string[] = [], ) { const pool = getUniqueHostileNpcIds(availableMonsterIds); if (pool.length === 0) return []; const requested = getUniqueHostileNpcIds(requestedMonsterIds).filter(monsterId => pool.includes(monsterId)); if (requested.length > 0) { return requested.slice(0, Math.min(MAX_HOSTILE_NPCS_PER_ENCOUNTER, pool.length)); } return pickEncounterHostileNpcIds(pool); } export function getHostileNpcGroupAnchorX(monsters: Array>) { if (monsters.length === 0) return PLAYER_BASE_X_METERS; return Math.min(...monsters.map(monster => monster.xMeters)); } export const getMonsterGroupAnchorX = getHostileNpcGroupAnchorX; export function getFacingTowardPlayer(monsterX: number, playerX: number): FacingDirection { return monsterX >= playerX ? 'left' : 'right'; } export const pickEncounterMonsterIds = pickEncounterHostileNpcIds; export function buildHostileNpcEncounter( worldType: WorldType, monsterId: string, options: { xMeters?: number; } = {}, ): Encounter | null { const preset = getHostileNpcPresetById(worldType, monsterId); if (!preset) return null; return { id: `monster:${worldType}:${preset.id}`, kind: 'npc', monsterPresetId: preset.id, npcName: preset.name, npcDescription: preset.description, npcAvatar: preset.name.slice(0, 1) || '敌', context: '敌对角色', xMeters: options.xMeters, initialAffinity: -40, hostile: true, attributeProfile: preset.attributeProfile, }; } export function createSceneHostileNpc( worldType: WorldType, monsterId: string, playerX = PLAYER_BASE_X_METERS, slotIndex = 0, ): SceneHostileNpc | null { const preset = getHostileNpcPresetById(worldType, monsterId); if (!preset) return null; const combatStats = resolveRoleCombatStats(preset.attributeProfile, { baseSpeed: preset.baseStats.speed, }); const maxHp = preset.baseStats.maxHp + combatStats.maxHpBonus; const formationSlots = getHostileNpcFormationSlots( worldType, Math.min(MAX_HOSTILE_NPCS_PER_ENCOUNTER, slotIndex + 1), ); const position = formationSlots[Math.min(slotIndex, formationSlots.length - 1)]; if (!position) { return null; } return { id: preset.id, name: preset.name, action: preset.introAction, description: preset.description, animation: 'idle', xMeters: position.xMeters, yOffset: position.yOffset, facing: getFacingTowardPlayer(position.xMeters, playerX), attackRange: preset.baseStats.attackRange, speed: combatStats.turnSpeed, hp: maxHp, maxHp, renderKind: 'npc', combatTags: preset.combatTags, attributeProfile: preset.attributeProfile, behaviorVectors: preset.behaviorVectors, encounter: buildHostileNpcEncounter(worldType, preset.id, { xMeters: position.xMeters, }) ?? undefined, }; } export function createSceneHostileNpcsFromIds( worldType: WorldType, hostileNpcIds: string[], playerX = PLAYER_BASE_X_METERS, ): SceneHostileNpc[] { const fallbackMonsterPresets = getHostileNpcPresetsByWorld(worldType); const resolvedFallbackId = fallbackMonsterPresets[0]?.id; const resolvedIds = (hostileNpcIds.length > 0 ? hostileNpcIds : resolvedFallbackId ? [resolvedFallbackId] : []) .slice(0, getMaxSceneHostileNpcCount(worldType)); const formationSlots = getHostileNpcFormationSlots(worldType, resolvedIds.length || 1); return resolvedIds .map((monsterId, index) => { const monster = createSceneHostileNpc(worldType, monsterId, playerX, index); const position = formationSlots[index] ?? formationSlots[formationSlots.length - 1]; if (!monster || !position) return null; return { ...monster, xMeters: position.xMeters, yOffset: position.yOffset, facing: getFacingTowardPlayer(position.xMeters, playerX), }; }) .filter(Boolean) as SceneHostileNpc[]; } export function createSceneHostileNpcsFromEncounters( worldType: WorldType, encounters: Encounter[], playerX = PLAYER_BASE_X_METERS, ): SceneHostileNpc[] { const hostileEncounters = encounters.filter( (encounter): encounter is Encounter & { monsterPresetId: string } => Boolean(encounter.monsterPresetId), ); if (hostileEncounters.length === 0) return []; const baseMonsters = createSceneHostileNpcsFromIds( worldType, hostileEncounters.map(encounter => encounter.monsterPresetId), playerX, ); return baseMonsters.map((monster, index) => { const encounter = hostileEncounters[index]; if (!encounter) return monster; return { ...monster, name: encounter.npcName, description: encounter.npcDescription, renderKind: 'npc' as const, encounter: { ...encounter, xMeters: monster.xMeters, }, }; }); } export function getBaseSceneHostileNpcs(worldType: WorldType, playerX = PLAYER_BASE_X_METERS): SceneHostileNpc[] { const fallbackId = getHostileNpcPresetsByWorld(worldType)[0]?.id; return fallbackId ? createSceneHostileNpcsFromIds(worldType, [fallbackId], playerX) : []; } export function distanceBetweenPlayerAndClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[]) { if (monsters.length === 0) return Infinity; return Math.min(...monsters.map(monster => Math.abs(monster.xMeters - playerX))); } export function getClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[]) { if (monsters.length === 0) return null; return [...monsters].sort((a, b) => Math.abs(a.xMeters - playerX) - Math.abs(b.xMeters - playerX))[0]; } export function getHostileNpcDistance(playerX: number, monster: SceneHostileNpc) { return Math.abs(monster.xMeters - playerX); } function normalizeHostileNpcAnimation(value: string | undefined): HostileNpcRenderAnimation { return value === 'move' || value === 'attack' || value === 'die' ? value : 'idle'; } export function normalizeHostileNpcChanges( changes: SceneDirective['hostileNpcChanges'], worldType: WorldType, ): SceneHostileNpcChange[] { const resolvedAllowedIds = new Set(getHostileNpcPresetsByWorld(worldType).map(monster => monster.id)); const safeChanges = changes ?? []; return safeChanges .filter(change => resolvedAllowedIds.has(change.id)) .map(change => ({ id: change.id, action: typeof change.action === 'string' && change.action.trim() ? change.action.trim() : '缁х画鍘嬭揩鐜╁', animation: normalizeHostileNpcAnimation(change.animation), moveMeters: typeof change.moveMeters === 'number' ? Number(change.moveMeters.toFixed(1)) : 0, yOffset: 0, })); } export function applySceneDirective( monsters: SceneHostileNpc[], directive: SceneDirective, playerX: number, ): SceneHostileNpc[] { const nextPlayerX = playerX + directive.playerMoveMeters; const hostileNpcChanges = directive.hostileNpcChanges ?? []; return monsters.map(monster => { const change = hostileNpcChanges.find(item => item.id === monster.id); const nextX = monster.xMeters + (change?.moveMeters ?? 0); return { ...monster, action: change?.action ?? monster.action, animation: change?.animation ?? monster.animation, xMeters: Number(nextX.toFixed(1)), yOffset: 0, facing: getFacingTowardPlayer(nextX, nextPlayerX), }; }); } export function settleHostileNpcAnimations(monsters: SceneHostileNpc[]) { return monsters.map(monster => ({ ...monster, animation: 'idle' as const, facing: getFacingTowardPlayer(monster.xMeters, PLAYER_BASE_X_METERS), })); } export function createFallbackOption( functionId: string, text: string, playerAnimation: AnimationState, moveMeters: number, scrollWorld = false, ): StoryOption { return { functionId, actionText: text, text, visuals: { playerAnimation, playerMoveMeters: moveMeters, playerOffsetY: 0, playerFacing: moveMeters < 0 ? 'left' : 'right', scrollWorld, monsterChanges: [], hostileNpcChanges: [], }, }; }