This commit is contained in:
389
src/data/hostileNpcs.ts
Normal file
389
src/data/hostileNpcs.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
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,
|
||||
levelProfile: encounter.levelProfile,
|
||||
experienceReward: encounter.experienceReward,
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user