388 lines
12 KiB
TypeScript
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: [],
|
|
},
|
|
};
|
|
}
|