Files
Genarrative/src/data/hostileNpcs.ts
高物 0981d6ee1b
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-11 15:43:32 +08:00

388 lines
12 KiB
TypeScript

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, HostileNpcSpriteConfig[]> = {
[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, number> = {
[WorldType.WUXIA]: 3.2,
[WorldType.XIANXIA]: 3.6,
[WorldType.CUSTOM]: 3.4,
};
type HostileNpcFormationSlot = Pick<SceneHostileNpc, 'xMeters' | 'yOffset'>;
function getUniqueHostileNpcIds(hostileNpcIds: string[]) {
const seen = new Set<string>();
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<T>(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<Pick<SceneHostileNpc, 'xMeters'>>) {
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: [],
},
};
}