Files
Genarrative/src/data/hostileNpcs.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

485 lines
13 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,
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: [],
},
};
}