253
src/data/attributeProfileGenerator.ts
Normal file
253
src/data/attributeProfileGenerator.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type {
|
||||
AttributeMigrationTrace,
|
||||
AttributeVector,
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CustomWorldItem,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
InventoryItem,
|
||||
ItemAttributeResonance,
|
||||
LegacyAttributeSet,
|
||||
RoleAttributeEvidence,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import {buildDefaultAxisVector} from './attributeResolver';
|
||||
import {ensureRoleAttributeProfile} from './attributeValidation';
|
||||
|
||||
const AXIS_KEYWORD_RULES: Array<{slotId: string; patterns: RegExp[]; weight: number; reason: string}> = [
|
||||
{ slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16, reason: '文本表现出强承压与硬碰硬倾向。' },
|
||||
{ slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16, reason: '文本强调速度、位移或换位能力。' },
|
||||
{ slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16, reason: '文本强调洞察、术理或破局能力。' },
|
||||
{ slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16, reason: '文本强调意志、压迫与决断。' },
|
||||
{ slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16, reason: '文本强调关系、共鸣或交换。' },
|
||||
{ slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16, reason: '文本强调稳态、续战或恢复。' },
|
||||
];
|
||||
|
||||
const SKILL_STYLE_VECTORS: Record<CharacterSkillDefinition['style'], AttributeVector> = {
|
||||
burst: buildDefaultAxisVector({ axis_a: 0.18, axis_c: 0.2, axis_d: 0.46, axis_f: 0.16 }),
|
||||
steady: buildDefaultAxisVector({ axis_a: 0.16, axis_c: 0.18, axis_e: 0.14, axis_f: 0.52 }),
|
||||
mobility: buildDefaultAxisVector({ axis_b: 0.52, axis_c: 0.12, axis_d: 0.16, axis_f: 0.2 }),
|
||||
finisher: buildDefaultAxisVector({ axis_a: 0.3, axis_b: 0.22, axis_c: 0.2, axis_d: 0.28 }),
|
||||
projectile: buildDefaultAxisVector({ axis_b: 0.26, axis_c: 0.34, axis_d: 0.1, axis_f: 0.3 }),
|
||||
};
|
||||
|
||||
function applyKeywordWeights(
|
||||
seed: AttributeVector,
|
||||
sourceText: string,
|
||||
evidence: RoleAttributeEvidence[],
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
AXIS_KEYWORD_RULES.forEach(rule => {
|
||||
const matches = rule.patterns.reduce((count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), 0);
|
||||
if (matches <= 0) return;
|
||||
|
||||
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
|
||||
const slot = schema.slots.find(item => item.slotId === rule.slotId);
|
||||
if (slot) {
|
||||
evidence.push({
|
||||
slotId: slot.slotId,
|
||||
reason: `${slot.name}:${rule.reason}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildLegacyAttributeSeed(attributes: LegacyAttributeSet) {
|
||||
return buildDefaultAxisVector({
|
||||
axis_a: attributes.strength * 8 + attributes.spirit * 2,
|
||||
axis_b: attributes.agility * 9 + attributes.intelligence * 1,
|
||||
axis_c: attributes.intelligence * 8 + attributes.agility * 2,
|
||||
axis_d: attributes.spirit * 5 + attributes.strength * 4 + attributes.agility * 1,
|
||||
axis_e: attributes.spirit * 4 + attributes.intelligence * 4 + attributes.agility * 1,
|
||||
axis_f: attributes.spirit * 7 + attributes.strength * 3,
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueEvidence(evidence: RoleAttributeEvidence[]) {
|
||||
const seen = new Set<string>();
|
||||
return evidence.filter(entry => {
|
||||
const key = `${entry.slotId}:${entry.reason}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRoleAttributeProfileFromLegacyData({
|
||||
entityId,
|
||||
schema,
|
||||
legacyAttributes,
|
||||
textBlocks,
|
||||
extraWeights,
|
||||
}: {
|
||||
entityId: string;
|
||||
schema: WorldAttributeSchema;
|
||||
legacyAttributes?: LegacyAttributeSet | null;
|
||||
textBlocks?: Array<string | null | undefined>;
|
||||
extraWeights?: AttributeVector;
|
||||
}) {
|
||||
const evidence: RoleAttributeEvidence[] = [];
|
||||
const seed = legacyAttributes
|
||||
? buildLegacyAttributeSeed(legacyAttributes)
|
||||
: buildDefaultAxisVector({
|
||||
axis_a: 58,
|
||||
axis_b: 58,
|
||||
axis_c: 58,
|
||||
axis_d: 58,
|
||||
axis_e: 58,
|
||||
axis_f: 58,
|
||||
});
|
||||
|
||||
const sourceText = (textBlocks ?? []).filter(Boolean).join(' ');
|
||||
if (sourceText) {
|
||||
applyKeywordWeights(seed, sourceText, evidence, schema);
|
||||
}
|
||||
|
||||
WORLD_ATTRIBUTE_SLOT_IDS.forEach(slotId => {
|
||||
seed[slotId] = (seed[slotId] ?? 0) + (extraWeights?.[slotId] ?? 0);
|
||||
});
|
||||
|
||||
const fallbackEvidence = uniqueEvidence(evidence).slice(0, 4);
|
||||
const profile = ensureRoleAttributeProfile(
|
||||
{
|
||||
schemaId: schema.id,
|
||||
values: seed,
|
||||
},
|
||||
schema,
|
||||
{
|
||||
fallbackValues: seed,
|
||||
fallbackEvidence,
|
||||
},
|
||||
);
|
||||
|
||||
const trace: AttributeMigrationTrace = {
|
||||
sourceCharacterId: entityId,
|
||||
schemaId: schema.id,
|
||||
oldAttributes: legacyAttributes ?? undefined,
|
||||
inferredReasons: fallbackEvidence.map(entry => entry.reason),
|
||||
fallbackUsed: false,
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
trace,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCharacterAttributeProfile(character: Character, schema: WorldAttributeSchema) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: character.id,
|
||||
schema,
|
||||
legacyAttributes: character.attributes,
|
||||
textBlocks: [
|
||||
character.title,
|
||||
character.description,
|
||||
character.backstory,
|
||||
character.personality,
|
||||
...(character.combatTags ?? []),
|
||||
...character.skills.map(skill => `${skill.name} ${skill.style} ${skill.delivery ?? ''}`),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildCustomWorldPlayableNpcAttributeProfile(
|
||||
npc: CustomWorldPlayableNpc,
|
||||
schema: WorldAttributeSchema,
|
||||
templateAttributes?: LegacyAttributeSet,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
legacyAttributes: templateAttributes,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.combatStyle,
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, schema: WorldAttributeSchema) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
textBlocks: [
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.motivation,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildMonsterAttributeProfile(
|
||||
monster: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
introAction: string;
|
||||
combatTags?: string[];
|
||||
habitatTags?: string[];
|
||||
baseStats: { attackRange: number; speed: number; maxHp: number };
|
||||
},
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: monster.id,
|
||||
schema,
|
||||
textBlocks: [
|
||||
monster.name,
|
||||
monster.description,
|
||||
monster.introAction,
|
||||
...(monster.combatTags ?? []),
|
||||
...(monster.habitatTags ?? []),
|
||||
],
|
||||
extraWeights: buildDefaultAxisVector({
|
||||
axis_a: monster.baseStats.maxHp >= 150 ? 24 : 0,
|
||||
axis_b: monster.baseStats.speed >= 7 ? 22 : 0,
|
||||
axis_d: monster.baseStats.attackRange >= 1.5 ? 18 : 6,
|
||||
axis_f: monster.baseStats.maxHp >= 180 ? 26 : 10,
|
||||
}),
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildItemAttributeResonance(
|
||||
item: Pick<InventoryItem | CustomWorldItem, 'category' | 'name' | 'description'> & {
|
||||
tags?: string[];
|
||||
buildProfile?: { resonanceVector?: AttributeVector | null } | null;
|
||||
},
|
||||
): ItemAttributeResonance {
|
||||
const directVector = item.buildProfile?.resonanceVector;
|
||||
if (directVector) {
|
||||
return {
|
||||
resonanceVector: directVector,
|
||||
explanation: `${item.name}显式声明了属性共振向量。`,
|
||||
};
|
||||
}
|
||||
|
||||
const source = `${item.category} ${item.name} ${item.description ?? ''} ${(item.tags ?? []).join(' ')}`;
|
||||
const vector = buildDefaultAxisVector({
|
||||
axis_a: /甲|盾|锤|骨|锋|刃|护/u.test(source) ? 0.28 : 0.08,
|
||||
axis_b: /弓|箭|靴|影|风|迅|游/u.test(source) ? 0.26 : 0.08,
|
||||
axis_c: /卷|符|阵|镜|策|识|图/u.test(source) ? 0.26 : 0.08,
|
||||
axis_d: /雷|爆|怒|杀|破|冲/u.test(source) ? 0.24 : 0.08,
|
||||
axis_e: /礼|契|印|信|药|护符|盟/u.test(source) ? 0.22 : 0.08,
|
||||
axis_f: /药|丹|露|稳|护|续|回/u.test(source) ? 0.24 : 0.08,
|
||||
});
|
||||
|
||||
return {
|
||||
resonanceVector: vector,
|
||||
explanation: `${item.name}的共振由品类与文本语义推断。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillAttributeProfile(skill: CharacterSkillDefinition) {
|
||||
return {
|
||||
intentVector: SKILL_STYLE_VECTORS[skill.style] ?? SKILL_STYLE_VECTORS.steady,
|
||||
};
|
||||
}
|
||||
175
src/data/attributeResolver.ts
Normal file
175
src/data/attributeResolver.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
Character,
|
||||
CombatActionAttributeProfile,
|
||||
CustomWorldProfile,
|
||||
RoleActionDefinition,
|
||||
RoleAttributeProfile,
|
||||
RoleRelationState,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import {normalizeAttributeVector, roundNumber} from './attributeValidation';
|
||||
import {getWorldAttributeSchema} from './worldAttributeSchemas';
|
||||
|
||||
export function resolveRelationStance(affinity: number): RoleRelationState['stance'] {
|
||||
if (affinity <= -30) return 'hostile';
|
||||
if (affinity <= 14) return 'guarded';
|
||||
if (affinity <= 34) return 'neutral';
|
||||
if (affinity <= 59) return 'cooperative';
|
||||
return 'bonded';
|
||||
}
|
||||
|
||||
export function buildRelationState(affinity: number): RoleRelationState {
|
||||
return {
|
||||
affinity,
|
||||
stance: resolveRelationStance(affinity),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAttributeSchema(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
return getWorldAttributeSchema(worldType, customWorldProfile);
|
||||
}
|
||||
|
||||
export function resolveCharacterAttributeProfile(
|
||||
character: Character,
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
void customWorldProfile;
|
||||
|
||||
if (worldType && character.attributeProfiles?.[worldType]) {
|
||||
return character.attributeProfiles[worldType] ?? character.attributeProfile;
|
||||
}
|
||||
|
||||
if (worldType === 'CUSTOM' && character.attributeProfiles?.CUSTOM) {
|
||||
return character.attributeProfiles.CUSTOM;
|
||||
}
|
||||
|
||||
return character.attributeProfile;
|
||||
}
|
||||
|
||||
export function getAttributeSlotValue(profile: RoleAttributeProfile | null | undefined, slotId: string) {
|
||||
return profile?.values?.[slotId] ?? 0;
|
||||
}
|
||||
|
||||
export function getNormalizedAttributeWeights(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return normalizeAttributeVector(profile?.values ?? {}, schema.slots.map(slot => slot.slotId));
|
||||
}
|
||||
|
||||
export function scoreAttributeFit(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
vector: AttributeVector | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
const weights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedVector = normalizeAttributeVector(vector ?? {}, schema.slots.map(slot => slot.slotId));
|
||||
|
||||
return roundNumber(
|
||||
schema.slots.reduce(
|
||||
(sum, slot) => sum + (weights[slot.slotId] ?? 0) * (normalizedVector[slot.slotId] ?? 0),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
export function scoreActionMatch(
|
||||
actorProfile: RoleAttributeProfile | null | undefined,
|
||||
action:
|
||||
| Pick<RoleActionDefinition, 'intentVector' | 'resistVector' | 'baseScore'>
|
||||
| Pick<CombatActionAttributeProfile, 'intentVector' | 'resistVector'>,
|
||||
schema: WorldAttributeSchema,
|
||||
options: {
|
||||
targetProfile?: RoleAttributeProfile | null;
|
||||
actorCoefficient?: number;
|
||||
targetCoefficient?: number;
|
||||
relationModifier?: number;
|
||||
contextModifier?: number;
|
||||
} = {},
|
||||
) {
|
||||
const actorFit = scoreAttributeFit(actorProfile, action.intentVector, schema);
|
||||
const targetResistance = action.resistVector
|
||||
? scoreAttributeFit(options.targetProfile, action.resistVector, schema)
|
||||
: 0;
|
||||
const actorCoefficient = options.actorCoefficient ?? 1;
|
||||
const targetCoefficient = options.targetCoefficient ?? 1;
|
||||
const baseScore = 'baseScore' in action ? action.baseScore : 0;
|
||||
|
||||
return roundNumber(
|
||||
baseScore
|
||||
+ actorFit * actorCoefficient
|
||||
- targetResistance * targetCoefficient
|
||||
+ (options.relationModifier ?? 0)
|
||||
+ (options.contextModifier ?? 0),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSortedAttributeEntries(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return [...schema.slots]
|
||||
.map(slot => ({
|
||||
slot,
|
||||
value: getAttributeSlotValue(profile, slot.slotId),
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
}
|
||||
|
||||
export function describeTopAttributes(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 3,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)
|
||||
.slice(0, limit)
|
||||
.map(entry => `${entry.slot.name}${entry.value}`);
|
||||
}
|
||||
|
||||
export function formatAttributeList(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = schema.slots.length,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)
|
||||
.slice(0, limit)
|
||||
.map(entry => ({
|
||||
slot: entry.slot,
|
||||
value: entry.value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLeadingAttributeSlot(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)[0]?.slot ?? schema.slots[0] ?? null;
|
||||
}
|
||||
|
||||
export function buildSchemaSummary(schema: WorldAttributeSchema, limit = 6) {
|
||||
return schema.slots.slice(0, limit).map(slot => ({
|
||||
name: slot.name,
|
||||
definition: slot.definition,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSlotById(schema: WorldAttributeSchema, slotId: string): WorldAttributeSlot | null {
|
||||
return schema.slots.find(slot => slot.slotId === slotId) ?? null;
|
||||
}
|
||||
|
||||
export function buildDefaultAxisVector(overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>) {
|
||||
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
|
||||
result[slotId] = overrides[slotId] ?? 0;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
252
src/data/attributeValidation.ts
Normal file
252
src/data/attributeValidation.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
RoleAttributeProfile,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
WorldAttributeSlotId,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
|
||||
const ATTRIBUTE_TOTAL_MIN = 300;
|
||||
const ATTRIBUTE_TOTAL_MAX = 420;
|
||||
const ATTRIBUTE_TOTAL_TARGET = 360;
|
||||
|
||||
const BANNED_ATTRIBUTE_TERMS = [
|
||||
'生命',
|
||||
'法力',
|
||||
'护甲',
|
||||
'攻击',
|
||||
'防御',
|
||||
'力量',
|
||||
'敏捷',
|
||||
'智力',
|
||||
'精神',
|
||||
'战士',
|
||||
'法师',
|
||||
'刺客',
|
||||
'正道',
|
||||
'魔道',
|
||||
] as const;
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function roundNumber(value: number, digits = 2) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toOptionalText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toText(value: unknown, fallback: string) {
|
||||
const normalized = toOptionalText(value);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map(item => toOptionalText(item))
|
||||
.filter(Boolean);
|
||||
|
||||
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
|
||||
}
|
||||
|
||||
export function coerceWorldAttributeSchema(
|
||||
raw: unknown,
|
||||
fallback: WorldAttributeSchema,
|
||||
): WorldAttributeSchema {
|
||||
if (!isRecord(raw)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rawGeneratedFrom = isRecord(raw.generatedFrom) ? raw.generatedFrom : {};
|
||||
const rawSlots = Array.isArray(raw.slots) ? raw.slots : [];
|
||||
const candidate: WorldAttributeSchema = {
|
||||
...fallback,
|
||||
id: toText(raw.id, fallback.id),
|
||||
worldId: toText(raw.worldId, fallback.worldId),
|
||||
schemaVersion: typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion) && raw.schemaVersion > 0
|
||||
? Math.max(1, Math.round(raw.schemaVersion))
|
||||
: fallback.schemaVersion,
|
||||
schemaName: toOptionalText(raw.schemaName) || fallback.schemaName,
|
||||
generatedFrom: {
|
||||
...fallback.generatedFrom,
|
||||
worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName),
|
||||
settingSummary: toText(rawGeneratedFrom.settingSummary, fallback.generatedFrom.settingSummary),
|
||||
tone: toText(rawGeneratedFrom.tone, fallback.generatedFrom.tone),
|
||||
conflictCore: toText(rawGeneratedFrom.conflictCore, fallback.generatedFrom.conflictCore),
|
||||
},
|
||||
slots: fallback.slots.map((fallbackSlot, index) => {
|
||||
const rawSlot = isRecord(rawSlots[index]) ? rawSlots[index] : {};
|
||||
return {
|
||||
...fallbackSlot,
|
||||
slotId: fallbackSlot.slotId,
|
||||
name: toText(rawSlot.name, fallbackSlot.name),
|
||||
definition: toText(rawSlot.definition, fallbackSlot.definition),
|
||||
positiveSignals: toStringArray(rawSlot.positiveSignals, fallbackSlot.positiveSignals),
|
||||
negativeSignals: toStringArray(rawSlot.negativeSignals, fallbackSlot.negativeSignals),
|
||||
combatUseText: toText(rawSlot.combatUseText, fallbackSlot.combatUseText),
|
||||
socialUseText: toText(rawSlot.socialUseText, fallbackSlot.socialUseText),
|
||||
explorationUseText: toText(rawSlot.explorationUseText, fallbackSlot.explorationUseText),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return validateWorldAttributeSchema(candidate).length > 0 ? fallback : candidate;
|
||||
}
|
||||
|
||||
export function normalizeAttributeVector(
|
||||
vector: AttributeVector,
|
||||
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
|
||||
) {
|
||||
const total = slotIds.reduce((sum, slotId) => sum + Math.max(0, vector[slotId] ?? 0), 0);
|
||||
if (total <= 0) {
|
||||
const evenShare = 1 / Math.max(slotIds.length, 1);
|
||||
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [slotId, roundNumber(Math.max(0, vector[slotId] ?? 0) / total, 4)]),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureSlotIds(slots: WorldAttributeSlot[]) {
|
||||
return slots.map((slot, index) => ({
|
||||
...slot,
|
||||
slotId: slot.slotId ?? WORLD_ATTRIBUTE_SLOT_IDS[index] ?? `axis_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
|
||||
const issues: string[] = [];
|
||||
const slots = ensureSlotIds(schema.slots ?? []);
|
||||
|
||||
if (slots.length !== WORLD_ATTRIBUTE_SLOT_IDS.length) {
|
||||
issues.push(`expected ${WORLD_ATTRIBUTE_SLOT_IDS.length} attribute slots, received ${slots.length}`);
|
||||
}
|
||||
|
||||
const nameSet = new Set<string>();
|
||||
slots.forEach(slot => {
|
||||
const trimmedName = slot.name.trim();
|
||||
if (!trimmedName) {
|
||||
issues.push(`slot ${slot.slotId} is missing a name`);
|
||||
}
|
||||
if (nameSet.has(trimmedName)) {
|
||||
issues.push(`duplicate attribute name "${trimmedName}"`);
|
||||
}
|
||||
nameSet.add(trimmedName);
|
||||
|
||||
if (BANNED_ATTRIBUTE_TERMS.some(term => trimmedName.includes(term))) {
|
||||
issues.push(`attribute name "${trimmedName}" contains banned legacy term`);
|
||||
}
|
||||
|
||||
if (!slot.definition.trim()) {
|
||||
issues.push(`slot ${slot.slotId} is missing a definition`);
|
||||
}
|
||||
|
||||
if (/攻击力|防御力|生命值|法力值/u.test(slot.definition)) {
|
||||
issues.push(`slot ${slot.slotId} definition is too derivative`);
|
||||
}
|
||||
|
||||
if (!slot.combatUseText.trim() || !slot.socialUseText.trim() || !slot.explorationUseText.trim()) {
|
||||
issues.push(`slot ${slot.slotId} must describe combat, social, and exploration usage`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function normalizeAttributeValues(
|
||||
values: AttributeVector,
|
||||
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
|
||||
targetTotal = ATTRIBUTE_TOTAL_TARGET,
|
||||
) {
|
||||
const positiveValues = slotIds.map(slotId => Math.max(0, values[slotId] ?? 0));
|
||||
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
const normalized = rawTotal > 0
|
||||
? positiveValues.map(value => (value / rawTotal) * targetTotal)
|
||||
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
|
||||
|
||||
const rounded = normalized.map(value => clamp(Math.round(value), 0, 100));
|
||||
let total = rounded.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
while (total < ATTRIBUTE_TOTAL_MIN) {
|
||||
const index = rounded.indexOf(Math.min(...rounded));
|
||||
if (index < 0) {
|
||||
break;
|
||||
}
|
||||
const currentValue = rounded[index] ?? 0;
|
||||
rounded[index] = clamp(currentValue + 1, 0, 100);
|
||||
total += 1;
|
||||
}
|
||||
|
||||
while (total > ATTRIBUTE_TOTAL_MAX) {
|
||||
const index = rounded.indexOf(Math.max(...rounded));
|
||||
if (index < 0) {
|
||||
break;
|
||||
}
|
||||
const currentValue = rounded[index] ?? 0;
|
||||
rounded[index] = clamp(currentValue - 1, 0, 100);
|
||||
total -= 1;
|
||||
}
|
||||
|
||||
return Object.fromEntries(slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]));
|
||||
}
|
||||
|
||||
export function ensureRoleAttributeProfile(
|
||||
profile: Partial<RoleAttributeProfile> | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
options: {
|
||||
fallbackValues?: AttributeVector;
|
||||
fallbackEvidence?: RoleAttributeProfile['evidence'];
|
||||
} = {},
|
||||
): RoleAttributeProfile {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const normalizedValues = normalizeAttributeValues(
|
||||
{
|
||||
...(options.fallbackValues ?? {}),
|
||||
...(profile?.values ?? {}),
|
||||
},
|
||||
slotIds,
|
||||
);
|
||||
|
||||
const sortedSlots = [...schema.slots]
|
||||
.map(slot => ({
|
||||
slot,
|
||||
value: normalizedValues[slot.slotId] ?? 0,
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
|
||||
const strongestValue = sortedSlots[0]?.value ?? 0;
|
||||
const topTraits = sortedSlots
|
||||
.filter(entry => entry.value >= strongestValue - 8)
|
||||
.slice(0, 2)
|
||||
.map(entry => entry.slot.name);
|
||||
|
||||
return {
|
||||
schemaId: profile?.schemaId ?? schema.id,
|
||||
values: normalizedValues,
|
||||
topTraits,
|
||||
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
|
||||
evidence: profile?.evidence?.length
|
||||
? [...profile.evidence]
|
||||
: options.fallbackEvidence?.length
|
||||
? [...options.fallbackEvidence]
|
||||
: sortedSlots.slice(0, 3).map(entry => ({
|
||||
slotId: entry.slot.slotId as WorldAttributeSlotId,
|
||||
reason: `${entry.slot.name}在当前画像中最突出。`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
315
src/data/buildDamage.test.ts
Normal file
315
src/data/buildDamage.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {AnimationState, type Character, type EquipmentLoadout, type GameState, type InventoryItem, WorldType} from '../types';
|
||||
import { buildCharacterAttributeProfile } from './attributeProfileGenerator';
|
||||
import {
|
||||
getBuildContributionAttributeRows,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from './buildDamage';
|
||||
import {getCharacterCombatTags} from './buildTags';
|
||||
import {getCharacterById} from './characterPresets';
|
||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
function requireCharacter(characterId: string) {
|
||||
const character = getCharacterById(characterId);
|
||||
expect(character).toBeTruthy();
|
||||
return character!;
|
||||
}
|
||||
|
||||
function cloneCharacter(character: Character, overrides: Partial<Character> = {}) {
|
||||
const nextCharacter = {
|
||||
...character,
|
||||
...overrides,
|
||||
attributes: {
|
||||
...character.attributes,
|
||||
...(overrides.attributes ?? {}),
|
||||
},
|
||||
} satisfies Character;
|
||||
|
||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(nextCharacter, wuxiaSchema);
|
||||
const xianxiaProfile = buildCharacterAttributeProfile(nextCharacter, xianxiaSchema);
|
||||
|
||||
return {
|
||||
...nextCharacter,
|
||||
attributeProfile: wuxiaProfile,
|
||||
attributeProfiles: {
|
||||
...nextCharacter.attributeProfiles,
|
||||
[WorldType.WUXIA]: wuxiaProfile,
|
||||
[WorldType.XIANXIA]: xianxiaProfile,
|
||||
},
|
||||
} satisfies Character;
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
slot: 'weapon' | 'armor' | 'relic';
|
||||
role: string;
|
||||
tags: string[];
|
||||
setId?: string;
|
||||
setName?: string;
|
||||
}): InventoryItem {
|
||||
return {
|
||||
id: params.id,
|
||||
category: params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: params.tags,
|
||||
equipmentSlotId: params.slot,
|
||||
buildProfile: {
|
||||
role: params.role,
|
||||
tags: params.tags,
|
||||
setId: params.setId,
|
||||
setName: params.setName,
|
||||
pieceName: params.slot,
|
||||
synergy: params.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGameState(loadout: EquipmentLoadout, activeBuildBuffs: GameState['activeBuildBuffs'] = []) {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'melee',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 100,
|
||||
playerMaxMana: 100,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs,
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: loadout,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
describe('buildDamage', () => {
|
||||
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const breakdown = getCompanionBuildDamageBreakdown(character);
|
||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
|
||||
expect(breakdown.rows.length).toBeGreaterThan(0);
|
||||
|
||||
breakdown.rows.forEach(row => {
|
||||
const contributionSum = Object.values(row.attributeContributions)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
const modifierSum = Object.values(row.attributeModifierDeltas)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
const attributeRows = getBuildContributionAttributeRows(row, schema);
|
||||
|
||||
expect(contributionSum).toBeCloseTo(row.fitScore, 4);
|
||||
expect(modifierSum).toBeCloseTo(row.bonusDelta, 4);
|
||||
expect(attributeRows.length).toBeGreaterThan(0);
|
||||
attributeRows.forEach(attributeRow => {
|
||||
expect(attributeRow.similarity).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.weight).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.modifierDelta).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('removing one tag only removes that tag row and does not recalculate shared rows', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const combatTags = getCharacterCombatTags(baseCharacter);
|
||||
expect(combatTags.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const fullBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
|
||||
combatTags,
|
||||
}));
|
||||
const trimmedBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
|
||||
combatTags: combatTags.slice(0, 2),
|
||||
}));
|
||||
|
||||
const sharedLabels = combatTags.slice(0, 2);
|
||||
sharedLabels.forEach(label => {
|
||||
const fullRow = fullBreakdown.rows.find(row => row.label === label);
|
||||
const trimmedRow = trimmedBreakdown.rows.find(row => row.label === label);
|
||||
|
||||
expect(fullRow?.bonusDelta).toBe(trimmedRow?.bonusDelta);
|
||||
expect(fullRow?.fitScore).toBe(trimmedRow?.fitScore);
|
||||
});
|
||||
expect(trimmedBreakdown.rows.find(row => row.label === combatTags[2])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives the same loadout noticeably different build multipliers for different attribute profiles', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(baseCharacter);
|
||||
|
||||
const loadout = {
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'test-weapon',
|
||||
name: 'Test Weapon',
|
||||
slot: 'weapon',
|
||||
role: primaryTag,
|
||||
tags: [primaryTag, secondaryTag],
|
||||
setId: 'set-duelist',
|
||||
setName: 'Duelist',
|
||||
}),
|
||||
armor: buildEquipmentItem({
|
||||
id: 'test-armor',
|
||||
name: 'Test Armor',
|
||||
slot: 'armor',
|
||||
role: secondaryTag,
|
||||
tags: [primaryTag, secondaryTag],
|
||||
setId: 'set-duelist',
|
||||
setName: 'Duelist',
|
||||
}),
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const agileCharacter = cloneCharacter(baseCharacter, {
|
||||
attributes: {
|
||||
strength: 7,
|
||||
agility: 11,
|
||||
intelligence: 3,
|
||||
spirit: 3,
|
||||
},
|
||||
});
|
||||
const mageCharacter = cloneCharacter(baseCharacter, {
|
||||
attributes: {
|
||||
strength: 3,
|
||||
agility: 4,
|
||||
intelligence: 10,
|
||||
spirit: 9,
|
||||
},
|
||||
});
|
||||
|
||||
const agileBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), agileCharacter);
|
||||
const mageBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), mageCharacter);
|
||||
|
||||
expect(agileBreakdown.buildDamageMultiplier).toBeGreaterThan(mageBreakdown.buildDamageMultiplier);
|
||||
expect(agileBreakdown.buildDamageMultiplier - mageBreakdown.buildDamageMultiplier).toBeGreaterThan(0.02);
|
||||
});
|
||||
|
||||
it('includes both buff tags and set tags in the final additive build bonus', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(character);
|
||||
const loadout = {
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'set-weapon',
|
||||
name: 'Set Weapon',
|
||||
slot: 'weapon',
|
||||
role: primaryTag,
|
||||
tags: [primaryTag],
|
||||
setId: 'set-runner',
|
||||
setName: 'Runner',
|
||||
}),
|
||||
armor: buildEquipmentItem({
|
||||
id: 'set-armor',
|
||||
name: 'Set Armor',
|
||||
slot: 'armor',
|
||||
role: secondaryTag,
|
||||
tags: [secondaryTag],
|
||||
setId: 'set-runner',
|
||||
setName: 'Runner',
|
||||
}),
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const breakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout, [
|
||||
{
|
||||
id: 'buff-1',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'test-skill',
|
||||
name: 'Test Buff',
|
||||
tags: [primaryTag],
|
||||
durationTurns: 2,
|
||||
},
|
||||
]), character);
|
||||
|
||||
expect(breakdown.rows.some(row => row.source === 'buff')).toBe(true);
|
||||
expect(breakdown.rows.some(row => row.source === 'set')).toBe(true);
|
||||
expect(breakdown.buildDamageBonus).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses different source coefficients for weapon, armor, and relic tags', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const equipmentOnlyTag = 'balanced';
|
||||
|
||||
const weaponBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'weapon-only',
|
||||
name: 'Weapon Only',
|
||||
slot: 'weapon',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}), character);
|
||||
const armorBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: null,
|
||||
armor: buildEquipmentItem({
|
||||
id: 'armor-only',
|
||||
name: 'Armor Only',
|
||||
slot: 'armor',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
relic: null,
|
||||
}), character);
|
||||
const relicBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: buildEquipmentItem({
|
||||
id: 'relic-only',
|
||||
name: 'Relic Only',
|
||||
slot: 'relic',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
}), character);
|
||||
|
||||
const weaponRow = weaponBreakdown.rows.find(row => row.source === 'weapon');
|
||||
const armorRow = armorBreakdown.rows.find(row => row.source === 'armor');
|
||||
const relicRow = relicBreakdown.rows.find(row => row.source === 'relic');
|
||||
|
||||
expect(weaponRow?.sourceCoefficient).toBe(0.85);
|
||||
expect(armorRow?.sourceCoefficient).toBe(0.75);
|
||||
expect(relicRow?.sourceCoefficient).toBe(0.8);
|
||||
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(relicRow?.bonusDelta ?? 0);
|
||||
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(armorRow?.bonusDelta ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
542
src/data/buildDamage.ts
Normal file
542
src/data/buildDamage.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
RoleAttributeProfile,
|
||||
SceneMonster,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
getNormalizedAttributeWeights,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from './attributeResolver';
|
||||
import { normalizeAttributeVector } from './attributeValidation';
|
||||
import {getBuildTagAttributeSimilarityProfile} from './buildTagAttributeAffinity';
|
||||
import {
|
||||
buildSetBuildTagLabel,
|
||||
getBuildTagDefinition,
|
||||
getCharacterCombatTags,
|
||||
getSceneMonsterCombatTags,
|
||||
getTimedBuildBuffTags,
|
||||
normalizeBuildRole,
|
||||
normalizeBuildTags,
|
||||
} from './buildTags';
|
||||
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
|
||||
import { getEquipmentBonuses } from './equipmentEffects';
|
||||
|
||||
const MAX_ACTIVE_BUILD_TAGS = 8;
|
||||
export const BASE_TAG_BONUS = 0.12;
|
||||
export const MAX_BUILD_BONUS = 0.6;
|
||||
|
||||
export type BuildTagSource =
|
||||
| 'buff'
|
||||
| 'character'
|
||||
| 'weapon'
|
||||
| 'armor'
|
||||
| 'relic'
|
||||
| 'set'
|
||||
| 'monster';
|
||||
|
||||
type ResolvedBuildTag = {
|
||||
label: string;
|
||||
source: BuildTagSource;
|
||||
priority: number;
|
||||
relatedTags?: string[];
|
||||
};
|
||||
|
||||
export type BuildContributionRow = {
|
||||
label: string;
|
||||
source: BuildTagSource;
|
||||
fitScore: number;
|
||||
sourceCoefficient: number;
|
||||
bonusDelta: number;
|
||||
attributeSimilarities: AttributeVector;
|
||||
attributeWeights: AttributeVector;
|
||||
attributeContributions: AttributeVector;
|
||||
attributeModifierDeltas: AttributeVector;
|
||||
};
|
||||
|
||||
export type BuildDamageBreakdown = {
|
||||
tags: string[];
|
||||
baseTagCount: number;
|
||||
buildDamageBonus: number;
|
||||
buildDamageMultiplier: number;
|
||||
rows: BuildContributionRow[];
|
||||
};
|
||||
|
||||
export type BuildContributionAttributeRow = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
definition: string;
|
||||
similarity: number;
|
||||
weight: number;
|
||||
value: number;
|
||||
modifierDelta: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function getSourceCoefficient(source: BuildTagSource) {
|
||||
switch (source) {
|
||||
case 'buff':
|
||||
return 1;
|
||||
case 'character':
|
||||
return 0.9;
|
||||
case 'weapon':
|
||||
return 0.85;
|
||||
case 'armor':
|
||||
return 0.75;
|
||||
case 'relic':
|
||||
return 0.8;
|
||||
case 'set':
|
||||
return 0.9;
|
||||
case 'monster':
|
||||
return 0.88;
|
||||
default:
|
||||
return 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
function pushTag(
|
||||
target: ResolvedBuildTag[],
|
||||
label: string | null | undefined,
|
||||
source: BuildTagSource,
|
||||
priority: number,
|
||||
relatedTags?: string[],
|
||||
) {
|
||||
const normalizedLabel = label?.trim();
|
||||
if (!normalizedLabel) return;
|
||||
target.push({
|
||||
label: normalizedLabel,
|
||||
source,
|
||||
priority,
|
||||
relatedTags: relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function getItemBuildTags(item: InventoryItem | null) {
|
||||
if (!item?.buildProfile) return [];
|
||||
return normalizeBuildTags([
|
||||
normalizeBuildRole(item.buildProfile.role),
|
||||
...(item.buildProfile.tags ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
if (!loadout) return [];
|
||||
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
const setPieces = new Map<string, { count: number; tags: string[]; setName: string }>();
|
||||
|
||||
([
|
||||
['weapon', loadout.weapon],
|
||||
['armor', loadout.armor],
|
||||
['relic', loadout.relic],
|
||||
] as const).forEach(([slotId, item]) => {
|
||||
if (!item) return;
|
||||
const itemTags = getItemBuildTags(item);
|
||||
itemTags.forEach(tag => pushTag(tags, tag, slotId, 60));
|
||||
|
||||
const setId = item.buildProfile?.setId?.trim();
|
||||
const setName = item.buildProfile?.setName?.trim();
|
||||
if (!setId || !setName) return;
|
||||
|
||||
const entry = setPieces.get(setId) ?? {
|
||||
count: 0,
|
||||
tags: [],
|
||||
setName,
|
||||
};
|
||||
entry.count += 1;
|
||||
entry.tags = [...new Set([...entry.tags, ...itemTags])];
|
||||
setPieces.set(setId, entry);
|
||||
});
|
||||
|
||||
setPieces.forEach(entry => {
|
||||
if (entry.count < 2) return;
|
||||
pushTag(
|
||||
tags,
|
||||
buildSetBuildTagLabel(entry.setName, entry.count),
|
||||
'set',
|
||||
entry.count >= 3 ? 70 : 65,
|
||||
entry.tags,
|
||||
);
|
||||
});
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
|
||||
const bestByLabel = new Map<string, ResolvedBuildTag>();
|
||||
|
||||
tags.forEach(tag => {
|
||||
const existing = bestByLabel.get(tag.label);
|
||||
if (!existing || tag.priority > existing.priority) {
|
||||
bestByLabel.set(tag.label, tag);
|
||||
}
|
||||
});
|
||||
|
||||
return [...bestByLabel.values()]
|
||||
.sort((left, right) => right.priority - left.priority || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.slice(0, MAX_ACTIVE_BUILD_TAGS);
|
||||
}
|
||||
|
||||
function averageAttributeVectors(vectors: AttributeVector[], slotIds: readonly string[]) {
|
||||
if (vectors.length === 0) {
|
||||
const evenShare = 1 / Math.max(slotIds.length, 1);
|
||||
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber(vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) / vectors.length, 4),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTagAffinity(tag: ResolvedBuildTag, schema: WorldAttributeSchema) {
|
||||
const definition = getBuildTagDefinition(tag.label);
|
||||
if (definition) {
|
||||
return getBuildTagAttributeSimilarityProfile(definition.id, schema);
|
||||
}
|
||||
|
||||
const relatedAffinities = (tag.relatedTags ?? []).flatMap(relatedTag => {
|
||||
const relatedDefinition = getBuildTagDefinition(relatedTag);
|
||||
if (!relatedDefinition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema).rawSimilarity];
|
||||
});
|
||||
|
||||
const rawSimilarity = averageAttributeVectors(relatedAffinities, schema.slots.map(slot => slot.slotId));
|
||||
|
||||
return {
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(rawSimilarity, schema.slots.map(slot => slot.slotId)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttributeContributions(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
tagAffinity: AttributeVector,
|
||||
schema: WorldAttributeSchema,
|
||||
sourceCoefficient: number,
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const attributeWeights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedAffinity = normalizeAttributeVector(tagAffinity ?? {}, slotIds);
|
||||
const attributeContributions = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber((attributeWeights[slotId] ?? 0) * (normalizedAffinity[slotId] ?? 0), 4),
|
||||
]),
|
||||
);
|
||||
const attributeModifierDeltas = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber(BASE_TAG_BONUS * sourceCoefficient * (attributeContributions[slotId] ?? 0), 4),
|
||||
]),
|
||||
);
|
||||
const fitScore = roundNumber(
|
||||
slotIds.reduce((sum, slotId) => sum + (attributeContributions[slotId] ?? 0), 0),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
fitScore,
|
||||
attributeWeights,
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBreakdownFromTags(
|
||||
tags: ResolvedBuildTag[],
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
): BuildDamageBreakdown {
|
||||
if (tags.length === 0) {
|
||||
return {
|
||||
tags: [],
|
||||
baseTagCount: 0,
|
||||
buildDamageBonus: 0,
|
||||
buildDamageMultiplier: 1,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const rows = tags.map(currentTag => {
|
||||
const tagAffinity = resolveTagAffinity(currentTag, schema);
|
||||
const sourceCoefficient = getSourceCoefficient(currentTag.source);
|
||||
const {
|
||||
fitScore,
|
||||
attributeWeights,
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} = buildAttributeContributions(profile, tagAffinity.normalizedSimilarity, schema, sourceCoefficient);
|
||||
const bonusDelta = roundNumber(
|
||||
Object.values(attributeModifierDeltas).reduce((sum, value) => sum + value, 0),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
label: currentTag.label,
|
||||
source: currentTag.source,
|
||||
fitScore,
|
||||
sourceCoefficient,
|
||||
bonusDelta,
|
||||
attributeSimilarities: normalizedAffinity,
|
||||
attributeWeights,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} satisfies BuildContributionRow;
|
||||
});
|
||||
|
||||
const buildDamageBonus = roundNumber(
|
||||
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, MAX_BUILD_BONUS),
|
||||
4,
|
||||
);
|
||||
const buildDamageMultiplier = roundNumber(1 + buildDamageBonus, 4);
|
||||
|
||||
return {
|
||||
tags: tags.map(tag => tag.label),
|
||||
baseTagCount: tags.length,
|
||||
buildDamageBonus,
|
||||
buildDamageMultiplier,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuildSourceLabel(source: BuildTagSource) {
|
||||
switch (source) {
|
||||
case 'buff':
|
||||
return '\u589e\u76ca';
|
||||
case 'character':
|
||||
return '\u89d2\u8272\u56fa\u6709';
|
||||
case 'weapon':
|
||||
return '\u6b66\u5668';
|
||||
case 'armor':
|
||||
return '\u62a4\u7532';
|
||||
case 'relic':
|
||||
return '\u9970\u54c1';
|
||||
case 'set':
|
||||
return '\u5957\u88c5';
|
||||
case 'monster':
|
||||
return '\u602a\u7269';
|
||||
default:
|
||||
return '\u6807\u7b7e';
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuildContributionAttributeRows(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
minimumValue = 0.0001,
|
||||
) {
|
||||
const totalModifierDelta = Object.values(row.attributeModifierDeltas ?? {}).reduce((sum, value) => sum + value, 0);
|
||||
|
||||
return schema.slots
|
||||
.map(slot => {
|
||||
const value = roundNumber(row.attributeContributions[slot.slotId] ?? 0, 4);
|
||||
const modifierDelta = roundNumber(row.attributeModifierDeltas?.[slot.slotId] ?? 0, 4);
|
||||
const percent = totalModifierDelta > 0 ? roundNumber(modifierDelta / totalModifierDelta, 4) : 0;
|
||||
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
label: slot.name,
|
||||
definition: slot.definition,
|
||||
similarity: roundNumber(row.attributeSimilarities?.[slot.slotId] ?? 0, 4),
|
||||
weight: roundNumber(row.attributeWeights?.[slot.slotId] ?? 0, 4),
|
||||
value,
|
||||
modifierDelta,
|
||||
percent,
|
||||
} satisfies BuildContributionAttributeRow;
|
||||
})
|
||||
.filter(entry => entry.value > minimumValue || entry.modifierDelta > minimumValue)
|
||||
.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function describeBuildContribution(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 2,
|
||||
) {
|
||||
const topRows = getBuildContributionAttributeRows(row, schema).slice(0, limit);
|
||||
if (topRows.length === 0) {
|
||||
return '\u5f53\u524d\u5c5e\u6027\u9002\u914d\u8f83\u5f31';
|
||||
}
|
||||
|
||||
if (topRows.length === 1) {
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc`;
|
||||
}
|
||||
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc\uff0c${topRows[1]?.label ?? '\u8f85\u52a9\u5c5e\u6027'}\u8f85\u52a9`;
|
||||
}
|
||||
|
||||
function getPlayerBuffs(gameState: GameState) {
|
||||
return (gameState.activeBuildBuffs ?? []).filter(buff => (buff.durationTurns ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function tickBuildBuffs(buffs: TimedBuildBuff[] | null | undefined) {
|
||||
return (buffs ?? [])
|
||||
.map(buff => ({
|
||||
...buff,
|
||||
durationTurns: Math.max(0, (buff.durationTurns ?? 0) - 1),
|
||||
}))
|
||||
.filter(buff => buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function appendBuildBuffs(
|
||||
baseBuffs: TimedBuildBuff[] | null | undefined,
|
||||
additions: TimedBuildBuff[] | null | undefined,
|
||||
) {
|
||||
const merged = new Map<string, TimedBuildBuff>();
|
||||
|
||||
[...(baseBuffs ?? []), ...(additions ?? [])].forEach(buff => {
|
||||
const existing = merged.get(buff.id);
|
||||
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
|
||||
merged.set(buff.id, {
|
||||
...buff,
|
||||
tags: normalizeBuildTags(buff.tags),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.values()].filter(buff => buff.tags.length > 0 && buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function getPlayerBuildDamageBreakdown(gameState: GameState, character: Character) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach(tag => pushTag(tags, tag, 'buff', 100));
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
getLoadoutBuildTags(gameState.playerEquipment).forEach(tag => tags.push(tag));
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, gameState.worldType, gameState.customWorldProfile),
|
||||
resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCompanionBuildDamageBreakdown(
|
||||
character: Character,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
const resolvedWorldType = worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, resolvedWorldType, customWorldProfile),
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMonsterBuildDamageBreakdown(
|
||||
monster: SceneMonster,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getSceneMonsterCombatTags(monster).forEach(tag => pushTag(tags, tag, 'monster', 90));
|
||||
const resolvedWorldType = worldType
|
||||
?? (monster.attributeProfile?.schemaId?.includes('xianxia') ? WorldType.XIANXIA : customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
monster.attributeProfile ?? null,
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateOutgoingDamage(
|
||||
baseDamage: number,
|
||||
options: {
|
||||
functionMultiplier?: number;
|
||||
equipmentMultiplier?: number;
|
||||
buildMultiplier?: number;
|
||||
} = {},
|
||||
) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
baseDamage
|
||||
* (options.functionMultiplier ?? 1)
|
||||
* (options.equipmentMultiplier ?? 1)
|
||||
* (options.buildMultiplier ?? 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePlayerOutgoingDamage(
|
||||
gameState: GameState,
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
) {
|
||||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCompanionOutgoingDamage(
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(character, worldType, customWorldProfile);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMonsterOutgoingDamage(
|
||||
monster: SceneMonster,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getMonsterBuildDamageBreakdown(monster, worldType, customWorldProfile);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
184
src/data/buildTagAttributeAffinity.ts
Normal file
184
src/data/buildTagAttributeAffinity.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {AttributeVector, WorldAttributeSchema, WorldAttributeSlot} from '../types';
|
||||
import {normalizeAttributeVector} from './attributeValidation';
|
||||
|
||||
type BuildTagAttributeAffinityMap = Record<string, AttributeVector>;
|
||||
|
||||
type SemanticAxisRule = {
|
||||
axisId: string;
|
||||
patterns: RegExp[];
|
||||
};
|
||||
|
||||
const SEMANTIC_SLOT_RULES: SemanticAxisRule[] = [
|
||||
{
|
||||
axisId: 'axis_a',
|
||||
patterns: [/骨|躯|甲|壳|体|锋|承压|抗压|结构|根基|底子|扛住|稳固|硬碰|机锋|潮骨|界躯|道骨|骨势/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_b',
|
||||
patterns: [/步|身法|位移|换位|机动|迅|闪|轻灵|抢位|转场|穿梭|步准|浪步|裂步|灵行/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_c',
|
||||
patterns: [/识|察|算|谋|阵|符|术|洞察|解析|看穿|辨认|因果|规律|算识|舟识|界识|识海|眼脉/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_d',
|
||||
patterns: [/压|魄|焰|胆|威|决|推进|强推|压迫|定调|逼出|逆转|潮压|潮魄|界压|劫纹|心焰/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_e',
|
||||
patterns: [/缘|契|盟|信|交|助|协|共鸣|共振|联结|牵引|交换|安抚|协频|契汐|缚契|尘缘|心契/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_f',
|
||||
patterns: [/息|稳|续|回|养|持久|恢复|调息|循环|续航|回稳|稳态|续载|回澜|回脉|玄息/u],
|
||||
},
|
||||
];
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function affinity(
|
||||
strength: number,
|
||||
agility: number,
|
||||
intelligence: number,
|
||||
spirit: number,
|
||||
): AttributeVector {
|
||||
return {
|
||||
axis_a: roundNumber(strength * 0.72 + spirit * 0.28),
|
||||
axis_b: roundNumber(agility * 0.88 + intelligence * 0.12),
|
||||
axis_c: roundNumber(intelligence * 0.78 + agility * 0.22),
|
||||
axis_d: roundNumber(strength * 0.62 + agility * 0.18 + intelligence * 0.2),
|
||||
axis_e: roundNumber(spirit * 0.72 + intelligence * 0.28),
|
||||
axis_f: roundNumber(spirit * 0.74 + strength * 0.26),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
|
||||
const sourceText = [
|
||||
slot.slotId,
|
||||
slot.name,
|
||||
slot.definition,
|
||||
slot.combatUseText,
|
||||
slot.socialUseText,
|
||||
slot.explorationUseText,
|
||||
...(slot.positiveSignals ?? []),
|
||||
...(slot.negativeSignals ?? []),
|
||||
].join(' ');
|
||||
|
||||
const semanticVector: AttributeVector = {};
|
||||
|
||||
SEMANTIC_SLOT_RULES.forEach((rule, ruleIndex) => {
|
||||
let score = 0;
|
||||
|
||||
if (slot.slotId === rule.axisId) {
|
||||
score += 2.2;
|
||||
} else if (slot.slotId === `axis_${String.fromCharCode(97 + ruleIndex)}`) {
|
||||
score += 1.2;
|
||||
}
|
||||
|
||||
score += rule.patterns.reduce((sum, pattern) => sum + (pattern.test(sourceText) ? 1 : 0), 0);
|
||||
|
||||
if (score > 0) {
|
||||
semanticVector[rule.axisId] = roundNumber(score, 4);
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(semanticVector).length === 0) {
|
||||
const fallbackAxisId = SEMANTIC_SLOT_RULES[index]?.axisId ?? slot.slotId;
|
||||
semanticVector[fallbackAxisId] = 1;
|
||||
}
|
||||
|
||||
return normalizeAttributeVector(
|
||||
semanticVector,
|
||||
SEMANTIC_SLOT_RULES.map(rule => rule.axisId),
|
||||
);
|
||||
}
|
||||
|
||||
function calculateVectorSimilarity(left: AttributeVector, right: AttributeVector) {
|
||||
return roundNumber(
|
||||
Object.keys({...left, ...right}).reduce(
|
||||
(sum, key) => sum + ((left[key] ?? 0) * (right[key] ?? 0)),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSchemaAwareAffinity(tagAffinity: AttributeVector, schema: WorldAttributeSchema) {
|
||||
const rawSimilarity = Object.fromEntries(
|
||||
schema.slots.map((slot, index) => [
|
||||
slot.slotId,
|
||||
calculateVectorSimilarity(tagAffinity, buildSlotSemanticVector(slot, index)),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(
|
||||
rawSimilarity,
|
||||
schema.slots.map(slot => slot.slotId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const BUILD_TAG_ATTRIBUTE_AFFINITY: BuildTagAttributeAffinityMap = {
|
||||
quickblade: affinity(0.35, 1, 0.1, 0.05),
|
||||
combo: affinity(0.3, 0.92, 0.18, 0.08),
|
||||
dash: affinity(0.45, 0.95, 0, 0),
|
||||
pursuit: affinity(0.38, 0.88, 0.08, 0.02),
|
||||
swiftstrike: affinity(0.22, 0.98, 0.12, 0.04),
|
||||
ranged: affinity(0.18, 0.82, 0.34, 0.08),
|
||||
guerrilla: affinity(0.24, 0.9, 0.28, 0.12),
|
||||
mobility: affinity(0.18, 1, 0.08, 0.08),
|
||||
windrun: affinity(0.08, 1, 0.1, 0.1),
|
||||
heavyhit: affinity(1, 0.28, 0.02, 0.04),
|
||||
burst: affinity(0.72, 0.58, 0.36, 0.08),
|
||||
armorbreak: affinity(0.92, 0.28, 0.08, 0.02),
|
||||
pressure: affinity(0.62, 0.64, 0.1, 0.08),
|
||||
bloodrush: affinity(0.84, 0.54, 0.04, 0.18),
|
||||
guard: affinity(0.7, 0.18, 0.04, 0.72),
|
||||
barrier: affinity(0.48, 0.08, 0.2, 0.92),
|
||||
heavyarmor: affinity(0.88, 0.04, 0.02, 0.54),
|
||||
counter: affinity(0.66, 0.46, 0.14, 0.36),
|
||||
banish: affinity(0.24, 0.06, 0.54, 0.88),
|
||||
caster: affinity(0, 0.1, 1, 0.6),
|
||||
mana: affinity(0.02, 0.08, 0.94, 0.74),
|
||||
thunder: affinity(0.06, 0.24, 0.96, 0.42),
|
||||
formation: affinity(0.08, 0.12, 0.82, 0.96),
|
||||
control: affinity(0.12, 0.34, 0.78, 0.72),
|
||||
overload: affinity(0.14, 0.18, 0.92, 0.38),
|
||||
heal: affinity(0.02, 0.08, 0.56, 1),
|
||||
support: affinity(0.14, 0.14, 0.58, 0.98),
|
||||
sustain: affinity(0.34, 0.18, 0.22, 0.9),
|
||||
fate: affinity(0.08, 0.22, 0.72, 0.84),
|
||||
fortune: affinity(0.06, 0.34, 0.7, 0.78),
|
||||
cooldown: affinity(0.04, 0.46, 0.82, 0.4),
|
||||
command: affinity(0.38, 0.26, 0.72, 0.82),
|
||||
balanced: affinity(0.58, 0.58, 0.58, 0.58),
|
||||
craft: affinity(0.24, 0.16, 0.74, 0.5),
|
||||
alchemy: affinity(0.08, 0.16, 0.84, 0.76),
|
||||
vanguard: affinity(0.82, 0.44, 0.08, 0.34),
|
||||
berserk: affinity(0.98, 0.42, 0, 0.22),
|
||||
spellblade: affinity(0.42, 0.42, 0.88, 0.38),
|
||||
paladin: affinity(0.58, 0.12, 0.42, 0.96),
|
||||
fortress: affinity(0.94, 0.04, 0.08, 0.82),
|
||||
starter: affinity(0.42, 0.42, 0.42, 0.42),
|
||||
};
|
||||
|
||||
export function getBuildTagAttributeAffinity(tagId: string, schema?: WorldAttributeSchema) {
|
||||
const semanticAffinity = BUILD_TAG_ATTRIBUTE_AFFINITY[tagId] ?? affinity(0.4, 0.4, 0.4, 0.4);
|
||||
|
||||
if (!schema) {
|
||||
return semanticAffinity;
|
||||
}
|
||||
|
||||
return resolveSchemaAwareAffinity(semanticAffinity, schema).normalizedSimilarity;
|
||||
}
|
||||
|
||||
export function getBuildTagAttributeSimilarityProfile(tagId: string, schema: WorldAttributeSchema) {
|
||||
const semanticAffinity = BUILD_TAG_ATTRIBUTE_AFFINITY[tagId] ?? affinity(0.4, 0.4, 0.4, 0.4);
|
||||
return resolveSchemaAwareAffinity(semanticAffinity, schema);
|
||||
}
|
||||
822
src/data/buildTagSimilarity.generated.ts
Normal file
822
src/data/buildTagSimilarity.generated.ts
Normal file
@@ -0,0 +1,822 @@
|
||||
export const BUILD_TAG_SIMILARITY_PAIRS: Array<readonly [string, string, number]> = [
|
||||
['快剑', '连段', 0.5652],
|
||||
['快剑', '突进', 0.639],
|
||||
['快剑', '追击', 0.5853],
|
||||
['快剑', '快袭', 0.7138],
|
||||
['快剑', '远射', 0.5732],
|
||||
['快剑', '游击', 0.5988],
|
||||
['快剑', '机动', 0.6052],
|
||||
['快剑', '风行', 0.6161],
|
||||
['快剑', '重击', 0.6202],
|
||||
['快剑', '爆发', 0.5647],
|
||||
['快剑', '破甲', 0.614],
|
||||
['快剑', '压制', 0.4927],
|
||||
['快剑', '压血', 0.4749],
|
||||
['快剑', '守御', 0.5533],
|
||||
['快剑', '护体', 0.4756],
|
||||
['快剑', '重甲', 0.5429],
|
||||
['快剑', '反击', 0.5565],
|
||||
['快剑', '镇邪', 0.4676],
|
||||
['快剑', '法修', 0.4834],
|
||||
['快剑', '法力', 0.4449],
|
||||
['快剑', '雷法', 0.5596],
|
||||
['快剑', '符阵', 0.502],
|
||||
['快剑', '控场', 0.4504],
|
||||
['快剑', '过载', 0.488],
|
||||
['快剑', '回复', 0.4804],
|
||||
['快剑', '护持', 0.4336],
|
||||
['快剑', '续战', 0.5132],
|
||||
['快剑', '命纹', 0.4916],
|
||||
['快剑', '机缘', 0.3826],
|
||||
['快剑', '冷却', 0.5143],
|
||||
['快剑', '统御', 0.4761],
|
||||
['快剑', '均衡', 0.4664],
|
||||
['快剑', '工巧', 0.4952],
|
||||
['快剑', '炼药', 0.496],
|
||||
['快剑', '先锋', 0.4771],
|
||||
['快剑', '狂战', 0.647],
|
||||
['快剑', '法剑', 0.7139],
|
||||
['快剑', '圣佑', 0.4072],
|
||||
['快剑', '堡垒', 0.5619],
|
||||
['快剑', '起手', 0.4805],
|
||||
['连段', '突进', 0.6269],
|
||||
['连段', '追击', 0.689],
|
||||
['连段', '快袭', 0.6255],
|
||||
['连段', '远射', 0.5834],
|
||||
['连段', '游击', 0.6451],
|
||||
['连段', '机动', 0.608],
|
||||
['连段', '风行', 0.5415],
|
||||
['连段', '重击', 0.6386],
|
||||
['连段', '爆发', 0.645],
|
||||
['连段', '破甲', 0.6281],
|
||||
['连段', '压制', 0.6701],
|
||||
['连段', '压血', 0.5097],
|
||||
['连段', '守御', 0.5739],
|
||||
['连段', '护体', 0.5288],
|
||||
['连段', '重甲', 0.5223],
|
||||
['连段', '反击', 0.6474],
|
||||
['连段', '镇邪', 0.4724],
|
||||
['连段', '法修', 0.5649],
|
||||
['连段', '法力', 0.5241],
|
||||
['连段', '雷法', 0.5451],
|
||||
['连段', '符阵', 0.5724],
|
||||
['连段', '控场', 0.6077],
|
||||
['连段', '过载', 0.5676],
|
||||
['连段', '回复', 0.5579],
|
||||
['连段', '护持', 0.5517],
|
||||
['连段', '续战', 0.6264],
|
||||
['连段', '命纹', 0.5979],
|
||||
['连段', '机缘', 0.4965],
|
||||
['连段', '冷却', 0.5609],
|
||||
['连段', '统御', 0.5969],
|
||||
['连段', '均衡', 0.5373],
|
||||
['连段', '工巧', 0.5193],
|
||||
['连段', '炼药', 0.5167],
|
||||
['连段', '先锋', 0.5145],
|
||||
['连段', '狂战', 0.5901],
|
||||
['连段', '法剑', 0.5712],
|
||||
['连段', '圣佑', 0.4439],
|
||||
['连段', '堡垒', 0.5764],
|
||||
['连段', '起手', 0.5323],
|
||||
['突进', '追击', 0.7523],
|
||||
['突进', '快袭', 0.7773],
|
||||
['突进', '远射', 0.681],
|
||||
['突进', '游击', 0.7741],
|
||||
['突进', '机动', 0.7414],
|
||||
['突进', '风行', 0.6838],
|
||||
['突进', '重击', 0.6753],
|
||||
['突进', '爆发', 0.679],
|
||||
['突进', '破甲', 0.7068],
|
||||
['突进', '压制', 0.6313],
|
||||
['突进', '压血', 0.5655],
|
||||
['突进', '守御', 0.6259],
|
||||
['突进', '护体', 0.5488],
|
||||
['突进', '重甲', 0.564],
|
||||
['突进', '反击', 0.6671],
|
||||
['突进', '镇邪', 0.5221],
|
||||
['突进', '法修', 0.5476],
|
||||
['突进', '法力', 0.4857],
|
||||
['突进', '雷法', 0.5302],
|
||||
['突进', '符阵', 0.6334],
|
||||
['突进', '控场', 0.5836],
|
||||
['突进', '过载', 0.6108],
|
||||
['突进', '回复', 0.589],
|
||||
['突进', '护持', 0.5765],
|
||||
['突进', '续战', 0.622],
|
||||
['突进', '命纹', 0.5123],
|
||||
['突进', '机缘', 0.546],
|
||||
['突进', '冷却', 0.5704],
|
||||
['突进', '统御', 0.5823],
|
||||
['突进', '均衡', 0.5443],
|
||||
['突进', '工巧', 0.5262],
|
||||
['突进', '炼药', 0.5517],
|
||||
['突进', '先锋', 0.6528],
|
||||
['突进', '狂战', 0.7068],
|
||||
['突进', '法剑', 0.5439],
|
||||
['突进', '圣佑', 0.4391],
|
||||
['突进', '堡垒', 0.6254],
|
||||
['突进', '起手', 0.6149],
|
||||
['追击', '快袭', 0.7158],
|
||||
['追击', '远射', 0.667],
|
||||
['追击', '游击', 0.7451],
|
||||
['追击', '机动', 0.6764],
|
||||
['追击', '风行', 0.6563],
|
||||
['追击', '重击', 0.65],
|
||||
['追击', '爆发', 0.6452],
|
||||
['追击', '破甲', 0.672],
|
||||
['追击', '压制', 0.6919],
|
||||
['追击', '压血', 0.5459],
|
||||
['追击', '守御', 0.6761],
|
||||
['追击', '护体', 0.6079],
|
||||
['追击', '重甲', 0.521],
|
||||
['追击', '反击', 0.7187],
|
||||
['追击', '镇邪', 0.5213],
|
||||
['追击', '法修', 0.5634],
|
||||
['追击', '法力', 0.5432],
|
||||
['追击', '雷法', 0.5044],
|
||||
['追击', '符阵', 0.6157],
|
||||
['追击', '控场', 0.5958],
|
||||
['追击', '过载', 0.6295],
|
||||
['追击', '回复', 0.6354],
|
||||
['追击', '护持', 0.6463],
|
||||
['追击', '续战', 0.7218],
|
||||
['追击', '命纹', 0.5615],
|
||||
['追击', '机缘', 0.5268],
|
||||
['追击', '冷却', 0.6234],
|
||||
['追击', '统御', 0.6099],
|
||||
['追击', '均衡', 0.5722],
|
||||
['追击', '工巧', 0.4781],
|
||||
['追击', '炼药', 0.5361],
|
||||
['追击', '先锋', 0.5768],
|
||||
['追击', '狂战', 0.6197],
|
||||
['追击', '法剑', 0.5672],
|
||||
['追击', '圣佑', 0.5151],
|
||||
['追击', '堡垒', 0.5949],
|
||||
['追击', '起手', 0.5904],
|
||||
['快袭', '远射', 0.6641],
|
||||
['快袭', '游击', 0.7421],
|
||||
['快袭', '机动', 0.6528],
|
||||
['快袭', '风行', 0.6794],
|
||||
['快袭', '重击', 0.7028],
|
||||
['快袭', '爆发', 0.6717],
|
||||
['快袭', '破甲', 0.6879],
|
||||
['快袭', '压制', 0.6159],
|
||||
['快袭', '压血', 0.5831],
|
||||
['快袭', '守御', 0.6133],
|
||||
['快袭', '护体', 0.5434],
|
||||
['快袭', '重甲', 0.5328],
|
||||
['快袭', '反击', 0.6898],
|
||||
['快袭', '镇邪', 0.5335],
|
||||
['快袭', '法修', 0.5486],
|
||||
['快袭', '法力', 0.5028],
|
||||
['快袭', '雷法', 0.593],
|
||||
['快袭', '符阵', 0.5869],
|
||||
['快袭', '控场', 0.5595],
|
||||
['快袭', '过载', 0.5888],
|
||||
['快袭', '回复', 0.5748],
|
||||
['快袭', '护持', 0.5483],
|
||||
['快袭', '续战', 0.5473],
|
||||
['快袭', '命纹', 0.5426],
|
||||
['快袭', '机缘', 0.4854],
|
||||
['快袭', '冷却', 0.5745],
|
||||
['快袭', '统御', 0.5406],
|
||||
['快袭', '均衡', 0.5206],
|
||||
['快袭', '工巧', 0.5192],
|
||||
['快袭', '炼药', 0.5656],
|
||||
['快袭', '先锋', 0.5619],
|
||||
['快袭', '狂战', 0.6826],
|
||||
['快袭', '法剑', 0.5964],
|
||||
['快袭', '圣佑', 0.4988],
|
||||
['快袭', '堡垒', 0.5941],
|
||||
['快袭', '起手', 0.5629],
|
||||
['远射', '游击', 0.6795],
|
||||
['远射', '机动', 0.6245],
|
||||
['远射', '风行', 0.5979],
|
||||
['远射', '重击', 0.6841],
|
||||
['远射', '爆发', 0.5983],
|
||||
['远射', '破甲', 0.5703],
|
||||
['远射', '压制', 0.5474],
|
||||
['远射', '压血', 0.4747],
|
||||
['远射', '守御', 0.5923],
|
||||
['远射', '护体', 0.5016],
|
||||
['远射', '重甲', 0.5697],
|
||||
['远射', '反击', 0.5669],
|
||||
['远射', '镇邪', 0.4811],
|
||||
['远射', '法修', 0.5174],
|
||||
['远射', '法力', 0.4697],
|
||||
['远射', '雷法', 0.5274],
|
||||
['远射', '符阵', 0.5635],
|
||||
['远射', '控场', 0.562],
|
||||
['远射', '过载', 0.526],
|
||||
['远射', '回复', 0.5325],
|
||||
['远射', '护持', 0.5249],
|
||||
['远射', '续战', 0.643],
|
||||
['远射', '命纹', 0.4921],
|
||||
['远射', '机缘', 0.4417],
|
||||
['远射', '冷却', 0.4675],
|
||||
['远射', '统御', 0.5189],
|
||||
['远射', '均衡', 0.5349],
|
||||
['远射', '工巧', 0.5065],
|
||||
['远射', '炼药', 0.5366],
|
||||
['远射', '先锋', 0.5259],
|
||||
['远射', '狂战', 0.5781],
|
||||
['远射', '法剑', 0.5516],
|
||||
['远射', '圣佑', 0.4589],
|
||||
['远射', '堡垒', 0.5505],
|
||||
['远射', '起手', 0.5292],
|
||||
['游击', '机动', 0.7143],
|
||||
['游击', '风行', 0.6702],
|
||||
['游击', '重击', 0.6773],
|
||||
['游击', '爆发', 0.6322],
|
||||
['游击', '破甲', 0.6881],
|
||||
['游击', '压制', 0.624],
|
||||
['游击', '压血', 0.5784],
|
||||
['游击', '守御', 0.6592],
|
||||
['游击', '护体', 0.5893],
|
||||
['游击', '重甲', 0.5299],
|
||||
['游击', '反击', 0.7005],
|
||||
['游击', '镇邪', 0.5426],
|
||||
['游击', '法修', 0.5741],
|
||||
['游击', '法力', 0.5228],
|
||||
['游击', '雷法', 0.523],
|
||||
['游击', '符阵', 0.6419],
|
||||
['游击', '控场', 0.6207],
|
||||
['游击', '过载', 0.5284],
|
||||
['游击', '回复', 0.556],
|
||||
['游击', '护持', 0.5766],
|
||||
['游击', '续战', 0.6038],
|
||||
['游击', '命纹', 0.5388],
|
||||
['游击', '机缘', 0.5112],
|
||||
['游击', '冷却', 0.554],
|
||||
['游击', '统御', 0.5779],
|
||||
['游击', '均衡', 0.5303],
|
||||
['游击', '工巧', 0.568],
|
||||
['游击', '炼药', 0.5643],
|
||||
['游击', '先锋', 0.6093],
|
||||
['游击', '狂战', 0.7051],
|
||||
['游击', '法剑', 0.6137],
|
||||
['游击', '圣佑', 0.4791],
|
||||
['游击', '堡垒', 0.6628],
|
||||
['游击', '起手', 0.5949],
|
||||
['机动', '风行', 0.779],
|
||||
['机动', '重击', 0.6363],
|
||||
['机动', '爆发', 0.627],
|
||||
['机动', '破甲', 0.6149],
|
||||
['机动', '压制', 0.6331],
|
||||
['机动', '压血', 0.5046],
|
||||
['机动', '守御', 0.6324],
|
||||
['机动', '护体', 0.5913],
|
||||
['机动', '重甲', 0.5902],
|
||||
['机动', '反击', 0.5831],
|
||||
['机动', '镇邪', 0.4794],
|
||||
['机动', '法修', 0.5937],
|
||||
['机动', '法力', 0.5797],
|
||||
['机动', '雷法', 0.5596],
|
||||
['机动', '符阵', 0.5786],
|
||||
['机动', '控场', 0.5914],
|
||||
['机动', '过载', 0.5971],
|
||||
['机动', '回复', 0.5985],
|
||||
['机动', '护持', 0.5888],
|
||||
['机动', '续战', 0.6009],
|
||||
['机动', '命纹', 0.565],
|
||||
['机动', '机缘', 0.5935],
|
||||
['机动', '冷却', 0.5674],
|
||||
['机动', '统御', 0.5976],
|
||||
['机动', '均衡', 0.5708],
|
||||
['机动', '工巧', 0.6219],
|
||||
['机动', '炼药', 0.5386],
|
||||
['机动', '先锋', 0.5903],
|
||||
['机动', '狂战', 0.6296],
|
||||
['机动', '法剑', 0.534],
|
||||
['机动', '圣佑', 0.4903],
|
||||
['机动', '堡垒', 0.5817],
|
||||
['机动', '起手', 0.5714],
|
||||
['风行', '重击', 0.5814],
|
||||
['风行', '爆发', 0.5888],
|
||||
['风行', '破甲', 0.5965],
|
||||
['风行', '压制', 0.5891],
|
||||
['风行', '压血', 0.4822],
|
||||
['风行', '守御', 0.5779],
|
||||
['风行', '护体', 0.563],
|
||||
['风行', '重甲', 0.5303],
|
||||
['风行', '反击', 0.5606],
|
||||
['风行', '镇邪', 0.4994],
|
||||
['风行', '法修', 0.5666],
|
||||
['风行', '法力', 0.5465],
|
||||
['风行', '雷法', 0.6185],
|
||||
['风行', '符阵', 0.6019],
|
||||
['风行', '控场', 0.5591],
|
||||
['风行', '过载', 0.5711],
|
||||
['风行', '回复', 0.5502],
|
||||
['风行', '护持', 0.5305],
|
||||
['风行', '续战', 0.5345],
|
||||
['风行', '命纹', 0.5222],
|
||||
['风行', '机缘', 0.4927],
|
||||
['风行', '冷却', 0.5579],
|
||||
['风行', '统御', 0.5759],
|
||||
['风行', '均衡', 0.5518],
|
||||
['风行', '工巧', 0.5349],
|
||||
['风行', '炼药', 0.4949],
|
||||
['风行', '先锋', 0.5716],
|
||||
['风行', '狂战', 0.637],
|
||||
['风行', '法剑', 0.5606],
|
||||
['风行', '圣佑', 0.4625],
|
||||
['风行', '堡垒', 0.5604],
|
||||
['风行', '起手', 0.5593],
|
||||
['重击', '爆发', 0.6945],
|
||||
['重击', '破甲', 0.6774],
|
||||
['重击', '压制', 0.6399],
|
||||
['重击', '压血', 0.5782],
|
||||
['重击', '守御', 0.6601],
|
||||
['重击', '护体', 0.5804],
|
||||
['重击', '重甲', 0.7689],
|
||||
['重击', '反击', 0.6758],
|
||||
['重击', '镇邪', 0.5743],
|
||||
['重击', '法修', 0.5315],
|
||||
['重击', '法力', 0.5534],
|
||||
['重击', '雷法', 0.6306],
|
||||
['重击', '符阵', 0.5642],
|
||||
['重击', '控场', 0.555],
|
||||
['重击', '过载', 0.603],
|
||||
['重击', '回复', 0.6222],
|
||||
['重击', '护持', 0.5424],
|
||||
['重击', '续战', 0.6046],
|
||||
['重击', '命纹', 0.5489],
|
||||
['重击', '机缘', 0.4778],
|
||||
['重击', '冷却', 0.5272],
|
||||
['重击', '统御', 0.5262],
|
||||
['重击', '均衡', 0.5299],
|
||||
['重击', '工巧', 0.5468],
|
||||
['重击', '炼药', 0.5696],
|
||||
['重击', '先锋', 0.5126],
|
||||
['重击', '狂战', 0.6859],
|
||||
['重击', '法剑', 0.593],
|
||||
['重击', '圣佑', 0.5253],
|
||||
['重击', '堡垒', 0.6473],
|
||||
['重击', '起手', 0.5027],
|
||||
['爆发', '破甲', 0.6471],
|
||||
['爆发', '压制', 0.6149],
|
||||
['爆发', '压血', 0.6011],
|
||||
['爆发', '守御', 0.6566],
|
||||
['爆发', '护体', 0.6024],
|
||||
['爆发', '重甲', 0.5939],
|
||||
['爆发', '反击', 0.6182],
|
||||
['爆发', '镇邪', 0.5866],
|
||||
['爆发', '法修', 0.5946],
|
||||
['爆发', '法力', 0.5942],
|
||||
['爆发', '雷法', 0.6125],
|
||||
['爆发', '符阵', 0.6034],
|
||||
['爆发', '控场', 0.553],
|
||||
['爆发', '过载', 0.6815],
|
||||
['爆发', '回复', 0.6327],
|
||||
['爆发', '护持', 0.5936],
|
||||
['爆发', '续战', 0.5908],
|
||||
['爆发', '命纹', 0.6207],
|
||||
['爆发', '机缘', 0.5688],
|
||||
['爆发', '冷却', 0.655],
|
||||
['爆发', '统御', 0.5539],
|
||||
['爆发', '均衡', 0.5714],
|
||||
['爆发', '工巧', 0.5236],
|
||||
['爆发', '炼药', 0.5637],
|
||||
['爆发', '先锋', 0.5253],
|
||||
['爆发', '狂战', 0.6509],
|
||||
['爆发', '法剑', 0.5871],
|
||||
['爆发', '圣佑', 0.5475],
|
||||
['爆发', '堡垒', 0.6038],
|
||||
['爆发', '起手', 0.578],
|
||||
['破甲', '压制', 0.6264],
|
||||
['破甲', '压血', 0.5547],
|
||||
['破甲', '守御', 0.7071],
|
||||
['破甲', '护体', 0.658],
|
||||
['破甲', '重甲', 0.7112],
|
||||
['破甲', '反击', 0.6969],
|
||||
['破甲', '镇邪', 0.6075],
|
||||
['破甲', '法修', 0.5574],
|
||||
['破甲', '法力', 0.5077],
|
||||
['破甲', '雷法', 0.5545],
|
||||
['破甲', '符阵', 0.6422],
|
||||
['破甲', '控场', 0.5732],
|
||||
['破甲', '过载', 0.5188],
|
||||
['破甲', '回复', 0.5907],
|
||||
['破甲', '护持', 0.5939],
|
||||
['破甲', '续战', 0.587],
|
||||
['破甲', '命纹', 0.5605],
|
||||
['破甲', '机缘', 0.4785],
|
||||
['破甲', '冷却', 0.5631],
|
||||
['破甲', '统御', 0.5967],
|
||||
['破甲', '均衡', 0.5164],
|
||||
['破甲', '工巧', 0.5391],
|
||||
['破甲', '炼药', 0.543],
|
||||
['破甲', '先锋', 0.6307],
|
||||
['破甲', '狂战', 0.6562],
|
||||
['破甲', '法剑', 0.6461],
|
||||
['破甲', '圣佑', 0.542],
|
||||
['破甲', '堡垒', 0.6839],
|
||||
['破甲', '起手', 0.562],
|
||||
['压制', '压血', 0.6276],
|
||||
['压制', '守御', 0.6984],
|
||||
['压制', '护体', 0.6072],
|
||||
['压制', '重甲', 0.545],
|
||||
['压制', '反击', 0.7119],
|
||||
['压制', '镇邪', 0.5967],
|
||||
['压制', '法修', 0.553],
|
||||
['压制', '法力', 0.5656],
|
||||
['压制', '雷法', 0.5769],
|
||||
['压制', '符阵', 0.5715],
|
||||
['压制', '控场', 0.749],
|
||||
['压制', '过载', 0.6006],
|
||||
['压制', '回复', 0.5653],
|
||||
['压制', '护持', 0.6715],
|
||||
['压制', '续战', 0.5801],
|
||||
['压制', '命纹', 0.551],
|
||||
['压制', '机缘', 0.5549],
|
||||
['压制', '冷却', 0.5747],
|
||||
['压制', '统御', 0.6469],
|
||||
['压制', '均衡', 0.5886],
|
||||
['压制', '工巧', 0.5383],
|
||||
['压制', '炼药', 0.535],
|
||||
['压制', '先锋', 0.5772],
|
||||
['压制', '狂战', 0.5509],
|
||||
['压制', '法剑', 0.5397],
|
||||
['压制', '圣佑', 0.5325],
|
||||
['压制', '堡垒', 0.6023],
|
||||
['压制', '起手', 0.5394],
|
||||
['压血', '守御', 0.5914],
|
||||
['压血', '护体', 0.5175],
|
||||
['压血', '重甲', 0.5008],
|
||||
['压血', '反击', 0.6153],
|
||||
['压血', '镇邪', 0.5773],
|
||||
['压血', '法修', 0.4587],
|
||||
['压血', '法力', 0.4814],
|
||||
['压血', '雷法', 0.5198],
|
||||
['压血', '符阵', 0.5016],
|
||||
['压血', '控场', 0.5041],
|
||||
['压血', '过载', 0.5253],
|
||||
['压血', '回复', 0.5365],
|
||||
['压血', '护持', 0.515],
|
||||
['压血', '续战', 0.497],
|
||||
['压血', '命纹', 0.4709],
|
||||
['压血', '机缘', 0.4604],
|
||||
['压血', '冷却', 0.5177],
|
||||
['压血', '统御', 0.4647],
|
||||
['压血', '均衡', 0.4405],
|
||||
['压血', '工巧', 0.405],
|
||||
['压血', '炼药', 0.5014],
|
||||
['压血', '先锋', 0.5009],
|
||||
['压血', '狂战', 0.6444],
|
||||
['压血', '法剑', 0.5183],
|
||||
['压血', '圣佑', 0.4573],
|
||||
['压血', '堡垒', 0.5345],
|
||||
['压血', '起手', 0.4848],
|
||||
['守御', '护体', 0.7607],
|
||||
['守御', '重甲', 0.7127],
|
||||
['守御', '反击', 0.7066],
|
||||
['守御', '镇邪', 0.6594],
|
||||
['守御', '法修', 0.5957],
|
||||
['守御', '法力', 0.614],
|
||||
['守御', '雷法', 0.5409],
|
||||
['守御', '符阵', 0.6034],
|
||||
['守御', '控场', 0.6585],
|
||||
['守御', '过载', 0.5236],
|
||||
['守御', '回复', 0.5995],
|
||||
['守御', '护持', 0.7566],
|
||||
['守御', '续战', 0.6493],
|
||||
['守御', '命纹', 0.5943],
|
||||
['守御', '机缘', 0.494],
|
||||
['守御', '冷却', 0.5445],
|
||||
['守御', '统御', 0.6908],
|
||||
['守御', '均衡', 0.6017],
|
||||
['守御', '工巧', 0.5528],
|
||||
['守御', '炼药', 0.5365],
|
||||
['守御', '先锋', 0.665],
|
||||
['守御', '狂战', 0.62],
|
||||
['守御', '法剑', 0.6191],
|
||||
['守御', '圣佑', 0.665],
|
||||
['守御', '堡垒', 0.7582],
|
||||
['守御', '起手', 0.5109],
|
||||
['护体', '重甲', 0.6496],
|
||||
['护体', '反击', 0.6368],
|
||||
['护体', '镇邪', 0.6807],
|
||||
['护体', '法修', 0.6148],
|
||||
['护体', '法力', 0.6455],
|
||||
['护体', '雷法', 0.5794],
|
||||
['护体', '符阵', 0.6043],
|
||||
['护体', '控场', 0.5781],
|
||||
['护体', '过载', 0.5113],
|
||||
['护体', '回复', 0.5927],
|
||||
['护体', '护持', 0.7176],
|
||||
['护体', '续战', 0.5704],
|
||||
['护体', '命纹', 0.6048],
|
||||
['护体', '机缘', 0.4744],
|
||||
['护体', '冷却', 0.4956],
|
||||
['护体', '统御', 0.6238],
|
||||
['护体', '均衡', 0.5194],
|
||||
['护体', '工巧', 0.4965],
|
||||
['护体', '炼药', 0.5417],
|
||||
['护体', '先锋', 0.5728],
|
||||
['护体', '狂战', 0.5373],
|
||||
['护体', '法剑', 0.6043],
|
||||
['护体', '圣佑', 0.725],
|
||||
['护体', '堡垒', 0.6437],
|
||||
['护体', '起手', 0.4898],
|
||||
['重甲', '反击', 0.5685],
|
||||
['重甲', '镇邪', 0.556],
|
||||
['重甲', '法修', 0.4907],
|
||||
['重甲', '法力', 0.5033],
|
||||
['重甲', '雷法', 0.513],
|
||||
['重甲', '符阵', 0.5623],
|
||||
['重甲', '控场', 0.5215],
|
||||
['重甲', '过载', 0.5094],
|
||||
['重甲', '回复', 0.573],
|
||||
['重甲', '护持', 0.579],
|
||||
['重甲', '续战', 0.6005],
|
||||
['重甲', '命纹', 0.5357],
|
||||
['重甲', '机缘', 0.4124],
|
||||
['重甲', '冷却', 0.4427],
|
||||
['重甲', '统御', 0.5444],
|
||||
['重甲', '均衡', 0.5009],
|
||||
['重甲', '工巧', 0.5093],
|
||||
['重甲', '炼药', 0.5303],
|
||||
['重甲', '先锋', 0.5833],
|
||||
['重甲', '狂战', 0.6125],
|
||||
['重甲', '法剑', 0.5387],
|
||||
['重甲', '圣佑', 0.5324],
|
||||
['重甲', '堡垒', 0.7125],
|
||||
['重甲', '起手', 0.473],
|
||||
['反击', '镇邪', 0.6254],
|
||||
['反击', '法修', 0.5644],
|
||||
['反击', '法力', 0.543],
|
||||
['反击', '雷法', 0.5453],
|
||||
['反击', '符阵', 0.6064],
|
||||
['反击', '控场', 0.615],
|
||||
['反击', '过载', 0.5353],
|
||||
['反击', '回复', 0.5944],
|
||||
['反击', '护持', 0.6343],
|
||||
['反击', '续战', 0.576],
|
||||
['反击', '命纹', 0.5603],
|
||||
['反击', '机缘', 0.5539],
|
||||
['反击', '冷却', 0.5888],
|
||||
['反击', '统御', 0.5744],
|
||||
['反击', '均衡', 0.5154],
|
||||
['反击', '工巧', 0.4982],
|
||||
['反击', '炼药', 0.5217],
|
||||
['反击', '先锋', 0.5842],
|
||||
['反击', '狂战', 0.6543],
|
||||
['反击', '法剑', 0.581],
|
||||
['反击', '圣佑', 0.5798],
|
||||
['反击', '堡垒', 0.6901],
|
||||
['反击', '起手', 0.5609],
|
||||
['镇邪', '法修', 0.5702],
|
||||
['镇邪', '法力', 0.5942],
|
||||
['镇邪', '雷法', 0.6029],
|
||||
['镇邪', '符阵', 0.6614],
|
||||
['镇邪', '控场', 0.568],
|
||||
['镇邪', '过载', 0.5081],
|
||||
['镇邪', '回复', 0.5846],
|
||||
['镇邪', '护持', 0.5381],
|
||||
['镇邪', '续战', 0.4889],
|
||||
['镇邪', '命纹', 0.5353],
|
||||
['镇邪', '机缘', 0.457],
|
||||
['镇邪', '冷却', 0.5112],
|
||||
['镇邪', '统御', 0.5196],
|
||||
['镇邪', '均衡', 0.4713],
|
||||
['镇邪', '工巧', 0.4463],
|
||||
['镇邪', '炼药', 0.4968],
|
||||
['镇邪', '先锋', 0.4949],
|
||||
['镇邪', '狂战', 0.591],
|
||||
['镇邪', '法剑', 0.6089],
|
||||
['镇邪', '圣佑', 0.6872],
|
||||
['镇邪', '堡垒', 0.6238],
|
||||
['镇邪', '起手', 0.4864],
|
||||
['法修', '法力', 0.7208],
|
||||
['法修', '雷法', 0.6333],
|
||||
['法修', '符阵', 0.6298],
|
||||
['法修', '控场', 0.5764],
|
||||
['法修', '过载', 0.578],
|
||||
['法修', '回复', 0.5988],
|
||||
['法修', '护持', 0.5658],
|
||||
['法修', '续战', 0.541],
|
||||
['法修', '命纹', 0.6459],
|
||||
['法修', '机缘', 0.5116],
|
||||
['法修', '冷却', 0.5266],
|
||||
['法修', '统御', 0.6418],
|
||||
['法修', '均衡', 0.4631],
|
||||
['法修', '工巧', 0.5871],
|
||||
['法修', '炼药', 0.6282],
|
||||
['法修', '先锋', 0.5125],
|
||||
['法修', '狂战', 0.5753],
|
||||
['法修', '法剑', 0.7104],
|
||||
['法修', '圣佑', 0.5691],
|
||||
['法修', '堡垒', 0.5534],
|
||||
['法修', '起手', 0.5379],
|
||||
['法力', '雷法', 0.61],
|
||||
['法力', '符阵', 0.5773],
|
||||
['法力', '控场', 0.4987],
|
||||
['法力', '过载', 0.605],
|
||||
['法力', '回复', 0.598],
|
||||
['法力', '护持', 0.5795],
|
||||
['法力', '续战', 0.5531],
|
||||
['法力', '命纹', 0.6539],
|
||||
['法力', '机缘', 0.5452],
|
||||
['法力', '冷却', 0.5194],
|
||||
['法力', '统御', 0.6022],
|
||||
['法力', '均衡', 0.5077],
|
||||
['法力', '工巧', 0.5687],
|
||||
['法力', '炼药', 0.5806],
|
||||
['法力', '先锋', 0.4548],
|
||||
['法力', '狂战', 0.5491],
|
||||
['法力', '法剑', 0.6307],
|
||||
['法力', '圣佑', 0.5769],
|
||||
['法力', '堡垒', 0.5276],
|
||||
['法力', '起手', 0.4906],
|
||||
['雷法', '符阵', 0.597],
|
||||
['雷法', '控场', 0.5121],
|
||||
['雷法', '过载', 0.5585],
|
||||
['雷法', '回复', 0.5585],
|
||||
['雷法', '护持', 0.4631],
|
||||
['雷法', '续战', 0.4514],
|
||||
['雷法', '命纹', 0.5399],
|
||||
['雷法', '机缘', 0.4319],
|
||||
['雷法', '冷却', 0.4935],
|
||||
['雷法', '统御', 0.5187],
|
||||
['雷法', '均衡', 0.4183],
|
||||
['雷法', '工巧', 0.4696],
|
||||
['雷法', '炼药', 0.5434],
|
||||
['雷法', '先锋', 0.4505],
|
||||
['雷法', '狂战', 0.5692],
|
||||
['雷法', '法剑', 0.6448],
|
||||
['雷法', '圣佑', 0.5153],
|
||||
['雷法', '堡垒', 0.4846],
|
||||
['雷法', '起手', 0.4587],
|
||||
['符阵', '控场', 0.6108],
|
||||
['符阵', '过载', 0.5434],
|
||||
['符阵', '回复', 0.5793],
|
||||
['符阵', '护持', 0.5548],
|
||||
['符阵', '续战', 0.5653],
|
||||
['符阵', '命纹', 0.5807],
|
||||
['符阵', '机缘', 0.5305],
|
||||
['符阵', '冷却', 0.5247],
|
||||
['符阵', '统御', 0.596],
|
||||
['符阵', '均衡', 0.4758],
|
||||
['符阵', '工巧', 0.5118],
|
||||
['符阵', '炼药', 0.5587],
|
||||
['符阵', '先锋', 0.5208],
|
||||
['符阵', '狂战', 0.6399],
|
||||
['符阵', '法剑', 0.6213],
|
||||
['符阵', '圣佑', 0.5678],
|
||||
['符阵', '堡垒', 0.6326],
|
||||
['符阵', '起手', 0.5444],
|
||||
['控场', '过载', 0.5345],
|
||||
['控场', '回复', 0.5483],
|
||||
['控场', '护持', 0.5773],
|
||||
['控场', '续战', 0.5117],
|
||||
['控场', '命纹', 0.5334],
|
||||
['控场', '机缘', 0.5049],
|
||||
['控场', '冷却', 0.5248],
|
||||
['控场', '统御', 0.6024],
|
||||
['控场', '均衡', 0.5389],
|
||||
['控场', '工巧', 0.5029],
|
||||
['控场', '炼药', 0.4973],
|
||||
['控场', '先锋', 0.5409],
|
||||
['控场', '狂战', 0.5177],
|
||||
['控场', '法剑', 0.4913],
|
||||
['控场', '圣佑', 0.4957],
|
||||
['控场', '堡垒', 0.5744],
|
||||
['控场', '起手', 0.5304],
|
||||
['过载', '回复', 0.621],
|
||||
['过载', '护持', 0.523],
|
||||
['过载', '续战', 0.5862],
|
||||
['过载', '命纹', 0.4679],
|
||||
['过载', '机缘', 0.4845],
|
||||
['过载', '冷却', 0.6125],
|
||||
['过载', '统御', 0.5142],
|
||||
['过载', '均衡', 0.512],
|
||||
['过载', '工巧', 0.4477],
|
||||
['过载', '炼药', 0.5165],
|
||||
['过载', '先锋', 0.472],
|
||||
['过载', '狂战', 0.5719],
|
||||
['过载', '法剑', 0.5009],
|
||||
['过载', '圣佑', 0.4471],
|
||||
['过载', '堡垒', 0.4894],
|
||||
['过载', '起手', 0.5507],
|
||||
['回复', '护持', 0.6385],
|
||||
['回复', '续战', 0.7208],
|
||||
['回复', '命纹', 0.5697],
|
||||
['回复', '机缘', 0.5503],
|
||||
['回复', '冷却', 0.6266],
|
||||
['回复', '统御', 0.6014],
|
||||
['回复', '均衡', 0.5601],
|
||||
['回复', '工巧', 0.551],
|
||||
['回复', '炼药', 0.6223],
|
||||
['回复', '先锋', 0.49],
|
||||
['回复', '狂战', 0.5537],
|
||||
['回复', '法剑', 0.5084],
|
||||
['回复', '圣佑', 0.5796],
|
||||
['回复', '堡垒', 0.5532],
|
||||
['回复', '起手', 0.5605],
|
||||
['护持', '续战', 0.6602],
|
||||
['护持', '命纹', 0.5718],
|
||||
['护持', '机缘', 0.5891],
|
||||
['护持', '冷却', 0.5337],
|
||||
['护持', '统御', 0.6472],
|
||||
['护持', '均衡', 0.5856],
|
||||
['护持', '工巧', 0.5238],
|
||||
['护持', '炼药', 0.5062],
|
||||
['护持', '先锋', 0.6196],
|
||||
['护持', '狂战', 0.5233],
|
||||
['护持', '法剑', 0.4996],
|
||||
['护持', '圣佑', 0.6853],
|
||||
['护持', '堡垒', 0.6118],
|
||||
['护持', '起手', 0.522],
|
||||
['续战', '命纹', 0.5695],
|
||||
['续战', '机缘', 0.5065],
|
||||
['续战', '冷却', 0.5479],
|
||||
['续战', '统御', 0.6108],
|
||||
['续战', '均衡', 0.6118],
|
||||
['续战', '工巧', 0.4802],
|
||||
['续战', '炼药', 0.5326],
|
||||
['续战', '先锋', 0.5283],
|
||||
['续战', '狂战', 0.589],
|
||||
['续战', '法剑', 0.5245],
|
||||
['续战', '圣佑', 0.4996],
|
||||
['续战', '堡垒', 0.5803],
|
||||
['续战', '起手', 0.5138],
|
||||
['命纹', '机缘', 0.6391],
|
||||
['命纹', '冷却', 0.4859],
|
||||
['命纹', '统御', 0.5763],
|
||||
['命纹', '均衡', 0.4904],
|
||||
['命纹', '工巧', 0.5419],
|
||||
['命纹', '炼药', 0.5336],
|
||||
['命纹', '先锋', 0.4693],
|
||||
['命纹', '狂战', 0.5297],
|
||||
['命纹', '法剑', 0.5789],
|
||||
['命纹', '圣佑', 0.5901],
|
||||
['命纹', '堡垒', 0.5077],
|
||||
['命纹', '起手', 0.5058],
|
||||
['机缘', '冷却', 0.4758],
|
||||
['机缘', '统御', 0.5276],
|
||||
['机缘', '均衡', 0.5299],
|
||||
['机缘', '工巧', 0.5537],
|
||||
['机缘', '炼药', 0.4607],
|
||||
['机缘', '先锋', 0.4412],
|
||||
['机缘', '狂战', 0.4916],
|
||||
['机缘', '法剑', 0.4279],
|
||||
['机缘', '圣佑', 0.5334],
|
||||
['机缘', '堡垒', 0.4821],
|
||||
['机缘', '起手', 0.5271],
|
||||
['冷却', '统御', 0.4681],
|
||||
['冷却', '均衡', 0.5192],
|
||||
['冷却', '工巧', 0.4528],
|
||||
['冷却', '炼药', 0.5105],
|
||||
['冷却', '先锋', 0.4123],
|
||||
['冷却', '狂战', 0.5277],
|
||||
['冷却', '法剑', 0.4966],
|
||||
['冷却', '圣佑', 0.427],
|
||||
['冷却', '堡垒', 0.5101],
|
||||
['冷却', '起手', 0.5162],
|
||||
['统御', '均衡', 0.5641],
|
||||
['统御', '工巧', 0.5768],
|
||||
['统御', '炼药', 0.5346],
|
||||
['统御', '先锋', 0.633],
|
||||
['统御', '狂战', 0.5482],
|
||||
['统御', '法剑', 0.5855],
|
||||
['统御', '圣佑', 0.5481],
|
||||
['统御', '堡垒', 0.5774],
|
||||
['统御', '起手', 0.5428],
|
||||
['均衡', '工巧', 0.5957],
|
||||
['均衡', '炼药', 0.4911],
|
||||
['均衡', '先锋', 0.4931],
|
||||
['均衡', '狂战', 0.488],
|
||||
['均衡', '法剑', 0.5058],
|
||||
['均衡', '圣佑', 0.4665],
|
||||
['均衡', '堡垒', 0.5519],
|
||||
['均衡', '起手', 0.5537],
|
||||
['工巧', '炼药', 0.6352],
|
||||
['工巧', '先锋', 0.4415],
|
||||
['工巧', '狂战', 0.4979],
|
||||
['工巧', '法剑', 0.5548],
|
||||
['工巧', '圣佑', 0.4727],
|
||||
['工巧', '堡垒', 0.5285],
|
||||
['工巧', '起手', 0.5812],
|
||||
['炼药', '先锋', 0.4232],
|
||||
['炼药', '狂战', 0.5542],
|
||||
['炼药', '法剑', 0.5878],
|
||||
['炼药', '圣佑', 0.4755],
|
||||
['炼药', '堡垒', 0.5155],
|
||||
['炼药', '起手', 0.5478],
|
||||
['先锋', '狂战', 0.5637],
|
||||
['先锋', '法剑', 0.516],
|
||||
['先锋', '圣佑', 0.4738],
|
||||
['先锋', '堡垒', 0.6068],
|
||||
['先锋', '起手', 0.5946],
|
||||
['狂战', '法剑', 0.6253],
|
||||
['狂战', '圣佑', 0.4844],
|
||||
['狂战', '堡垒', 0.6703],
|
||||
['狂战', '起手', 0.5352],
|
||||
['法剑', '圣佑', 0.5668],
|
||||
['法剑', '堡垒', 0.5968],
|
||||
['法剑', '起手', 0.4903],
|
||||
['圣佑', '堡垒', 0.5733],
|
||||
['圣佑', '起手', 0.4104],
|
||||
['堡垒', '起手', 0.5186],
|
||||
] as const;
|
||||
297
src/data/buildTags.ts
Normal file
297
src/data/buildTags.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
BuildTagCategory,
|
||||
BuildTagDefinition,
|
||||
Character,
|
||||
SceneMonster,
|
||||
TimedBuildBuff,
|
||||
} from '../types';
|
||||
import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
|
||||
|
||||
type RawBuildTagDefinition = Omit<BuildTagDefinition, 'attributeAffinity'>;
|
||||
|
||||
const TAG_CATEGORIES = {
|
||||
flow: '娴佹淳' as BuildTagCategory,
|
||||
style: '椋庢牸' as BuildTagCategory,
|
||||
resource: '璧勬簮' as BuildTagCategory,
|
||||
defense: '闃插尽' as BuildTagCategory,
|
||||
element: '鍏冪礌' as BuildTagCategory,
|
||||
craft: '宸ヨ壓' as BuildTagCategory,
|
||||
} satisfies Record<string, BuildTagCategory>;
|
||||
|
||||
const RAW_BUILD_TAG_DEFINITIONS: RawBuildTagDefinition[] = [
|
||||
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: 'Fast melee pressure.' },
|
||||
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: 'Chain-hit rhythm and multi-stage output.' },
|
||||
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: 'Gap-closing and front-loaded engage.' },
|
||||
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: 'Chasing and follow-up punishment.' },
|
||||
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: 'Short-window assassination and weak-point bursts.' },
|
||||
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: 'Mid-long range damage with spacing.' },
|
||||
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: 'Hit-and-run skirmishing.' },
|
||||
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: 'High movement and repositioning.' },
|
||||
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: 'Light-footed speed advantage.' },
|
||||
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: 'Heavy swings and one-hit pressure.' },
|
||||
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: 'Short-window damage spikes.' },
|
||||
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: 'Breaking defense and hard targets.' },
|
||||
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: 'Tempo control through relentless offense.' },
|
||||
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: 'Trading safety for damage when low.' },
|
||||
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: 'Reliable defense and front-line stability.' },
|
||||
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: 'Barrier, protection, and status resistance.' },
|
||||
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: 'Armor hardness and standing power.' },
|
||||
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: 'Counterplay after blocks or openings.' },
|
||||
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: 'Suppressing curses and hostile energies.' },
|
||||
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: 'Spell-driven output and control.' },
|
||||
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: 'Mana pool, spend, and recovery loop.' },
|
||||
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: 'Lightning damage and instant suppression.' },
|
||||
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: 'Prepared effects and battlefield shaping.' },
|
||||
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: 'Restricting movement and action choices.' },
|
||||
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: 'High-cost high-output casting windows.' },
|
||||
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: 'Recovery and fight reset.' },
|
||||
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: 'Buffing and stabilizing allies.' },
|
||||
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: 'Long-fight consistency and tolerance.' },
|
||||
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: 'Marks, triggers, and destiny loops.' },
|
||||
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: 'Timing and fortunate trigger value.' },
|
||||
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: 'Faster rotation and recharge.' },
|
||||
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: 'Coordinating team actions.' },
|
||||
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: 'Generalist and low-risk growth.' },
|
||||
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: 'Crafting, devices, and engineered support.' },
|
||||
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: 'Potion and temporary enhancement making.' },
|
||||
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: 'Frontline initiative and lane opening.' },
|
||||
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: 'Risky offense for high return.' },
|
||||
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: 'Hybrid blade-and-magic combat.' },
|
||||
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: 'Protection, recovery, and holy punishment.' },
|
||||
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: 'Extreme defense and counter-anchoring.' },
|
||||
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: 'Early-shape and beginner-friendly setup.' },
|
||||
];
|
||||
|
||||
const BUILD_TAG_DEFINITIONS: BuildTagDefinition[] = RAW_BUILD_TAG_DEFINITIONS.map(definition => ({
|
||||
...definition,
|
||||
attributeAffinity: getBuildTagAttributeAffinity(definition.id),
|
||||
}));
|
||||
|
||||
const CHARACTER_BUILD_TAGS: Record<string, string[]> = {
|
||||
'sword-princess': ['快剑', '突进', '压制'],
|
||||
'archer-hero': ['远射', '游击', '风行'],
|
||||
'girl-hero': ['快袭', '连段', '追击'],
|
||||
'punch-hero': ['重击', '爆发', '压血'],
|
||||
'fighter-4': ['守御', '护体', '先锋'],
|
||||
};
|
||||
|
||||
const STRUCTURAL_BUILD_TAGS = new Set([
|
||||
'weapon',
|
||||
'armor',
|
||||
'relic',
|
||||
'material',
|
||||
'consumable',
|
||||
'rare',
|
||||
'wuxia',
|
||||
'xianxia',
|
||||
'neutral',
|
||||
'武器',
|
||||
'护甲',
|
||||
'饰品',
|
||||
'消耗品',
|
||||
'材料',
|
||||
'稀有品',
|
||||
]);
|
||||
|
||||
const aliasToCanonical = new Map<string, string>();
|
||||
const definitionByLabel = new Map<string, BuildTagDefinition>();
|
||||
|
||||
for (const definition of BUILD_TAG_DEFINITIONS) {
|
||||
definitionByLabel.set(definition.label, definition);
|
||||
aliasToCanonical.set(definition.label.toLowerCase(), definition.label);
|
||||
definition.aliases.forEach(alias => aliasToCanonical.set(alias.toLowerCase(), definition.label));
|
||||
}
|
||||
|
||||
function normalizeLookupValue(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function uniqueTags(tags: string[]) {
|
||||
return [...new Set(tags.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeAffinity(affinity: AttributeVector) {
|
||||
const values = Object.values(affinity);
|
||||
const magnitude = Math.hypot(...values);
|
||||
|
||||
if (magnitude <= 0.0001) {
|
||||
return Object.fromEntries(Object.keys(affinity).map(key => [key, 0]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(affinity).map(([key, value]) => [key, value / magnitude]),
|
||||
);
|
||||
}
|
||||
|
||||
function calculateAffinitySimilarity(left: AttributeVector, right: AttributeVector) {
|
||||
const normalizedLeft = normalizeAffinity(left);
|
||||
const normalizedRight = normalizeAffinity(right);
|
||||
|
||||
return Object.keys({...normalizedLeft, ...normalizedRight}).reduce(
|
||||
(sum, key) => sum + ((normalizedLeft[key] ?? 0) * (normalizedRight[key] ?? 0)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuildTagDefinitions() {
|
||||
return BUILD_TAG_DEFINITIONS.map(definition => ({
|
||||
...definition,
|
||||
aliases: [...definition.aliases],
|
||||
attributeAffinity: { ...definition.attributeAffinity },
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBuildTagDefinition(tag: string | null | undefined) {
|
||||
const normalized = normalizeBuildTag(tag);
|
||||
return normalized ? definitionByLabel.get(normalized) ?? null : null;
|
||||
}
|
||||
|
||||
export function normalizeBuildTag(tag: string | null | undefined) {
|
||||
const value = tag?.trim();
|
||||
if (!value) return null;
|
||||
if (STRUCTURAL_BUILD_TAGS.has(value) || /^set:/iu.test(value) || /^piece:/iu.test(value)) return null;
|
||||
|
||||
const canonical = aliasToCanonical.get(normalizeLookupValue(value));
|
||||
if (canonical) return canonical;
|
||||
|
||||
if (/^[\u4e00-\u9fa5]{2,6}$/u.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeBuildRole(role: string | null | undefined) {
|
||||
const normalized = normalizeBuildTag(role);
|
||||
if (normalized) return normalized;
|
||||
|
||||
const value = role?.trim();
|
||||
if (!value) return '均衡';
|
||||
if (/^[\u4e00-\u9fa5]{2,6}$/u.test(value)) return value;
|
||||
return '均衡';
|
||||
}
|
||||
|
||||
export function normalizeBuildTags(tags: string[] | null | undefined, maxCount?: number) {
|
||||
const normalized = uniqueTags((tags ?? [])
|
||||
.map(normalizeBuildTag)
|
||||
.filter((tag): tag is string => Boolean(tag)));
|
||||
|
||||
return typeof maxCount === 'number' ? normalized.slice(0, maxCount) : normalized;
|
||||
}
|
||||
|
||||
export function getBuildTagSimilarity(left: string, right: string) {
|
||||
const normalizedLeft = normalizeBuildTag(left);
|
||||
const normalizedRight = normalizeBuildTag(right);
|
||||
if (!normalizedLeft || !normalizedRight) return 0;
|
||||
if (normalizedLeft === normalizedRight) return 1;
|
||||
|
||||
const leftDefinition = definitionByLabel.get(normalizedLeft);
|
||||
const rightDefinition = definitionByLabel.get(normalizedRight);
|
||||
if (!leftDefinition || !rightDefinition) return 0;
|
||||
|
||||
const affinitySimilarity = calculateAffinitySimilarity(
|
||||
leftDefinition.attributeAffinity,
|
||||
rightDefinition.attributeAffinity,
|
||||
);
|
||||
const categoryBonus = leftDefinition.category === rightDefinition.category ? 0.08 : 0;
|
||||
|
||||
return Math.min(1, Number((affinitySimilarity + categoryBonus).toFixed(4)));
|
||||
}
|
||||
|
||||
export function getSimilarBuildTags(tag: string, minimumSimilarity = 0.55) {
|
||||
const normalized = normalizeBuildTag(tag);
|
||||
if (!normalized) return [];
|
||||
|
||||
return BUILD_TAG_DEFINITIONS
|
||||
.map(definition => definition.label)
|
||||
.filter(label => label !== normalized)
|
||||
.map(label => ({
|
||||
label,
|
||||
similarity: getBuildTagSimilarity(normalized, label),
|
||||
}))
|
||||
.filter(entry => entry.similarity >= minimumSimilarity)
|
||||
.sort((left, right) => right.similarity - left.similarity || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.map(entry => entry.label);
|
||||
}
|
||||
|
||||
export function getCharacterCombatTags(character: Character) {
|
||||
if (character.combatTags?.length) {
|
||||
return normalizeBuildTags(character.combatTags, 3);
|
||||
}
|
||||
|
||||
const fixedTags = CHARACTER_BUILD_TAGS[character.id];
|
||||
if (fixedTags) {
|
||||
return normalizeBuildTags(fixedTags, 3);
|
||||
}
|
||||
|
||||
const derivedTags: string[] = [];
|
||||
const styles = new Set(character.skills.map(skill => skill.style));
|
||||
|
||||
if (styles.has('mobility')) derivedTags.push('突进');
|
||||
if (styles.has('projectile')) derivedTags.push('远射');
|
||||
if (styles.has('finisher')) derivedTags.push('重击');
|
||||
if (styles.has('burst')) derivedTags.push('爆发');
|
||||
if (styles.has('steady')) derivedTags.push('连段');
|
||||
|
||||
const {strength, agility, intelligence, spirit} = character.attributes;
|
||||
|
||||
if (agility >= Math.max(strength, intelligence, spirit)) derivedTags.push('机动');
|
||||
if (intelligence + spirit >= strength + 2) derivedTags.push('法修');
|
||||
if (strength >= intelligence && strength >= spirit) derivedTags.push('压制');
|
||||
|
||||
return normalizeBuildTags(derivedTags, 3);
|
||||
}
|
||||
|
||||
function inferMonsterTagsFromText(source: string) {
|
||||
const tags: string[] = [];
|
||||
const normalized = source.toLowerCase();
|
||||
|
||||
if (/雷|lightning|thunder|storm/u.test(normalized)) tags.push('雷法');
|
||||
if (/阵|formation|sigil|seal/u.test(normalized)) tags.push('符阵');
|
||||
if (/控场|control|bind|freeze|curse/u.test(normalized)) tags.push('控场');
|
||||
if (/镇邪|holy|banish|purge/u.test(normalized)) tags.push('镇邪');
|
||||
if (/反击|counter|parry/u.test(normalized)) tags.push('反击');
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getSceneMonsterCombatTags(monster: SceneMonster) {
|
||||
if (monster.combatTags?.length) {
|
||||
return normalizeBuildTags(monster.combatTags, 3);
|
||||
}
|
||||
|
||||
const derivedTags: string[] = [];
|
||||
derivedTags.push(...inferMonsterTagsFromText(`${monster.name} ${monster.action} ${monster.description}`));
|
||||
|
||||
if (monster.speed >= 7) derivedTags.push('机动');
|
||||
if (monster.attackRange >= 1.6) derivedTags.push('突进');
|
||||
if (monster.hp >= 150) derivedTags.push('重击');
|
||||
if (monster.hp >= 170) derivedTags.push('守御');
|
||||
|
||||
return normalizeBuildTags(derivedTags, 3);
|
||||
}
|
||||
|
||||
export function getTimedBuildBuffTags(buffs: TimedBuildBuff[] | null | undefined) {
|
||||
return normalizeBuildTags(
|
||||
(buffs ?? [])
|
||||
.filter(buff => (buff.durationTurns ?? 0) > 0)
|
||||
.flatMap(buff => buff.tags),
|
||||
6,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSetBuildTagLabel(setName: string, pieceCount: number) {
|
||||
const normalizedName = setName
|
||||
.split('/')
|
||||
.map(part => part.trim())
|
||||
.find(Boolean) ?? setName.trim();
|
||||
|
||||
return pieceCount >= 3 ? `宗匠${normalizedName}` : `套装${normalizedName}`;
|
||||
}
|
||||
|
||||
export function buildEmbeddingPromptText(definition: BuildTagDefinition) {
|
||||
return `${definition.label}:${definition.description} 别名:${definition.aliases.join('、')}。属性亲和:${Object.entries(definition.attributeAffinity)
|
||||
.map(([slotId, value]) => `${slotId} ${value}`)
|
||||
.join(';')}。`;
|
||||
}
|
||||
127
src/data/characterCombat.ts
Normal file
127
src/data/characterCombat.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterAnimationConfig,
|
||||
CharacterSkillDefinition,
|
||||
CombatDelivery,
|
||||
SpriteSequenceDefinition,
|
||||
} from '../types';
|
||||
|
||||
const DEFAULT_FRAME_MS = 100;
|
||||
const DEFAULT_FPS = 10;
|
||||
|
||||
function getCharacterRoot(character: Character) {
|
||||
return `/character/${encodeURIComponent(character.assetFolder)}/${encodeURIComponent(character.assetVariant)}`;
|
||||
}
|
||||
|
||||
function buildFramesFromConfig(
|
||||
character: Character,
|
||||
config: CharacterAnimationConfig,
|
||||
folderPrefix = 'Hero',
|
||||
) {
|
||||
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
|
||||
const extension = config.extension ?? 'png';
|
||||
|
||||
if (normalizedBasePath) {
|
||||
if (config.file) {
|
||||
return [`${normalizedBasePath}/${encodeURIComponent(config.file)}`];
|
||||
}
|
||||
|
||||
const frames: string[] = [];
|
||||
const startFrame = config.startFrame ?? 1;
|
||||
|
||||
for (let index = 0; index < config.frames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${normalizedBasePath}/${config.prefix}${frameNumber}.${extension}`);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
const root = getCharacterRoot(character);
|
||||
const folder = encodeURIComponent(config.folder);
|
||||
if (config.file) {
|
||||
return [`${root}/${folderPrefix}/${folder}/${encodeURIComponent(config.file)}`];
|
||||
}
|
||||
const frames: string[] = [];
|
||||
const startFrame = config.startFrame ?? 1;
|
||||
|
||||
for (let index = 0; index < config.frames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${root}/${folderPrefix}/${folder}/${config.prefix}${frameNumber}.${extension}`);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
function buildFramesFromAsset(
|
||||
character: Character,
|
||||
sequence: Extract<SpriteSequenceDefinition, { source: 'asset' }>,
|
||||
) {
|
||||
const root = getCharacterRoot(character);
|
||||
const folder = sequence.folder
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
if (sequence.file) {
|
||||
return [`${root}/${folder}/${encodeURIComponent(sequence.file)}`];
|
||||
}
|
||||
|
||||
const frames: string[] = [];
|
||||
const totalFrames = Math.max(1, sequence.frames ?? 1);
|
||||
const startFrame = sequence.startFrame ?? 1;
|
||||
const extension = sequence.extension ?? 'png';
|
||||
|
||||
for (let index = 0; index < totalFrames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${root}/${folder}/${sequence.prefix ?? ''}${frameNumber}.${extension}`);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function getCharacterAnimationConfig(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
return character.animationMap?.[animation] ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterAnimationDurationMs(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
const config = getCharacterAnimationConfig(character, animation);
|
||||
if (!config) return DEFAULT_FRAME_MS;
|
||||
return Math.max(DEFAULT_FRAME_MS, config.frames * DEFAULT_FRAME_MS);
|
||||
}
|
||||
|
||||
export function getSequenceFps(sequence: SpriteSequenceDefinition) {
|
||||
return sequence.fps ?? DEFAULT_FPS;
|
||||
}
|
||||
|
||||
export function getSequenceDurationMs(sequence: SpriteSequenceDefinition, frameCount: number) {
|
||||
const fps = getSequenceFps(sequence);
|
||||
return Math.max(DEFAULT_FRAME_MS, Math.ceil((Math.max(1, frameCount) * 1000) / fps));
|
||||
}
|
||||
|
||||
export function resolveSequenceFrames(
|
||||
character: Character,
|
||||
sequence: SpriteSequenceDefinition,
|
||||
) {
|
||||
if (sequence.source === 'animation') {
|
||||
const config = getCharacterAnimationConfig(character, sequence.animation);
|
||||
return config ? buildFramesFromConfig(character, config) : [];
|
||||
}
|
||||
|
||||
return buildFramesFromAsset(character, sequence);
|
||||
}
|
||||
|
||||
export function getSkillCasterAnimation(skill: CharacterSkillDefinition) {
|
||||
return skill.casterAnimation ?? skill.animation;
|
||||
}
|
||||
|
||||
export function getSkillDelivery(skill: CharacterSkillDefinition): CombatDelivery {
|
||||
return skill.delivery ?? (skill.style === 'projectile' ? 'ranged' : 'melee');
|
||||
}
|
||||
1
src/data/characterOverrides.json
Normal file
1
src/data/characterOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1839
src/data/characterPresets.ts
Normal file
1839
src/data/characterPresets.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
src/data/companionRoster.ts
Normal file
128
src/data/companionRoster.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { CompanionState, GameState } from '../types';
|
||||
import { MAX_COMPANIONS } from './npcInteractions';
|
||||
|
||||
function upsertCompanion(list: CompanionState[], companion: CompanionState) {
|
||||
const next = [...list];
|
||||
const existingIndex = next.findIndex(item => item.npcId === companion.npcId);
|
||||
if (existingIndex >= 0) {
|
||||
next[existingIndex] = companion;
|
||||
return next;
|
||||
}
|
||||
|
||||
next.push(companion);
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeCompanion(list: CompanionState[], npcId: string) {
|
||||
return list.filter(item => item.npcId !== npcId);
|
||||
}
|
||||
|
||||
export function getRecruitedNpcIds(state: Pick<GameState, 'companions' | 'roster'>) {
|
||||
return new Set([
|
||||
...state.companions.map(companion => companion.npcId),
|
||||
...state.roster.map(companion => companion.npcId),
|
||||
]);
|
||||
}
|
||||
|
||||
export function normalizeRoster(roster: CompanionState[], activeCompanions: CompanionState[]) {
|
||||
const activeIds = new Set(activeCompanions.map(companion => companion.npcId));
|
||||
return roster
|
||||
.filter(companion => !activeIds.has(companion.npcId))
|
||||
.reduce<CompanionState[]>((next, companion) => upsertCompanion(next, companion), []);
|
||||
}
|
||||
|
||||
export function benchActiveCompanion(state: GameState, npcId: string) {
|
||||
const activeCompanion = state.companions.find(companion => companion.npcId === npcId);
|
||||
if (!activeCompanion) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: state.companions.filter(companion => companion.npcId !== npcId),
|
||||
roster: upsertCompanion(state.roster, activeCompanion),
|
||||
};
|
||||
}
|
||||
|
||||
export function activateRosterCompanion(state: GameState, npcId: string, swapNpcId?: string | null) {
|
||||
const reserveCompanion = state.roster.find(companion => companion.npcId === npcId);
|
||||
if (!reserveCompanion) return state;
|
||||
|
||||
if (state.companions.some(companion => companion.npcId === npcId)) {
|
||||
return {
|
||||
...state,
|
||||
roster: removeCompanion(state.roster, npcId),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.companions.length < MAX_COMPANIONS) {
|
||||
return {
|
||||
...state,
|
||||
companions: [...state.companions, reserveCompanion],
|
||||
roster: removeCompanion(state.roster, npcId),
|
||||
};
|
||||
}
|
||||
|
||||
if (!swapNpcId) return state;
|
||||
const swapIndex = state.companions.findIndex(companion => companion.npcId === swapNpcId);
|
||||
if (swapIndex < 0) return state;
|
||||
|
||||
const swappedOut = state.companions[swapIndex];
|
||||
if (!swappedOut) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextCompanions = [...state.companions];
|
||||
nextCompanions[swapIndex] = reserveCompanion;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: nextCompanions,
|
||||
roster: upsertCompanion(removeCompanion(state.roster, npcId), swappedOut),
|
||||
};
|
||||
}
|
||||
|
||||
export function recruitCompanionToParty(
|
||||
state: GameState,
|
||||
companion: CompanionState,
|
||||
replacedNpcId?: string | null,
|
||||
) {
|
||||
const nextReserve = removeCompanion(state.roster, companion.npcId);
|
||||
|
||||
if (!replacedNpcId && state.companions.length < MAX_COMPANIONS) {
|
||||
return {
|
||||
...state,
|
||||
companions: [...state.companions, companion],
|
||||
roster: nextReserve,
|
||||
};
|
||||
}
|
||||
|
||||
if (!replacedNpcId) {
|
||||
return {
|
||||
...state,
|
||||
companions: state.companions.slice(0, MAX_COMPANIONS),
|
||||
roster: normalizeRoster(nextReserve, state.companions),
|
||||
};
|
||||
}
|
||||
|
||||
const replaceIndex = state.companions.findIndex(item => item.npcId === replacedNpcId);
|
||||
if (replaceIndex < 0) {
|
||||
return {
|
||||
...state,
|
||||
companions: [...state.companions, companion].slice(0, MAX_COMPANIONS),
|
||||
roster: nextReserve,
|
||||
};
|
||||
}
|
||||
|
||||
const replacedCompanion = state.companions[replaceIndex];
|
||||
if (!replacedCompanion) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextCompanions = [...state.companions];
|
||||
nextCompanions[replaceIndex] = companion;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: nextCompanions,
|
||||
roster: upsertCompanion(nextReserve, replacedCompanion),
|
||||
};
|
||||
}
|
||||
165
src/data/customWorldBuildTags.ts
Normal file
165
src/data/customWorldBuildTags.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { type CustomWorldThemeMode,detectCustomWorldThemeMode } from '../services/customWorldTheme';
|
||||
import { type Character, type CustomWorldPlayableNpc, type CustomWorldProfile } from '../types';
|
||||
import { normalizeBuildTags } from './buildTags';
|
||||
|
||||
type CustomWorldTagProfile = Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'
|
||||
>;
|
||||
|
||||
type CustomWorldTagRole = Pick<
|
||||
CustomWorldPlayableNpc,
|
||||
'name' | 'title' | 'description' | 'backstory' | 'personality' | 'combatStyle' | 'tags'
|
||||
> & {
|
||||
templateCharacterId?: string;
|
||||
};
|
||||
|
||||
const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
|
||||
'sword-princess': ['\u5feb\u5251', '\u7a81\u8fdb', '\u538b\u5236'],
|
||||
'archer-hero': ['\u8fdc\u5c04', '\u6e38\u51fb', '\u673a\u52a8'],
|
||||
'girl-hero': ['\u5feb\u88ad', '\u8ffd\u51fb', '\u673a\u52a8'],
|
||||
'punch-hero': ['\u91cd\u51fb', '\u7206\u53d1', '\u538b\u8840'],
|
||||
'fighter-4': ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
|
||||
};
|
||||
|
||||
const THEME_FALLBACK_TAGS: Record<CustomWorldThemeMode, string[]> = {
|
||||
martial: [],
|
||||
arcane: ['\u6cd5\u4fee', '\u7b26\u9635', '\u6cd5\u529b'],
|
||||
machina: ['\u5de5\u5de7', '\u63a7\u573a', '\u62a4\u4f53'],
|
||||
tide: ['\u673a\u52a8', '\u7eed\u6218', '\u8fdc\u5c04'],
|
||||
rift: ['\u6cd5\u4fee', '\u63a7\u573a', '\u8fc7\u8f7d'],
|
||||
};
|
||||
|
||||
const TEXT_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{
|
||||
pattern: /\u5f13|\u7bad|\u5f29|\u72d9|\u8fdc\u7a0b|\u8239|\u822a|\u5de1|\u730e|\u5c04/u,
|
||||
tags: ['\u8fdc\u5c04', '\u673a\u52a8'],
|
||||
},
|
||||
{
|
||||
pattern: /\u5251|\u5203|\u5200|\u950b|\u4fa0|\u6597\u4fee|\u5feb\u5251/u,
|
||||
tags: ['\u5feb\u5251', '\u7a81\u8fdb'],
|
||||
},
|
||||
{
|
||||
pattern: /\u62f3|\u9524|\u65a7|\u7206\u53d1|\u91cd\u51fb|\u9707/u,
|
||||
tags: ['\u91cd\u51fb', '\u7206\u53d1'],
|
||||
},
|
||||
{
|
||||
pattern: /\u76fe|\u5b88|\u536b|\u9635|\u524d\u950b|\u9547\u5b88|\u7532/u,
|
||||
tags: ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
|
||||
},
|
||||
{
|
||||
pattern: /\u836f|\u533b|\u7597|\u4e39|\u9732|\u8349|\u8c37|\u6108|\u8865\u7ed9/u,
|
||||
tags: ['\u56de\u590d', '\u7eed\u6218', '\u70bc\u836f'],
|
||||
},
|
||||
{
|
||||
pattern: /\u7b26|\u9635|\u5492|\u7075|\u6cd5|\u4fee|\u9053|\u4ed9|\u79d8\u5883|\u88c2\u9699|\u754c\u95e8|\u754c\u57df/u,
|
||||
tags: ['\u6cd5\u4fee', '\u63a7\u573a', '\u7b26\u9635'],
|
||||
},
|
||||
{
|
||||
pattern: /\u96f7|\u9706|\u7535/u,
|
||||
tags: ['\u96f7\u6cd5', '\u7206\u53d1'],
|
||||
},
|
||||
{
|
||||
pattern: /\u673a\u5173|\u5668\u4fee|\u953b|\u94f8|\u5de5\u574a|\u6cd5\u5668|\u673a\u5de7/u,
|
||||
tags: ['\u5de5\u5de7', '\u62a4\u4f53'],
|
||||
},
|
||||
{
|
||||
pattern: /\u6697|\u5f71|\u6f5c|\u4f0f|\u523a|\u591c|\u8c0d|\u8ffd\u67e5|\u65e7\u6848|\u5de1\u67e5/u,
|
||||
tags: ['\u5feb\u88ad', '\u8ffd\u51fb', '\u673a\u52a8'],
|
||||
},
|
||||
{
|
||||
pattern: /\u6307\u6325|\u7edf\u9886|\u519b|\u9635\u7ebf|\u961f\u957f|\u53f7\u4ee4/u,
|
||||
tags: ['\u7edf\u5fa1', '\u6276\u6301', '\u5148\u950b'],
|
||||
},
|
||||
];
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.map(value => value?.trim() ?? '').filter(Boolean))];
|
||||
}
|
||||
|
||||
function inferBuildTagsFromTexts(values: string[]) {
|
||||
const source = values.join(' ');
|
||||
if (!source.trim()) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
return normalizeBuildTags(
|
||||
TEXT_TAG_RULES
|
||||
.filter(rule => rule.pattern.test(source))
|
||||
.flatMap(rule => rule.tags),
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveCustomWorldCombatTags(
|
||||
profile: CustomWorldTagProfile,
|
||||
role: CustomWorldTagRole,
|
||||
options: {
|
||||
fallbackTags?: string[];
|
||||
templateCharacterId?: string | null;
|
||||
maxCount?: number;
|
||||
} = {},
|
||||
) {
|
||||
const sourceTexts = uniqueStrings([
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
role.name,
|
||||
role.title,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.combatStyle,
|
||||
...(role.tags ?? []),
|
||||
]);
|
||||
|
||||
const explicitTags = normalizeBuildTags(role.tags, 6);
|
||||
const inferredTags = inferBuildTagsFromTexts(sourceTexts);
|
||||
const themeTags = THEME_FALLBACK_TAGS[detectCustomWorldThemeMode(profile)];
|
||||
const templateTags = options.templateCharacterId
|
||||
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
|
||||
: [];
|
||||
|
||||
return normalizeBuildTags([
|
||||
...explicitTags,
|
||||
...inferredTags,
|
||||
...themeTags,
|
||||
...templateTags,
|
||||
...(options.fallbackTags ?? []),
|
||||
]).slice(0, options.maxCount ?? 3);
|
||||
}
|
||||
|
||||
export function mergeCustomWorldPlayableNpcTags(
|
||||
profile: CustomWorldTagProfile,
|
||||
role: CustomWorldTagRole,
|
||||
options: {
|
||||
fallbackTags?: string[];
|
||||
templateCharacterId?: string | null;
|
||||
maxCount?: number;
|
||||
} = {},
|
||||
) {
|
||||
const combatTags = deriveCustomWorldCombatTags(profile, role, options);
|
||||
const templateTags = options.templateCharacterId
|
||||
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
|
||||
: [];
|
||||
|
||||
return uniqueStrings([
|
||||
...combatTags,
|
||||
...role.tags,
|
||||
...templateTags,
|
||||
...(options.fallbackTags ?? []),
|
||||
]).slice(0, options.maxCount ?? 5);
|
||||
}
|
||||
|
||||
export function deriveCustomWorldCharacterCombatTags(
|
||||
profile: CustomWorldTagProfile,
|
||||
role: CustomWorldTagRole,
|
||||
baseCharacter: Character,
|
||||
) {
|
||||
return deriveCustomWorldCombatTags(profile, role, {
|
||||
fallbackTags: normalizeBuildTags(baseCharacter.combatTags, 3),
|
||||
templateCharacterId: role.templateCharacterId ?? baseCharacter.id,
|
||||
maxCount: 3,
|
||||
});
|
||||
}
|
||||
9
src/data/customWorldCharacterLoadout.stub.ts
Normal file
9
src/data/customWorldCharacterLoadout.stub.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { InventoryItem } from '../types';
|
||||
|
||||
export function buildCustomWorldStarterInventoryItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterEquipmentItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
287
src/data/customWorldCharacterLoadout.ts
Normal file
287
src/data/customWorldCharacterLoadout.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Character, CustomWorldPlayableNpc, CustomWorldProfile, EquipmentSlotId, InventoryItem } from '../types';
|
||||
import {
|
||||
buildRuntimeCustomWorldInventoryItems,
|
||||
getRuntimeCustomWorldProfile,
|
||||
type RuntimeCustomWorldItemQueryOptions,
|
||||
} from './customWorldRuntime';
|
||||
|
||||
const CATEGORY_ORDER = new Map<string, number>([
|
||||
['æ¦å™¨', 0],
|
||||
['护甲', 1],
|
||||
['饰å“<C3A5>', 2],
|
||||
['消耗å“<C3A5>', 3],
|
||||
['æ<><C3A6>æ–™', 4],
|
||||
['稀有å“<C3A5>', 5],
|
||||
['专属物å“<C3A5>', 6],
|
||||
]);
|
||||
|
||||
const STOP_PHRASES = new Set([
|
||||
'这个世界',
|
||||
'当å‰<C3A5>世界',
|
||||
'玩家进入',
|
||||
'çŽ©å®¶æ ¸å¿ƒ',
|
||||
'世界设定',
|
||||
'世界概述',
|
||||
'世界基调',
|
||||
'角色背景',
|
||||
'角色æ<C2B2><C3A6>è¿°',
|
||||
'角色设定',
|
||||
'角色故事',
|
||||
'剧情关键',
|
||||
'å<>Žç»å†’险',
|
||||
'完整角色',
|
||||
'当å‰<C3A5>å±€åŠ?',
|
||||
'进入世界',
|
||||
'æ ¸å¿ƒç›®æ ‡',
|
||||
'å<>¯æ‰®æ¼?',
|
||||
'主角候�',
|
||||
'主è¦<C3A8>角色',
|
||||
'当å‰<C3A5>角色',
|
||||
'这趟旅程',
|
||||
'No retreat',
|
||||
'真æ£èµ·ç‚¹',
|
||||
]);
|
||||
|
||||
const THEME_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{ pattern: /range|bow|shot|sniper|scout/i, tags: ['range', 'mobility', 'explore', 'weapon'] },
|
||||
{ pattern: /blade|sword|slash|duel|charge/i, tags: ['melee', 'combat', 'weapon'] },
|
||||
{ pattern: /fist|hammer|burst|smash|impact/i, tags: ['burst', 'combat', 'weapon'] },
|
||||
{ pattern: /armor|shield|guard|wall|vanguard/i, tags: ['guard', 'defense', 'armor'] },
|
||||
{ pattern: /medic|herb|potion|heal|remedy/i, tags: ['alchemy', 'healing', 'supply'] },
|
||||
{ pattern: /rune|sigil|spell|mana|arcane|focus/i, tags: ['mana', 'arcane', 'glyph', 'focus'] },
|
||||
{ pattern: /rare|relic|archive|key|history/i, tags: ['rare', 'clue', 'history', 'secret'] },
|
||||
{ pattern: /travel|map|road|route|trail/i, tags: ['explore', 'route', 'supply'] },
|
||||
{ pattern: /forge|craft|tool|gear|metal/i, tags: ['craft', 'material', 'forge'] },
|
||||
{ pattern: /flora|seed|bloom|vine|root/i, tags: ['herb', 'alchemy', 'material'] },
|
||||
];
|
||||
|
||||
function resolveCustomWorldPlayableRole(profile: CustomWorldProfile, character: Character) {
|
||||
return profile.playableNpcs.find(role => role.id === character.id)
|
||||
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
|
||||
?? profile.playableNpcs.find(role => role.name === character.name)
|
||||
?? null;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], max = 32) {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max);
|
||||
}
|
||||
|
||||
function sortInventoryByCategory(items: InventoryItem[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const categoryDelta = (CATEGORY_ORDER.get(left.category) ?? 99) - (CATEGORY_ORDER.get(right.category) ?? 99);
|
||||
if (categoryDelta !== 0) {
|
||||
return categoryDelta;
|
||||
}
|
||||
return left.name.localeCompare(right.name, 'zh-Hans-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function collectPhrases(sourceTexts: string[]) {
|
||||
return sourceTexts.flatMap(text =>
|
||||
text
|
||||
.split(/[[\]\s,。ã€<EFBFBD>“â€<EFBFBD>‘’;:?ï¼?.!?:()()ã€<EFBFBD>ã€?]+/u)
|
||||
.map(segment => segment.trim())
|
||||
.filter(segment => segment.length >= 2 && segment.length <= 12)
|
||||
.filter(segment => !STOP_PHRASES.has(segment)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectChineseNgrams(value: string, minSize = 2, maxSize = 4, limit = 16) {
|
||||
const source = value.replace(/[^\u4e00-\u9fa5]/g, '');
|
||||
const grams: string[] = [];
|
||||
|
||||
for (let size = minSize; size <= maxSize; size += 1) {
|
||||
for (let index = 0; index <= source.length - size; index += 1) {
|
||||
const gram = source.slice(index, index + size);
|
||||
if (STOP_PHRASES.has(gram)) {
|
||||
continue;
|
||||
}
|
||||
grams.push(gram);
|
||||
if (grams.length >= limit) {
|
||||
return grams;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grams;
|
||||
}
|
||||
|
||||
function buildKeywordBundle(profile: CustomWorldProfile, character: Character, role: CustomWorldPlayableNpc | null) {
|
||||
const roleTexts = [
|
||||
role?.title ?? '',
|
||||
role?.description ?? '',
|
||||
role?.backstory ?? '',
|
||||
role?.combatStyle ?? '',
|
||||
...(role?.tags ?? []),
|
||||
];
|
||||
const characterTexts = [
|
||||
character.description,
|
||||
character.backstory,
|
||||
character.personality,
|
||||
...(character.combatTags ?? []),
|
||||
];
|
||||
const worldTexts = [
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
];
|
||||
const sourceTexts = [...roleTexts, ...characterTexts, ...worldTexts].filter(Boolean);
|
||||
const phrases = collectPhrases(sourceTexts);
|
||||
const ngrams = [
|
||||
...collectChineseNgrams(role?.title ?? '', 2, 4, 12),
|
||||
...collectChineseNgrams(role?.combatStyle ?? '', 2, 4, 12),
|
||||
...collectChineseNgrams((role?.tags ?? []).join(' '), 2, 4, 10),
|
||||
...collectChineseNgrams(profile.name, 2, 4, 10),
|
||||
];
|
||||
const heuristics = THEME_TAG_RULES
|
||||
.filter(rule => rule.pattern.test(sourceTexts.join(' ')))
|
||||
.flatMap(rule => rule.tags);
|
||||
|
||||
return {
|
||||
preferredTags: dedupeStrings([...(role?.tags ?? []), ...(character.combatTags ?? []), ...heuristics], 18),
|
||||
keywords: dedupeStrings([...phrases, ...ngrams, ...heuristics], 36),
|
||||
};
|
||||
}
|
||||
|
||||
function queryItems(
|
||||
seedKey: string,
|
||||
baseOptions: RuntimeCustomWorldItemQueryOptions,
|
||||
fallbackOptions?: RuntimeCustomWorldItemQueryOptions,
|
||||
) {
|
||||
const items = buildRuntimeCustomWorldInventoryItems(seedKey, baseOptions);
|
||||
const categoryFallbackTriggered = Boolean(
|
||||
fallbackOptions
|
||||
&& baseOptions.categories?.length
|
||||
&& items.some(item => !baseOptions.categories!.includes(item.category)),
|
||||
);
|
||||
if ((items.length > 0 && !categoryFallbackTriggered) || !fallbackOptions) {
|
||||
return items;
|
||||
}
|
||||
return buildRuntimeCustomWorldInventoryItems(seedKey, fallbackOptions);
|
||||
}
|
||||
|
||||
function mergeUniqueItems(...groups: InventoryItem[][]) {
|
||||
const result: InventoryItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
groups.flat().forEach(item => {
|
||||
const key = `${item.category}:${item.name}`;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterEquipmentItems(
|
||||
character: Character,
|
||||
explicitProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
const profile = explicitProfile ?? getRuntimeCustomWorldProfile();
|
||||
if (!profile) {
|
||||
return {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
|
||||
}
|
||||
|
||||
const role = resolveCustomWorldPlayableRole(profile, character);
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const baseTextKeywords = bundle.keywords;
|
||||
const baseTags = bundle.preferredTags;
|
||||
|
||||
const [weapon] = queryItems(`equipment:${character.id}:weapon`, {
|
||||
count: 1,
|
||||
categories: ['æ¦å™¨'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'weapon', '战斗']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, role?.combatStyle ?? '', 'æ¦å™¨', '战斗']),
|
||||
});
|
||||
const [armor] = queryItems(`equipment:${character.id}:armor`, {
|
||||
count: 1,
|
||||
categories: ['护甲'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'armor', '防护', '护体']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, role?.personality ?? character.personality, '护甲', '守御']),
|
||||
});
|
||||
const [relic] = queryItems(`equipment:${character.id}:relic`, {
|
||||
count: 1,
|
||||
categories: ['饰å“<C3A5>'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']),
|
||||
}, {
|
||||
count: 1,
|
||||
categories: ['饰å“<C3A5>', '稀有å“<C3A5>', '专属物å“<C3A5>'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']),
|
||||
});
|
||||
|
||||
return {
|
||||
weapon: weapon ?? null,
|
||||
armor: armor ?? null,
|
||||
relic: relic ?? null,
|
||||
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterInventoryItems(
|
||||
character: Character,
|
||||
explicitProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
const profile = explicitProfile ?? getRuntimeCustomWorldProfile();
|
||||
if (!profile) {
|
||||
return [] as InventoryItem[];
|
||||
}
|
||||
|
||||
const role = resolveCustomWorldPlayableRole(profile, character);
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const consumables = queryItems(`inventory:${character.id}:consumables`, {
|
||||
count: 2,
|
||||
quantity: 2,
|
||||
categories: ['消耗å“<C3A5>'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']),
|
||||
keywords: dedupeStrings([...bundle.keywords, role?.combatStyle ?? '', 'è°ƒæ<C692>¯', 'ç»æˆ˜']),
|
||||
});
|
||||
const materials = queryItems(`inventory:${character.id}:materials`, {
|
||||
count: 1,
|
||||
quantity: 2,
|
||||
categories: ['æ<><C3A6>æ–™'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'material', 'forge', 'alchemy']),
|
||||
keywords: dedupeStrings([...bundle.keywords, role?.backstory ?? character.backstory, 'æ<><C3A6>æ–™']),
|
||||
});
|
||||
const rareUtility = queryItems(`inventory:${character.id}:rare-utility`, {
|
||||
count: 1,
|
||||
categories: ['饰å“<C3A5>', '稀有å“<C3A5>'],
|
||||
rarityFloor: 'uncommon',
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'relic', 'rare', '线索', '寻路']),
|
||||
keywords: dedupeStrings([...bundle.keywords, profile.settingText, profile.summary, '线索', '寻路']),
|
||||
});
|
||||
const signature = queryItems(`inventory:${character.id}:signature`, {
|
||||
count: 1,
|
||||
categories: ['专属物å“<C3A5>', '稀有å“<C3A5>'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, '剧情关键', '异å<E2809A>˜', 'æ—§å<C2A7>²', 'rare']),
|
||||
keywords: dedupeStrings([...bundle.keywords, profile.playerGoal, profile.name, '信物', '关键']),
|
||||
});
|
||||
|
||||
const merged = mergeUniqueItems(consumables, materials, rareUtility, signature);
|
||||
if (merged.length >= 5) {
|
||||
return sortInventoryByCategory(merged.slice(0, 5));
|
||||
}
|
||||
|
||||
const filler = queryItems(`inventory:${character.id}:filler`, {
|
||||
count: 5 - merged.length,
|
||||
categories: ['消耗å“<C3A5>', 'æ<><C3A6>æ–™', '饰å“<C3A5>', '稀有å“<C3A5>', '专属物å“<C3A5>'],
|
||||
preferredTags: bundle.preferredTags,
|
||||
keywords: bundle.keywords,
|
||||
});
|
||||
|
||||
return sortInventoryByCategory(mergeUniqueItems(merged, filler).slice(0, 5));
|
||||
}
|
||||
309
src/data/customWorldLibrary.ts
Normal file
309
src/data/customWorldLibrary.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldNpcVisual,
|
||||
CustomWorldNpcVisualGear,
|
||||
CustomWorldNpcVisualGearType,
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
|
||||
const MAX_SAVED_CUSTOM_WORLDS = 12;
|
||||
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
|
||||
|
||||
type StoredCustomWorldLibrary = {
|
||||
version: number;
|
||||
profiles: CustomWorldProfile[];
|
||||
};
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function toOptionalInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisualGear | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const type = typeof value.type === 'string' && CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has(value.type as CustomWorldNpcVisualGearType)
|
||||
? value.type as CustomWorldNpcVisualGearType
|
||||
: null;
|
||||
const file = toText(value.file);
|
||||
|
||||
if (!type || !file) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
file,
|
||||
frameIndex: toOptionalInteger(value.frameIndex) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const race = typeof value.race === 'string' && CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace)
|
||||
? value.race as CustomWorldNpcVisualRace
|
||||
: null;
|
||||
|
||||
if (!race) return undefined;
|
||||
|
||||
return {
|
||||
race,
|
||||
bodyColor: toText(value.bodyColor, 'black'),
|
||||
headIndex: Math.max(1, toOptionalInteger(value.headIndex) ?? 1),
|
||||
hairColorIndex: Math.max(1, toOptionalInteger(value.hairColorIndex) ?? 1),
|
||||
hairStyleFrame: Math.max(0, toOptionalInteger(value.hairStyleFrame) ?? 0),
|
||||
facialHairEnabled: Boolean(value.facialHairEnabled),
|
||||
facialHairColorIndex: Math.max(1, toOptionalInteger(value.facialHairColorIndex) ?? 1),
|
||||
facialHairStyleFrame: Math.max(0, toOptionalInteger(value.facialHairStyleFrame) ?? 0),
|
||||
headgear: normalizeCustomWorldNpcVisualGear(value.headgear),
|
||||
mainHand: normalizeCustomWorldNpcVisualGear(value.mainHand),
|
||||
offHand: normalizeCustomWorldNpcVisualGear(value.offHand),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const profile: ItemStatProfile = {
|
||||
maxHpBonus: toOptionalNumber(value.maxHpBonus),
|
||||
maxManaBonus: toOptionalNumber(value.maxManaBonus),
|
||||
outgoingDamageBonus: toOptionalNumber(value.outgoingDamageBonus),
|
||||
incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier),
|
||||
};
|
||||
|
||||
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
|
||||
}
|
||||
|
||||
function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const profile: ItemUseProfile = {
|
||||
hpRestore: toOptionalNumber(value.hpRestore),
|
||||
manaRestore: toOptionalNumber(value.manaRestore),
|
||||
cooldownReduction: toOptionalNumber(value.cooldownReduction),
|
||||
};
|
||||
|
||||
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
|
||||
}
|
||||
|
||||
function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayableNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title: toText(value.title, '未命名角色'),
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
tags: toStringArray(value.tags),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
role: toText(value.role, '未命名场景角色'),
|
||||
description: toText(value.description),
|
||||
motivation: toText(value.motivation),
|
||||
relationshipHooks: toStringArray(value.relationshipHooks),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
const category = toText(value.category);
|
||||
const rarity = typeof value.rarity === 'string' && ITEM_RARITIES.has(value.rarity as ItemRarity)
|
||||
? value.rarity as ItemRarity
|
||||
: null;
|
||||
if (!name || !category || !rarity) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-item-${index + 1}`),
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
description: toText(value.description),
|
||||
tags: toStringArray(value.tags),
|
||||
iconSrc: toText(value.iconSrc) || undefined,
|
||||
sourcePath: toText(value.sourcePath) || undefined,
|
||||
origin: value.origin === 'generated' || value.origin === 'catalog' ? value.origin : undefined,
|
||||
equipmentSlotId: normalizeEquipmentSlot(value.equipmentSlotId),
|
||||
statProfile: normalizeItemStatProfile(value.statProfile),
|
||||
useProfile: normalizeItemUseProfile(value.useProfile),
|
||||
value: toOptionalNumber(value.value),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-landmark-${index + 1}`),
|
||||
name,
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
const settingText = toText(value.settingText, toText(value.summary, name));
|
||||
if (!name) return null;
|
||||
|
||||
const templateWorldType = value.templateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA;
|
||||
const subtitle = toText(value.subtitle);
|
||||
const summary = toText(value.summary);
|
||||
const tone = toText(value.tone);
|
||||
const playerGoal = toText(value.playerGoal);
|
||||
const generatedAttributeSchema = generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
});
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
name,
|
||||
subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||
playableNpcs: Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs: Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [],
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
landmarks: Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmark(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmark => Boolean(entry))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeProfiles(profiles: CustomWorldProfile[]) {
|
||||
const normalizedProfiles = profiles
|
||||
.map(profile => normalizeProfile(profile))
|
||||
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
|
||||
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return normalizedProfiles;
|
||||
}
|
||||
|
||||
const payload: StoredCustomWorldLibrary = {
|
||||
version: CUSTOM_WORLD_LIBRARY_VERSION,
|
||||
profiles: normalizedProfiles,
|
||||
};
|
||||
writeStoredJson({
|
||||
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
|
||||
value: payload,
|
||||
});
|
||||
return normalizedProfiles;
|
||||
}
|
||||
|
||||
export function readSavedCustomWorldProfiles() {
|
||||
return (
|
||||
readStoredJson({
|
||||
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
|
||||
parse: value => {
|
||||
if (!isRecord(value) || value.version !== CUSTOM_WORLD_LIBRARY_VERSION || !Array.isArray(value.profiles)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.profiles
|
||||
.map(profile => normalizeProfile(profile))
|
||||
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
|
||||
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
|
||||
},
|
||||
}) ?? ([] as CustomWorldProfile[])
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
const nextProfiles = [
|
||||
profile,
|
||||
...readSavedCustomWorldProfiles().filter(savedProfile => savedProfile.id !== profile.id),
|
||||
];
|
||||
return writeProfiles(nextProfiles);
|
||||
}
|
||||
363
src/data/customWorldRuntime.ts
Normal file
363
src/data/customWorldRuntime.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
|
||||
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
|
||||
|
||||
export function setRuntimeCustomWorldProfile(profile: CustomWorldProfile | null) {
|
||||
runtimeCustomWorldProfile = profile;
|
||||
}
|
||||
|
||||
export function getRuntimeCustomWorldProfile() {
|
||||
return runtimeCustomWorldProfile;
|
||||
}
|
||||
|
||||
export function resolveRuleWorldType(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
||||
): WorldTemplateType | null {
|
||||
if (!worldType) return null;
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return customWorldProfile ? resolveCustomWorldAnchorWorldType(customWorldProfile) : WorldType.WUXIA;
|
||||
}
|
||||
return worldType;
|
||||
}
|
||||
|
||||
export function isCustomWorldType(worldType: WorldType | null | undefined) {
|
||||
return worldType === WorldType.CUSTOM;
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function compactStrings(values: Array<string | null | undefined | false>) {
|
||||
return [...new Set(
|
||||
values
|
||||
.map(value => typeof value === 'string' ? value.trim() : '')
|
||||
.filter(Boolean),
|
||||
)];
|
||||
}
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, fallback: T): T {
|
||||
return items[index % items.length] ?? fallback;
|
||||
}
|
||||
|
||||
function normalizeInventoryItemId(item: CustomWorldItem, quantity: number, seedKey: string) {
|
||||
return `custom:${item.id}:${quantity}:${hashText(seedKey).toString(36)}`;
|
||||
}
|
||||
|
||||
function toInventoryItem(item: CustomWorldItem, quantity: number, seedKey: string): InventoryItem {
|
||||
return {
|
||||
id: normalizeInventoryItemId(item, quantity, seedKey),
|
||||
category: item.category,
|
||||
name: item.name,
|
||||
quantity,
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
iconSrc: item.iconSrc,
|
||||
description: item.description,
|
||||
equipmentSlotId: item.equipmentSlotId ?? null,
|
||||
statProfile: item.statProfile ?? null,
|
||||
useProfile: item.useProfile ?? null,
|
||||
value: item.value,
|
||||
runtimeMetadata: {
|
||||
origin: 'procedural',
|
||||
generationChannel: 'discovery',
|
||||
seedKey,
|
||||
sourceReason: `围绕自定义世界 ${runtimeCustomWorldProfile?.name ?? '未知世界'} 的主题即时生成。`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeCustomWorldItemQueryOptions {
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
preferredTags?: string[];
|
||||
keywords?: string[];
|
||||
count?: number;
|
||||
quantity?: number;
|
||||
rarityFloor?: CustomWorldItem['rarity'];
|
||||
}
|
||||
|
||||
const RARITY_ORDER: CustomWorldItem['rarity'][] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
||||
const DEFAULT_RUNTIME_CATEGORIES = ['武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物'] as const;
|
||||
const CATEGORY_DEFAULT_TAGS: Record<string, string[]> = {
|
||||
武器: ['weapon', '战斗'],
|
||||
护甲: ['armor', '防护'],
|
||||
饰品: ['relic', 'mana'],
|
||||
消耗品: ['healing', '补给'],
|
||||
材料: ['material', '采集'],
|
||||
稀有品: ['rare', '线索'],
|
||||
专属物: ['rare', '剧情关键'],
|
||||
};
|
||||
const WORLD_ITEM_PREFIXES: Record<WorldTemplateType, string[]> = {
|
||||
[WorldType.WUXIA]: ['江湖', '风雨', '断桥', '青锋', '旧案', '夜行'],
|
||||
[WorldType.XIANXIA]: ['灵潮', '云阙', '星砂', '裂界', '玄脉', '天舟'],
|
||||
};
|
||||
const WORLD_ITEM_NOUNS: Record<string, string[]> = {
|
||||
武器: ['刃', '剑', '弓', '枪', '印', '锤'],
|
||||
护甲: ['甲', '衣', '护符', '披风', '战铠', '护腕'],
|
||||
饰品: ['坠', '环', '佩', '珠', '印记', '信物'],
|
||||
消耗品: ['药', '露', '符', '瓶', '包', '散'],
|
||||
材料: ['砂', '石', '铁', '木', '羽', '晶'],
|
||||
稀有品: ['残页', '密卷', '古钥', '图录', '印匣', '秘函'],
|
||||
专属物: ['遗物', '核心', '母印', '真符', '遗钥', '界核'],
|
||||
};
|
||||
|
||||
function normalizeLookupText(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getRarityFloorValue(rarityFloor?: CustomWorldItem['rarity']) {
|
||||
return rarityFloor ? RARITY_ORDER.indexOf(rarityFloor) : -1;
|
||||
}
|
||||
|
||||
function sanitizeNameFragment(value: string) {
|
||||
return value.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '').slice(0, 4);
|
||||
}
|
||||
|
||||
function getWorldSeedLabel(profile: CustomWorldProfile) {
|
||||
const fromName = sanitizeNameFragment(profile.name);
|
||||
if (fromName) return fromName;
|
||||
|
||||
const fromSetting = sanitizeNameFragment(profile.settingText);
|
||||
if (fromSetting) return fromSetting;
|
||||
|
||||
return profile.templateWorldType === WorldType.XIANXIA ? '灵境' : '江湖';
|
||||
}
|
||||
|
||||
function buildRuntimeItemTags(
|
||||
category: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
seed: number,
|
||||
) {
|
||||
const baseTags = [...(CATEGORY_DEFAULT_TAGS[category] ?? ['world-item'])];
|
||||
const preferredTags = [...new Set((options.preferredTags ?? []).map(tag => tag.trim()).filter(Boolean))];
|
||||
const keywordTags = [...new Set((options.keywords ?? []).map(tag => tag.trim()).filter(Boolean))];
|
||||
const selectedPreferredTag = preferredTags.length > 0
|
||||
? preferredTags[seed % preferredTags.length]
|
||||
: undefined;
|
||||
const selectedKeywordTag = keywordTags.length > 0
|
||||
? keywordTags[(seed >>> 3) % keywordTags.length]
|
||||
: undefined;
|
||||
|
||||
if (category === '消耗品' && preferredTags.some(tag => /mana|法力|灵气|内息/u.test(tag))) {
|
||||
baseTags.push('mana');
|
||||
}
|
||||
if (category === '消耗品' && preferredTags.some(tag => /heal|疗|血|恢复/u.test(tag))) {
|
||||
baseTags.push('healing');
|
||||
}
|
||||
|
||||
return compactStrings([...baseTags, selectedPreferredTag, selectedKeywordTag]).slice(0, 5);
|
||||
}
|
||||
|
||||
function inferRuntimeItemRarity(seed: number, rarityFloorValue: number): CustomWorldItem['rarity'] {
|
||||
const rolledRarity = [0, 1, 1, 2, 2, 2, 3, 3, 4][seed % 9] ?? 0;
|
||||
return RARITY_ORDER[Math.max(rarityFloorValue, rolledRarity)] ?? 'common';
|
||||
}
|
||||
|
||||
function inferRuntimeItemMechanics(
|
||||
category: string,
|
||||
rarity: CustomWorldItem['rarity'],
|
||||
tags: string[],
|
||||
seed: number,
|
||||
) {
|
||||
const rarityTier = Math.max(1, RARITY_ORDER.indexOf(rarity) + 1);
|
||||
|
||||
if (category === '武器') {
|
||||
return {
|
||||
equipmentSlotId: 'weapon' as const,
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
||||
},
|
||||
useProfile: null,
|
||||
value: 28 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '护甲') {
|
||||
return {
|
||||
equipmentSlotId: 'armor' as const,
|
||||
statProfile: {
|
||||
maxHpBonus: 10 * rarityTier + (seed % 8),
|
||||
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
||||
},
|
||||
useProfile: null,
|
||||
value: 26 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '饰品' || category === '稀有品' || category === '专属物') {
|
||||
return {
|
||||
equipmentSlotId: 'relic' as const,
|
||||
statProfile: {
|
||||
maxManaBonus: 8 * rarityTier + (seed % 7),
|
||||
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
||||
},
|
||||
useProfile: null,
|
||||
value: 32 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '消耗品') {
|
||||
return {
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: tags.includes('mana')
|
||||
? { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 }
|
||||
: { hpRestore: 16 * rarityTier },
|
||||
value: 18 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
value: 10 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProceduralRuntimeItem(
|
||||
profile: CustomWorldProfile,
|
||||
seedKey: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
index: number,
|
||||
) {
|
||||
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
|
||||
const seed = hashText(`${profile.id}:${seedKey}:${index}`);
|
||||
const defaultCategory = DEFAULT_RUNTIME_CATEGORIES[0] ?? 'world-item';
|
||||
const categories = compactStrings(options.categories?.length ? options.categories : [...DEFAULT_RUNTIME_CATEGORIES]);
|
||||
const category = pickCyclic(categories, seed, defaultCategory);
|
||||
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
|
||||
const rarity = inferRuntimeItemRarity(seed, rarityFloorValue);
|
||||
const tags = buildRuntimeItemTags(category, options, seed);
|
||||
const prefixPool = WORLD_ITEM_PREFIXES[anchorWorldType];
|
||||
const nounPool = WORLD_ITEM_NOUNS[category] ?? WORLD_ITEM_NOUNS.稀有品;
|
||||
const fallbackNounPool = ['sigil', 'relic', 'token', 'seal', 'core', 'mark'];
|
||||
const resolvedNounPool = nounPool ?? fallbackNounPool;
|
||||
const worldSeed = getWorldSeedLabel(profile);
|
||||
const optionSeed = sanitizeNameFragment((options.preferredTags ?? [])[0] ?? '') || sanitizeNameFragment((options.keywords ?? [])[0] ?? '');
|
||||
const prefix = pickCyclic(prefixPool, seed >>> 2, prefixPool[0] ?? 'world');
|
||||
const noun = pickCyclic(resolvedNounPool, seed >>> 5, fallbackNounPool[0]);
|
||||
const name = `${prefix}${optionSeed || worldSeed}${noun}${index + 1}`;
|
||||
const mechanics = inferRuntimeItemMechanics(category, rarity, tags, seed);
|
||||
|
||||
return {
|
||||
id: `runtime-item:${hashText(`${seedKey}:${index}`).toString(36)}`,
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
description: `围绕“${profile.playerGoal}”即时生成的${category},适合在 ${profile.name} 中作为掉落、交易或补给资源。`,
|
||||
tags,
|
||||
origin: 'generated' as const,
|
||||
equipmentSlotId: mechanics.equipmentSlotId,
|
||||
statProfile: mechanics.statProfile,
|
||||
useProfile: mechanics.useProfile,
|
||||
value: mechanics.value,
|
||||
} satisfies CustomWorldItem;
|
||||
}
|
||||
|
||||
function matchesRuntimeQuery(
|
||||
item: CustomWorldItem,
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
rarityFloorValue: number,
|
||||
) {
|
||||
if (options.categories?.length && !options.categories.includes(item.category)) {
|
||||
return false;
|
||||
}
|
||||
if (options.tags?.length && !options.tags.some(tag => item.tags.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
if (rarityFloorValue >= 0) {
|
||||
const itemRarityValue = RARITY_ORDER.indexOf(item.rarity);
|
||||
if (itemRarityValue < rarityFloorValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scoreItemRelevance(item: CustomWorldItem, options: RuntimeCustomWorldItemQueryOptions) {
|
||||
const haystack = normalizeLookupText([
|
||||
item.name,
|
||||
item.category,
|
||||
item.description,
|
||||
...(item.tags ?? []),
|
||||
].join(' '));
|
||||
const itemTags = new Set((item.tags ?? []).map(tag => normalizeLookupText(tag)));
|
||||
let score = 0;
|
||||
|
||||
const preferredTags = [...new Set((options.preferredTags ?? []).map(normalizeLookupText).filter(Boolean))];
|
||||
preferredTags.forEach(tag => {
|
||||
if (itemTags.has(tag)) {
|
||||
score += 10;
|
||||
return;
|
||||
}
|
||||
if (haystack.includes(tag)) {
|
||||
score += 4;
|
||||
}
|
||||
});
|
||||
|
||||
const keywords = [...new Set((options.keywords ?? []).map(normalizeLookupText).filter(keyword => keyword.length >= 2))];
|
||||
keywords.forEach(keyword => {
|
||||
if (!haystack.includes(keyword)) {
|
||||
return;
|
||||
}
|
||||
score += keyword.length >= 4 ? 7 : keyword.length === 3 ? 5 : 3;
|
||||
});
|
||||
|
||||
if (options.categories?.includes(item.category)) {
|
||||
score += 2;
|
||||
}
|
||||
if (item.origin === 'generated') {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function rankItems(items: CustomWorldItem[], seedKey: string, options: RuntimeCustomWorldItemQueryOptions = {}) {
|
||||
const seed = hashText(seedKey);
|
||||
return [...items].sort((left, right) => {
|
||||
const relevanceDelta = scoreItemRelevance(right, options) - scoreItemRelevance(left, options);
|
||||
if (relevanceDelta !== 0) {
|
||||
return relevanceDelta;
|
||||
}
|
||||
|
||||
const leftScore = hashText(`${left.id}:${seed}`) % 997;
|
||||
const rightScore = hashText(`${right.id}:${seed}`) % 997;
|
||||
return leftScore - rightScore;
|
||||
});
|
||||
}
|
||||
|
||||
export function pickRuntimeCustomWorldItems(
|
||||
seedKey: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions = {},
|
||||
) {
|
||||
const profile = runtimeCustomWorldProfile;
|
||||
if (!profile) return [] as CustomWorldItem[];
|
||||
|
||||
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
|
||||
const sourceItems = Array.from({ length: Math.max(16, (options.count ?? 1) * 10) }, (_, index) =>
|
||||
buildProceduralRuntimeItem(profile, seedKey, options, index),
|
||||
);
|
||||
|
||||
const filtered = sourceItems.filter(item => matchesRuntimeQuery(item, options, rarityFloorValue));
|
||||
|
||||
return rankItems(filtered.length > 0 ? filtered : sourceItems, seedKey, options).slice(0, options.count ?? 1);
|
||||
}
|
||||
|
||||
export function buildRuntimeCustomWorldInventoryItems(
|
||||
seedKey: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions = {},
|
||||
) {
|
||||
const count = options.count ?? 1;
|
||||
return pickRuntimeCustomWorldItems(seedKey, options)
|
||||
.slice(0, count)
|
||||
.map((item, index) => toInventoryItem(item, options.quantity ?? 1, `${seedKey}:${index}`));
|
||||
}
|
||||
80
src/data/customWorldVisuals.ts
Normal file
80
src/data/customWorldVisuals.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
|
||||
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer%20Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl%20Hero%201/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch%20Hero%203/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter%204/original/Hero/idle/idle01.png',
|
||||
] as const;
|
||||
|
||||
const SCENE_BACKGROUND_PACKS = [
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 1', count: 121 },
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 2', count: 119 },
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
|
||||
] as const;
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function buildSceneImagePath(packName: string, imageNumber: number) {
|
||||
const filename = `${imageNumber.toString().padStart(3, '0')}.png`;
|
||||
return `/scene_bg/Pixel Battle Backgrounds Mega Pack/${packName}/${filename}`;
|
||||
}
|
||||
|
||||
export function getAllCustomWorldSceneImages() {
|
||||
const refs: string[] = [];
|
||||
|
||||
for (const pack of SCENE_BACKGROUND_PACKS) {
|
||||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||||
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectWorldSceneImagePool(worldType: WorldTemplateType) {
|
||||
const refs: string[] = [];
|
||||
let globalIndex = 0;
|
||||
|
||||
for (const pack of SCENE_BACKGROUND_PACKS) {
|
||||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||||
const assignedWorld = globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
|
||||
if (assignedWorld === worldType) {
|
||||
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
||||
}
|
||||
globalIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function normalizeOptionalImageSrc(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function getDefaultCustomWorldNpcImage(seedKey: string, index: number) {
|
||||
const offset = hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
|
||||
return CUSTOM_WORLD_NPC_IMAGE_POOL[offset];
|
||||
}
|
||||
|
||||
export function getDefaultCustomWorldSceneImage(
|
||||
seedKey: string,
|
||||
index: number,
|
||||
worldType: WorldTemplateType,
|
||||
) {
|
||||
const pool = collectWorldSceneImagePool(worldType);
|
||||
if (pool.length === 0) {
|
||||
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
|
||||
}
|
||||
|
||||
const offset = hashText(`${seedKey}:scene:${index}`) % pool.length;
|
||||
return pool[offset];
|
||||
}
|
||||
60
src/data/economy.ts
Normal file
60
src/data/economy.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { InventoryItem, WorldType } from '../types';
|
||||
|
||||
const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
|
||||
common: 12,
|
||||
uncommon: 24,
|
||||
rare: 48,
|
||||
epic: 92,
|
||||
legendary: 168,
|
||||
};
|
||||
|
||||
export function getCurrencyName(worldType: WorldType | null) {
|
||||
if (worldType === WorldType.XIANXIA) return '灵石';
|
||||
if (worldType === WorldType.WUXIA) return '铜钱';
|
||||
return '钱币';
|
||||
}
|
||||
|
||||
export function getInitialPlayerCurrency(worldType: WorldType | null) {
|
||||
return worldType === WorldType.XIANXIA ? 140 : 160;
|
||||
}
|
||||
|
||||
export function getDiscountTierForAffinity(affinity: number) {
|
||||
if (affinity >= 90) return 3;
|
||||
if (affinity >= 60) return 2;
|
||||
if (affinity >= 30) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getInventoryItemValue(item: InventoryItem) {
|
||||
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
|
||||
return Math.max(8, Math.round(item.value));
|
||||
}
|
||||
|
||||
let value = RARITY_BASE_VALUES[item.rarity];
|
||||
|
||||
if (item.tags.includes('weapon')) value += 14;
|
||||
if (item.tags.includes('armor')) value += 12;
|
||||
if (item.tags.includes('relic')) value += 16;
|
||||
if (item.tags.includes('mana')) value += 8;
|
||||
if (item.tags.includes('healing')) value += 8;
|
||||
if (item.tags.includes('material')) value += 4;
|
||||
if (item.category.includes('专属')) value += 10;
|
||||
|
||||
return Math.max(8, value);
|
||||
}
|
||||
|
||||
export function getNpcPurchasePrice(item: InventoryItem, affinity: number) {
|
||||
const discountTier = getDiscountTierForAffinity(affinity);
|
||||
const discountMultiplier = 1 - (discountTier * 0.08);
|
||||
return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier));
|
||||
}
|
||||
|
||||
export function getNpcBuybackPrice(item: InventoryItem, affinity: number) {
|
||||
const discountTier = getDiscountTierForAffinity(affinity);
|
||||
const buybackMultiplier = 0.4 + (discountTier * 0.06);
|
||||
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number, worldType: WorldType | null) {
|
||||
return `${value} ${getCurrencyName(worldType)}`;
|
||||
}
|
||||
264
src/data/editorValidation.ts
Normal file
264
src/data/editorValidation.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Character, ItemCatalogOverride, WorldType } from '../types';
|
||||
import { CharacterPresetOverride } from './characterPresets';
|
||||
import { MonsterPreset, MonsterPresetOverride } from './hostileNpcPresets';
|
||||
import { SceneNpcPresetOverride, ScenePreset, ScenePresetOverride } from './scenePresets';
|
||||
|
||||
function pushError(errors: string[], message: string) {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
function isPositiveNumber(value: number | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isKnownGender(value: unknown): value is 'male' | 'female' {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function validateBuildBuffs(errors: string[], ownerId: string, label: string, buffs: unknown) {
|
||||
if (!Array.isArray(buffs)) {
|
||||
pushError(errors, `${ownerId} ${label} must be an array.`);
|
||||
return;
|
||||
}
|
||||
|
||||
buffs.forEach((buff, index) => {
|
||||
if (!buff || typeof buff !== 'object') {
|
||||
pushError(errors, `${ownerId} ${label}[${index}] must be an object.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const typedBuff = buff as {
|
||||
name?: unknown;
|
||||
tags?: unknown;
|
||||
durationTurns?: unknown;
|
||||
};
|
||||
|
||||
if (typeof typedBuff.name !== 'string' || !typedBuff.name.trim()) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}] is missing a valid name.`);
|
||||
}
|
||||
|
||||
if (!isNonEmptyStringArray(typedBuff.tags)) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}].tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (typeof typedBuff.durationTurns !== 'number' || !Number.isFinite(typedBuff.durationTurns) || typedBuff.durationTurns <= 0) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}].durationTurns must be > 0.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCharacterOverrides(
|
||||
overrideMap: Record<string, CharacterPresetOverride>,
|
||||
characters: Character[],
|
||||
scenesByWorld: Partial<Record<WorldType, Array<Pick<ScenePreset, 'id'>>>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const validCharacterIds = new Set(characters.map(character => character.id));
|
||||
const validSceneIdsByWorld = {
|
||||
[WorldType.WUXIA]: new Set((scenesByWorld[WorldType.WUXIA] ?? []).map(scene => scene.id)),
|
||||
[WorldType.XIANXIA]: new Set((scenesByWorld[WorldType.XIANXIA] ?? []).map(scene => scene.id)),
|
||||
[WorldType.CUSTOM]: new Set((scenesByWorld[WorldType.CUSTOM] ?? []).map(scene => scene.id)),
|
||||
};
|
||||
|
||||
Object.entries(overrideMap).forEach(([characterId, override]) => {
|
||||
if (!validCharacterIds.has(characterId)) {
|
||||
pushError(errors, `未知角色覆盖:${characterId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.gender !== undefined && !isKnownGender(override.gender)) {
|
||||
pushError(errors, `${characterId} gender must be "male" or "female".`);
|
||||
}
|
||||
|
||||
if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) {
|
||||
pushError(errors, `${characterId} combatTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.skills) {
|
||||
override.skills.forEach((skill, index) => {
|
||||
const skillLabel = `${characterId} skill ${skill.id || index + 1}`;
|
||||
if (!skill.id?.trim()) pushError(errors, `${skillLabel} is missing id.`);
|
||||
if (!skill.name?.trim()) pushError(errors, `${skillLabel} is missing name.`);
|
||||
if (!isPositiveNumber(skill.range)) pushError(errors, `${skillLabel} range must be > 0.`);
|
||||
if (typeof skill.damage !== 'number' || skill.damage < 0) pushError(errors, `${skillLabel} damage must be >= 0.`);
|
||||
if (typeof skill.manaCost !== 'number' || skill.manaCost < 0) pushError(errors, `${skillLabel} manaCost must be >= 0.`);
|
||||
if (typeof skill.cooldownTurns !== 'number' || skill.cooldownTurns < 0) pushError(errors, `${skillLabel} cooldownTurns must be >= 0.`);
|
||||
if (skill.buildBuffs !== undefined) {
|
||||
validateBuildBuffs(errors, characterId, `skill ${skill.id || index + 1} buildBuffs`, skill.buildBuffs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (override.sceneBindings) {
|
||||
Object.entries(override.sceneBindings).forEach(([world, binding]) => {
|
||||
if (!binding) return;
|
||||
const worldType = world as WorldType;
|
||||
const validSceneIds = validSceneIdsByWorld[worldType];
|
||||
|
||||
if (binding.homeSceneId && !validSceneIds.has(binding.homeSceneId)) {
|
||||
pushError(errors, `${characterId} has invalid homeSceneId for ${worldType}: ${binding.homeSceneId}`);
|
||||
}
|
||||
|
||||
(binding.npcSceneIds ?? []).forEach(sceneId => {
|
||||
if (!validSceneIds.has(sceneId)) {
|
||||
pushError(errors, `${characterId} has invalid npcSceneId for ${worldType}: ${sceneId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateMonsterOverrides(
|
||||
overrideMap: Record<string, MonsterPresetOverride>,
|
||||
monsters: MonsterPreset[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const validMonsterIds = new Set(monsters.map(monster => monster.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([monsterId, override]) => {
|
||||
if (!validMonsterIds.has(monsterId)) {
|
||||
pushError(errors, `未知怪物覆盖:${monsterId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(override.baseStats ?? {}).forEach(([key, value]) => {
|
||||
const numericValue = typeof value === 'number' ? value : undefined;
|
||||
if (!isPositiveNumber(numericValue)) {
|
||||
pushError(errors, `${monsterId} baseStats.${key} must be > 0.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) {
|
||||
pushError(errors, `${monsterId} combatTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
Object.entries(override.animations ?? {}).forEach(([animation, rawConfig]) => {
|
||||
const config = rawConfig as { frames?: number; fps?: number } | undefined;
|
||||
if (!config) return;
|
||||
if (!isPositiveNumber(config.frames)) pushError(errors, `${monsterId} ${animation}.frames must be > 0.`);
|
||||
if (!isPositiveNumber(config.fps)) pushError(errors, `${monsterId} ${animation}.fps must be > 0.`);
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSceneOverrides(
|
||||
overrideMap: Record<string, ScenePresetOverride>,
|
||||
scenes: ScenePreset[],
|
||||
monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const sceneById = new Map(scenes.map(scene => [scene.id, scene]));
|
||||
const validSceneIds = new Set(scenes.map(scene => scene.id));
|
||||
const validMonsterIdsByWorld = {
|
||||
[WorldType.WUXIA]: new Set((monstersByWorld[WorldType.WUXIA] ?? []).map(monster => monster.id)),
|
||||
[WorldType.XIANXIA]: new Set((monstersByWorld[WorldType.XIANXIA] ?? []).map(monster => monster.id)),
|
||||
[WorldType.CUSTOM]: new Set((monstersByWorld[WorldType.CUSTOM] ?? []).map(monster => monster.id)),
|
||||
};
|
||||
|
||||
Object.entries(overrideMap).forEach(([sceneId, override]) => {
|
||||
const scene = sceneById.get(sceneId);
|
||||
if (!scene) {
|
||||
pushError(errors, `未知场景覆盖:${sceneId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.forwardSceneId && !validSceneIds.has(override.forwardSceneId)) {
|
||||
pushError(errors, `${sceneId} has invalid forwardSceneId: ${override.forwardSceneId}`);
|
||||
}
|
||||
|
||||
(override.connectedSceneIds ?? []).forEach(targetSceneId => {
|
||||
if (!validSceneIds.has(targetSceneId)) {
|
||||
pushError(errors, `${sceneId} has invalid connectedSceneId: ${targetSceneId}`);
|
||||
}
|
||||
});
|
||||
|
||||
(override.monsterIds ?? []).forEach(monsterId => {
|
||||
if (!validMonsterIdsByWorld[scene.worldType].has(monsterId)) {
|
||||
pushError(errors, `${sceneId} has invalid monsterId: ${monsterId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSceneNpcOverrides(
|
||||
overrideMap: Record<string, SceneNpcPresetOverride>,
|
||||
validNpcIds: string[],
|
||||
characters: Character[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const npcIdSet = new Set(validNpcIds);
|
||||
const characterIdSet = new Set(characters.map(character => character.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([npcId, override]) => {
|
||||
if (!npcIdSet.has(npcId)) {
|
||||
pushError(errors, `未知场景角色覆盖:${npcId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.gender !== undefined && !isKnownGender(override.gender)) {
|
||||
pushError(errors, `${npcId} gender must be "male" or "female".`);
|
||||
}
|
||||
|
||||
if (override.characterId && !characterIdSet.has(override.characterId)) {
|
||||
pushError(errors, `${npcId} has invalid characterId: ${override.characterId}`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateItemOverrides(
|
||||
overrideMap: Record<string, ItemCatalogOverride>,
|
||||
validItemIds: string[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const itemIdSet = new Set(validItemIds);
|
||||
|
||||
Object.entries(overrideMap).forEach(([itemId, override]) => {
|
||||
if (!itemIdSet.has(itemId)) {
|
||||
pushError(errors, `未知物品覆盖:${itemId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.name !== undefined && !override.name.trim()) {
|
||||
pushError(errors, `${itemId} name cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.category !== undefined && !override.category.trim()) {
|
||||
pushError(errors, `${itemId} category cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.description !== undefined && !override.description.trim()) {
|
||||
pushError(errors, `${itemId} description cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.tags !== undefined && !isNonEmptyStringArray(override.tags)) {
|
||||
pushError(errors, `${itemId} tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.buildProfile?.tags !== undefined && !isNonEmptyStringArray(override.buildProfile.tags)) {
|
||||
pushError(errors, `${itemId} buildProfile.tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.buildProfile?.craftTags !== undefined && !isNonEmptyStringArray(override.buildProfile.craftTags)) {
|
||||
pushError(errors, `${itemId} buildProfile.craftTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.useProfile?.buildBuffs !== undefined) {
|
||||
validateBuildBuffs(errors, itemId, 'useProfile.buildBuffs', override.useProfile.buildBuffs);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
125
src/data/encounterTransition.ts
Normal file
125
src/data/encounterTransition.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { GameState } from '../types';
|
||||
import { getFacingTowardPlayer, getMonsterGroupAnchorX, PLAYER_BASE_X_METERS } from './hostileNpcs';
|
||||
|
||||
function roundMeters(value: number) {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function lerp(start: number, end: number, progress: number) {
|
||||
return roundMeters(start + ((end - start) * progress));
|
||||
}
|
||||
|
||||
export function hasEncounterEntity(state: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>) {
|
||||
return state.sceneMonsters.length > 0 || Boolean(state.currentEncounter);
|
||||
}
|
||||
|
||||
export function buildEncounterEntryState(
|
||||
finalState: GameState,
|
||||
entryX: number,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const anchorX = getMonsterGroupAnchorX(finalState.sceneMonsters);
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
const offset = monster.xMeters - anchorX;
|
||||
const xMeters = roundMeters(entryX + offset);
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
animation: 'move' as const,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
};
|
||||
}),
|
||||
currentEncounter: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
return {
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: entryX,
|
||||
},
|
||||
sceneMonsters: [],
|
||||
};
|
||||
}
|
||||
|
||||
return finalState;
|
||||
}
|
||||
|
||||
export function buildEncounterTransitionState(
|
||||
finalState: GameState,
|
||||
sourceState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const sourceById = new Map(sourceState.sceneMonsters.map(monster => [monster.id, monster]));
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
const sourceMonster = sourceById.get(monster.id);
|
||||
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
animation: 'move' as const,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
};
|
||||
}),
|
||||
currentEncounter: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
return {
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
|
||||
},
|
||||
sceneMonsters: [],
|
||||
};
|
||||
}
|
||||
|
||||
return finalState;
|
||||
}
|
||||
|
||||
export function interpolateEncounterTransitionState(
|
||||
startState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
|
||||
finalState: GameState,
|
||||
progress: number,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const startById = new Map(startState.sceneMonsters.map(monster => [monster.id, monster]));
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
const startMonster = startById.get(monster.id);
|
||||
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
animation: progress < 1 ? ('move' as const) : monster.animation,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
};
|
||||
}),
|
||||
currentEncounter: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
const startX = startState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters ?? 0;
|
||||
const endX = finalState.currentEncounter.xMeters ?? startX;
|
||||
return {
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: lerp(startX, endX, progress),
|
||||
},
|
||||
sceneMonsters: [],
|
||||
};
|
||||
}
|
||||
|
||||
return finalState;
|
||||
}
|
||||
313
src/data/equipmentEffects.ts
Normal file
313
src/data/equipmentEffects.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
|
||||
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import type { CharacterEquipmentItem } from './characterPresets';
|
||||
import { getCharacterEquipment, getCharacterMaxMana } from './characterPresets';
|
||||
|
||||
export type EquipmentBonuses = {
|
||||
maxHpBonus: number;
|
||||
maxManaBonus: number;
|
||||
outgoingDamageMultiplier: number;
|
||||
incomingDamageMultiplier: number;
|
||||
};
|
||||
|
||||
export const EQUIPMENT_SLOTS: EquipmentSlotId[] = ['weapon', 'armor', 'relic'];
|
||||
|
||||
const WEAPON_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.06,
|
||||
uncommon: 0.1,
|
||||
rare: 0.14,
|
||||
epic: 0.2,
|
||||
legendary: 0.28,
|
||||
};
|
||||
|
||||
const ARMOR_HP_BONUS: Record<ItemRarity, number> = {
|
||||
common: 14,
|
||||
uncommon: 22,
|
||||
rare: 32,
|
||||
epic: 44,
|
||||
legendary: 58,
|
||||
};
|
||||
|
||||
const ARMOR_DAMAGE_MULTIPLIER: Record<ItemRarity, number> = {
|
||||
common: 0.97,
|
||||
uncommon: 0.94,
|
||||
rare: 0.9,
|
||||
epic: 0.86,
|
||||
legendary: 0.8,
|
||||
};
|
||||
|
||||
const RELIC_MANA_BONUS: Record<ItemRarity, number> = {
|
||||
common: 10,
|
||||
uncommon: 18,
|
||||
rare: 28,
|
||||
epic: 40,
|
||||
legendary: 54,
|
||||
};
|
||||
|
||||
const RELIC_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.02,
|
||||
uncommon: 0.04,
|
||||
rare: 0.06,
|
||||
epic: 0.09,
|
||||
legendary: 0.12,
|
||||
};
|
||||
|
||||
export function createEmptyEquipmentLoadout(): EquipmentLoadout {
|
||||
return {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentSlotLabel(slot: EquipmentSlotId) {
|
||||
return {
|
||||
weapon: '武器',
|
||||
armor: '护甲',
|
||||
relic: '饰品',
|
||||
}[slot];
|
||||
}
|
||||
|
||||
export function getEquipmentRarityLabel(rarity: ItemRarity) {
|
||||
return {
|
||||
common: '普通',
|
||||
uncommon: '优秀',
|
||||
rare: '稀有',
|
||||
epic: '史诗',
|
||||
legendary: '传说',
|
||||
}[rarity];
|
||||
}
|
||||
|
||||
function normalizePresetRarity(rarityText: string | undefined): ItemRarity {
|
||||
if (!rarityText) return 'common';
|
||||
if (/传说|legendary/i.test(rarityText)) return 'legendary';
|
||||
if (/史诗|epic/i.test(rarityText)) return 'epic';
|
||||
if (/稀有|rare/i.test(rarityText)) return 'rare';
|
||||
if (/优秀|uncommon/i.test(rarityText)) return 'uncommon';
|
||||
return 'common';
|
||||
}
|
||||
|
||||
function inferSlotFromText(value: string) {
|
||||
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) return 'weapon' as const;
|
||||
if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) return 'armor' as const;
|
||||
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) return 'relic' as const;
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferEquipmentTags(slot: EquipmentSlotId, name: string) {
|
||||
const tags = new Set<string>([slot]);
|
||||
|
||||
if (/灵|气|符|珠|盘|玉/u.test(name)) tags.add('mana');
|
||||
if (/护|守|甲|铠/u.test(name)) tags.add('armor');
|
||||
if (/刃|剑|弓|刀|拳/u.test(name)) tags.add('weapon');
|
||||
if (/徽章|护符|坠|铃|盘|令/u.test(name)) tags.add('relic');
|
||||
if (/疗|愈|血/u.test(name)) tags.add('healing');
|
||||
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
function buildStarterEquipmentItem(
|
||||
characterId: string,
|
||||
equipmentItem: CharacterEquipmentItem,
|
||||
slot: EquipmentSlotId,
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: `starter:${characterId}:${slot}`,
|
||||
category: getEquipmentSlotLabel(slot),
|
||||
name: equipmentItem.item,
|
||||
quantity: 1,
|
||||
rarity: normalizePresetRarity(equipmentItem.rarity),
|
||||
tags: inferEquipmentTags(slot, equipmentItem.item),
|
||||
equipmentSlotId: slot,
|
||||
buildProfile: inferStarterBuildProfile(slot, equipmentItem.item),
|
||||
};
|
||||
}
|
||||
|
||||
function inferStarterBuildProfile(slot: EquipmentSlotId, name: string): InventoryItem['buildProfile'] {
|
||||
const source = `${slot} ${name}`;
|
||||
|
||||
if (/弓|箭|矢/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('游击'),
|
||||
tags: normalizeBuildTags(['远射', '游击', '风行']),
|
||||
synergy: ['拉扯', '先手试探', '远程压制'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/盾|甲|铠|护/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('先锋'),
|
||||
tags: normalizeBuildTags(['守御', '护体', '先锋']),
|
||||
synergy: ['正面承压', '稳定推进', '反手压场'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/拳|锤|斧/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('狂战'),
|
||||
tags: normalizeBuildTags(['重击', '爆发', '压血']),
|
||||
synergy: ['近身爆发', '压低血线', '强攻破面'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/符|印|珠|戒|坠/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('法修'),
|
||||
tags: normalizeBuildTags(['法力', '护体', '镇邪']),
|
||||
synergy: ['法力支撑', '续战调息', '偏功能补位'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'weapon') {
|
||||
return {
|
||||
role: normalizeBuildRole('快剑'),
|
||||
tags: normalizeBuildTags(['快剑', '突进', '压制']),
|
||||
synergy: ['贴身连击', '起手压制', '追身进攻'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'armor') {
|
||||
return {
|
||||
role: normalizeBuildRole('守御'),
|
||||
tags: normalizeBuildTags(['守御', '护体']),
|
||||
synergy: ['过渡承伤', '基础防护'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: normalizeBuildRole('均衡'),
|
||||
tags: normalizeBuildTags(['均衡', '续战']),
|
||||
synergy: ['过渡补强', '基础续航'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentSlotFromItem(item: InventoryItem): EquipmentSlotId | null {
|
||||
if (item.equipmentSlotId) return item.equipmentSlotId;
|
||||
if (item.tags.includes('weapon')) return 'weapon';
|
||||
if (item.tags.includes('armor')) return 'armor';
|
||||
if (item.tags.includes('relic')) return 'relic';
|
||||
|
||||
return inferSlotFromText(`${item.category} ${item.name}`);
|
||||
}
|
||||
|
||||
export function isInventoryItemEquippable(item: InventoryItem) {
|
||||
return getEquipmentSlotFromItem(item) !== null;
|
||||
}
|
||||
|
||||
export function buildInitialEquipmentLoadout(character: Character) {
|
||||
const loadout = createEmptyEquipmentLoadout();
|
||||
const starterEquipment = getCharacterEquipment(character);
|
||||
|
||||
starterEquipment.forEach((equipmentItem, index) => {
|
||||
const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`)
|
||||
?? EQUIPMENT_SLOTS[index]
|
||||
?? null;
|
||||
if (!inferredSlot || loadout[inferredSlot]) return;
|
||||
|
||||
loadout[inferredSlot] = buildStarterEquipmentItem(character.id, equipmentItem, inferredSlot);
|
||||
});
|
||||
|
||||
return loadout;
|
||||
}
|
||||
|
||||
function getFallbackBonusesForItem(slot: EquipmentSlotId, rarity: ItemRarity) {
|
||||
if (slot === 'weapon') {
|
||||
return {
|
||||
maxHpBonus: 0,
|
||||
maxManaBonus: 0,
|
||||
outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity],
|
||||
incomingDamageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'armor') {
|
||||
return {
|
||||
maxHpBonus: ARMOR_HP_BONUS[rarity],
|
||||
maxManaBonus: 0,
|
||||
outgoingDamageBonus: 0,
|
||||
incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
maxHpBonus: 0,
|
||||
maxManaBonus: RELIC_MANA_BONUS[rarity],
|
||||
outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity],
|
||||
incomingDamageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getItemEquipmentBonuses(item: InventoryItem, slot: EquipmentSlotId) {
|
||||
const fallback = getFallbackBonusesForItem(slot, item.rarity);
|
||||
const statProfile = item.statProfile;
|
||||
|
||||
return {
|
||||
maxHpBonus: statProfile?.maxHpBonus ?? fallback.maxHpBonus,
|
||||
maxManaBonus: statProfile?.maxManaBonus ?? fallback.maxManaBonus,
|
||||
outgoingDamageBonus: statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
|
||||
incomingDamageMultiplier: statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentBonuses(loadout: EquipmentLoadout): EquipmentBonuses {
|
||||
let maxHpBonus = 0;
|
||||
let maxManaBonus = 0;
|
||||
let outgoingDamageBonus = 0;
|
||||
let incomingDamageMultiplier = 1;
|
||||
|
||||
EQUIPMENT_SLOTS.forEach(slot => {
|
||||
const item = loadout[slot];
|
||||
if (!item) return;
|
||||
|
||||
const itemBonuses = getItemEquipmentBonuses(item, slot);
|
||||
maxHpBonus += itemBonuses.maxHpBonus;
|
||||
maxManaBonus += itemBonuses.maxManaBonus;
|
||||
outgoingDamageBonus += itemBonuses.outgoingDamageBonus;
|
||||
incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier;
|
||||
});
|
||||
|
||||
return {
|
||||
maxHpBonus,
|
||||
maxManaBonus,
|
||||
outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)),
|
||||
incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEquipmentLoadoutToState(
|
||||
state: GameState,
|
||||
nextEquipment: EquipmentLoadout,
|
||||
): GameState {
|
||||
const previousBonuses = getEquipmentBonuses(state.playerEquipment ?? createEmptyEquipmentLoadout());
|
||||
const nextBonuses = getEquipmentBonuses(nextEquipment);
|
||||
const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus);
|
||||
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
|
||||
const nextMaxMana = state.playerCharacter ? getCharacterMaxMana(state.playerCharacter) : state.playerMaxMana;
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerMaxHp: nextMaxHp,
|
||||
playerHp: Math.min(nextMaxHp, state.playerHp),
|
||||
playerMaxMana: nextMaxMana,
|
||||
playerMana: nextMaxMana,
|
||||
playerEquipment: nextEquipment,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeEquipmentBonuses(bonuses: EquipmentBonuses) {
|
||||
const parts = [
|
||||
bonuses.maxHpBonus > 0 ? `气血上限 +${bonuses.maxHpBonus}` : null,
|
||||
bonuses.maxManaBonus > 0 ? `灵力上限 +${bonuses.maxManaBonus}` : null,
|
||||
bonuses.outgoingDamageMultiplier > 1 ? `伤害 x${bonuses.outgoingDamageMultiplier}` : null,
|
||||
bonuses.incomingDamageMultiplier < 1 ? `承伤 x${bonuses.incomingDamageMultiplier}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(',') : '暂无额外加成';
|
||||
}
|
||||
534
src/data/forgeSystem.ts
Normal file
534
src/data/forgeSystem.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import type {
|
||||
EquipmentSlotId,
|
||||
InventoryItem,
|
||||
ItemBuildProfile,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getSimilarBuildTags, normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import { formatCurrency } from './economy';
|
||||
import { getEquipmentSlotFromItem } from './equipmentEffects';
|
||||
import { addInventoryItems, removeInventoryItem } from './npcInteractions';
|
||||
|
||||
export type ForgeRecipeKind = 'synthesis' | 'forge';
|
||||
|
||||
type ForgeRequirement = {
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
matches: (item: InventoryItem) => boolean;
|
||||
};
|
||||
|
||||
type ForgeRecipeDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ForgeRecipeKind;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
requirements: ForgeRequirement[];
|
||||
createResult: (worldType: WorldType | null) => InventoryItem;
|
||||
};
|
||||
|
||||
export type ForgeRecipeView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ForgeRecipeKind;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
};
|
||||
|
||||
export type ForgeExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
currency: number;
|
||||
createdItem: InventoryItem;
|
||||
};
|
||||
|
||||
export type DismantleExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
outputs: InventoryItem[];
|
||||
};
|
||||
|
||||
export type ReforgeExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
reforgedItem: InventoryItem;
|
||||
currencyCost: number;
|
||||
};
|
||||
|
||||
function createItemId(prefix: string) {
|
||||
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeQuantity(quantity: number) {
|
||||
return Math.max(1, Math.floor(quantity));
|
||||
}
|
||||
|
||||
function buildMaterialItem(
|
||||
name: string,
|
||||
quantity: number,
|
||||
tags: string[],
|
||||
rarity: ItemRarity = 'uncommon',
|
||||
description?: string,
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: createItemId(`forge-material:${name}`),
|
||||
category: '材料',
|
||||
name,
|
||||
quantity: normalizeQuantity(quantity),
|
||||
rarity,
|
||||
tags: ['material', ...normalizeBuildTags(tags)],
|
||||
description,
|
||||
buildProfile: {
|
||||
role: normalizeBuildRole('工巧'),
|
||||
tags: normalizeBuildTags(tags),
|
||||
craftTags: normalizeBuildTags(tags),
|
||||
forgeRank: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
name: string;
|
||||
slot: EquipmentSlotId;
|
||||
rarity: ItemRarity;
|
||||
description: string;
|
||||
role: string;
|
||||
tags: string[];
|
||||
setId: string;
|
||||
setName: string;
|
||||
pieceName: string;
|
||||
synergy: string[];
|
||||
statProfile: ItemStatProfile;
|
||||
}) {
|
||||
return {
|
||||
id: createItemId(`forge-equip:${params.name}`),
|
||||
category: params.slot === 'weapon' ? '武器' : params.slot === 'armor' ? '护甲' : '饰品',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: params.rarity,
|
||||
tags: [
|
||||
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
...normalizeBuildTags(params.tags),
|
||||
],
|
||||
description: params.description,
|
||||
equipmentSlotId: params.slot,
|
||||
statProfile: params.statProfile,
|
||||
buildProfile: {
|
||||
role: normalizeBuildRole(params.role),
|
||||
tags: normalizeBuildTags(params.tags),
|
||||
setId: params.setId,
|
||||
setName: params.setName,
|
||||
pieceName: params.pieceName,
|
||||
synergy: params.synergy,
|
||||
craftTags: normalizeBuildTags(params.tags),
|
||||
forgeRank: 1,
|
||||
} satisfies ItemBuildProfile,
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
function buildRefinedIngot() {
|
||||
return buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare', '经过二次锻压的通用金属锭材,可用于武器与护甲锻造。');
|
||||
}
|
||||
|
||||
function buildCondensedSilk() {
|
||||
return buildMaterialItem('凝光纱', 1, ['工巧', '法力'], 'rare', '适合饰品与法器类配方的高阶纤维材料。');
|
||||
}
|
||||
|
||||
function buildTagEssence(tag: string) {
|
||||
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare', `从旧装备中提炼出的 ${tag} 构筑精粹。`);
|
||||
}
|
||||
|
||||
function buildAnyMaterialRequirement(id: string, label: string, quantity: number): ForgeRequirement {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
quantity,
|
||||
matches: item => item.tags.includes('material') || item.category.includes('材料'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNamedMaterialRequirement(name: string, quantity: number): ForgeRequirement {
|
||||
return {
|
||||
id: `name:${name}`,
|
||||
label: name,
|
||||
quantity,
|
||||
matches: item => item.name === name,
|
||||
};
|
||||
}
|
||||
|
||||
const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 3),
|
||||
],
|
||||
createResult: () => buildRefinedIngot(),
|
||||
},
|
||||
{
|
||||
id: 'synthesis-condensed-silk',
|
||||
name: '凝光纺丝',
|
||||
kind: 'synthesis',
|
||||
description: '用灵性残材与粉末纺出适合饰品锻造的凝光纱。',
|
||||
resultLabel: '凝光纱',
|
||||
currencyCost: 24,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 2),
|
||||
{
|
||||
id: 'tag:mana',
|
||||
label: '含法力标签材料',
|
||||
quantity: 1,
|
||||
matches: item => (item.tags.includes('material') || item.category.includes('材料')) && item.tags.includes('mana'),
|
||||
},
|
||||
],
|
||||
createResult: () => buildCondensedSilk(),
|
||||
},
|
||||
{
|
||||
id: 'forge-duelist-blade',
|
||||
name: '锻造 百炼追风剑',
|
||||
kind: 'forge',
|
||||
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
|
||||
resultLabel: '百炼追风剑',
|
||||
currencyCost: 72,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement('快剑精粹', 1),
|
||||
buildNamedMaterialRequirement('突进精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '百炼追风剑',
|
||||
slot: 'weapon',
|
||||
rarity: 'epic',
|
||||
description: '为快剑与追身构筑准备的锻造兵刃,挥动时更容易连续压进对手空门。',
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进', '追击'],
|
||||
setId: 'forge-set-duelist',
|
||||
setName: '追风连锋',
|
||||
pieceName: 'weapon',
|
||||
synergy: ['快剑', '突进', '追击'],
|
||||
statProfile: {
|
||||
maxManaBonus: 10,
|
||||
outgoingDamageBonus: 0.2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forge-ward-armor',
|
||||
name: '锻造 镇岳护甲',
|
||||
kind: 'forge',
|
||||
description: '面向前排承压的护甲,适合守御与护体构筑。',
|
||||
resultLabel: '镇岳护甲',
|
||||
currencyCost: 78,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement('守御精粹', 1),
|
||||
buildNamedMaterialRequirement('护体精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '镇岳护甲',
|
||||
slot: 'armor',
|
||||
rarity: 'epic',
|
||||
description: '厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。',
|
||||
role: '守御',
|
||||
tags: ['守御', '护体', '先锋'],
|
||||
setId: 'forge-set-ward',
|
||||
setName: '镇岳守阵',
|
||||
pieceName: 'armor',
|
||||
synergy: ['守御', '护体', '先锋'],
|
||||
statProfile: {
|
||||
maxHpBonus: 56,
|
||||
maxManaBonus: 8,
|
||||
outgoingDamageBonus: 0.08,
|
||||
incomingDamageMultiplier: 0.84,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forge-thunder-relic',
|
||||
name: '锻造 雷纹灵坠',
|
||||
kind: 'forge',
|
||||
description: '为法修、雷法、过载 build 提供资源与爆发补强。',
|
||||
resultLabel: '雷纹灵坠',
|
||||
currencyCost: 88,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('凝光纱', 2),
|
||||
buildNamedMaterialRequirement('法力精粹', 1),
|
||||
buildNamedMaterialRequirement('雷法精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '雷纹灵坠',
|
||||
slot: 'relic',
|
||||
rarity: 'epic',
|
||||
description: '内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。',
|
||||
role: '法修',
|
||||
tags: ['法修', '雷法', '过载'],
|
||||
setId: 'forge-set-thunder',
|
||||
setName: '雷纹御法',
|
||||
pieceName: 'relic',
|
||||
synergy: ['法修', '雷法', '过载'],
|
||||
statProfile: {
|
||||
maxHpBonus: 8,
|
||||
maxManaBonus: 42,
|
||||
outgoingDamageBonus: 0.14,
|
||||
incomingDamageMultiplier: 0.92,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function countMatchingItems(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
return inventory
|
||||
.filter(item => requirement.matches(item))
|
||||
.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
let remaining = requirement.quantity;
|
||||
let nextInventory = [...inventory];
|
||||
|
||||
for (const item of inventory) {
|
||||
if (remaining <= 0) break;
|
||||
if (!requirement.matches(item)) continue;
|
||||
|
||||
const consumed = Math.min(item.quantity, remaining);
|
||||
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
|
||||
remaining -= consumed;
|
||||
}
|
||||
|
||||
return remaining === 0 ? nextInventory : null;
|
||||
}
|
||||
|
||||
function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: ForgeRequirement[]) {
|
||||
let nextInventory = [...inventory];
|
||||
for (const requirement of requirements) {
|
||||
const consumedInventory = consumeRequirement(nextInventory, requirement);
|
||||
if (!consumedInventory) return null;
|
||||
nextInventory = consumedInventory;
|
||||
}
|
||||
return nextInventory;
|
||||
}
|
||||
|
||||
function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId | null) {
|
||||
const rarityScale: Record<ItemRarity, number> = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
};
|
||||
|
||||
const amount = rarityScale[item.rarity];
|
||||
if (slot === 'weapon') {
|
||||
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
if (slot === 'armor') {
|
||||
return [buildMaterialItem('甲片', amount, ['工巧', '守御'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
if (slot === 'relic') {
|
||||
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'], 'common')];
|
||||
}
|
||||
|
||||
function buildDismantleEssences(item: InventoryItem) {
|
||||
const buildTags = normalizeBuildTags([
|
||||
...(item.buildProfile?.tags ?? []),
|
||||
item.buildProfile?.role ?? '',
|
||||
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
|
||||
|
||||
return buildTags.map(tag => buildTagEssence(tag));
|
||||
}
|
||||
|
||||
function enhanceStatProfile(statProfile: ItemStatProfile | null | undefined, slot: EquipmentSlotId | null) {
|
||||
const nextProfile = { ...(statProfile ?? {}) };
|
||||
nextProfile.maxHpBonus = (nextProfile.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4);
|
||||
nextProfile.maxManaBonus = (nextProfile.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4);
|
||||
nextProfile.outgoingDamageBonus = Number(((nextProfile.outgoingDamageBonus ?? 0) + 0.03).toFixed(3));
|
||||
|
||||
if (typeof nextProfile.incomingDamageMultiplier === 'number') {
|
||||
nextProfile.incomingDamageMultiplier = Number(Math.max(0.72, nextProfile.incomingDamageMultiplier - 0.03).toFixed(3));
|
||||
} else if (slot === 'armor' || slot === 'relic') {
|
||||
nextProfile.incomingDamageMultiplier = slot === 'armor' ? 0.94 : 0.97;
|
||||
}
|
||||
|
||||
return nextProfile;
|
||||
}
|
||||
|
||||
function buildReforgedItem(item: InventoryItem) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot || !item.buildProfile) return null;
|
||||
|
||||
const currentTags = normalizeBuildTags(item.buildProfile.tags);
|
||||
const primaryTag = currentTags[0];
|
||||
const replacement = primaryTag
|
||||
? getSimilarBuildTags(primaryTag, 0.6).find(tag => !currentTags.includes(tag)) ?? primaryTag
|
||||
: null;
|
||||
|
||||
const nextTags = normalizeBuildTags([
|
||||
...(replacement ? [replacement] : []),
|
||||
...currentTags.slice(replacement && replacement !== primaryTag ? 1 : 0),
|
||||
]).slice(0, 3);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: createItemId(`reforge:${item.name}`),
|
||||
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
|
||||
statProfile: enhanceStatProfile(item.statProfile, slot),
|
||||
buildProfile: {
|
||||
...item.buildProfile,
|
||||
role: normalizeBuildRole(item.buildProfile.role),
|
||||
tags: nextTags,
|
||||
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
|
||||
synergy: nextTags,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
function getReforgeCost(slot: EquipmentSlotId | null) {
|
||||
if (slot === 'relic') {
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement('凝光纱', 1)],
|
||||
currencyCost: 52,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement('精炼锭材', 1)],
|
||||
currencyCost: 46,
|
||||
};
|
||||
}
|
||||
|
||||
export function getForgeRecipeViews(
|
||||
inventory: InventoryItem[],
|
||||
playerCurrency = 0,
|
||||
worldType: WorldType | null = null,
|
||||
) {
|
||||
return FORGE_RECIPES.map(recipe => ({
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
kind: recipe.kind,
|
||||
description: recipe.description,
|
||||
resultLabel: recipe.resultLabel,
|
||||
currencyCost: recipe.currencyCost,
|
||||
currencyText: formatCurrency(recipe.currencyCost, worldType),
|
||||
requirements: recipe.requirements.map(requirement => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
owned: countMatchingItems(inventory, requirement),
|
||||
})),
|
||||
canCraft:
|
||||
playerCurrency >= recipe.currencyCost &&
|
||||
recipe.requirements.every(requirement => countMatchingItems(inventory, requirement) >= requirement.quantity),
|
||||
})) satisfies ForgeRecipeView[];
|
||||
}
|
||||
|
||||
export function executeForgeRecipe(
|
||||
inventory: InventoryItem[],
|
||||
recipeId: string,
|
||||
worldType: WorldType | null,
|
||||
playerCurrency: number,
|
||||
): ForgeExecutionResult | null {
|
||||
const recipe = FORGE_RECIPES.find(candidate => candidate.id === recipeId);
|
||||
if (!recipe || playerCurrency < recipe.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
const createdItem = recipe.createResult(worldType);
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [createdItem]),
|
||||
currency: playerCurrency - recipe.currencyCost,
|
||||
createdItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeDismantleItem(inventory: InventoryItem[], itemId: string): DismantleExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
if (!slot && !targetItem.buildProfile) return null;
|
||||
|
||||
const outputs = [
|
||||
...buildDismantleBaseMaterials(targetItem, slot),
|
||||
...buildDismantleEssences(targetItem),
|
||||
];
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeReforgeItem(
|
||||
inventory: InventoryItem[],
|
||||
itemId: string,
|
||||
playerCurrency: number,
|
||||
): ReforgeExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
const reforgedItem = buildReforgedItem(targetItem);
|
||||
const reforgeCost = getReforgeCost(slot);
|
||||
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(
|
||||
removeInventoryItem(inventory, itemId, 1),
|
||||
reforgeCost.requirements,
|
||||
);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
|
||||
reforgedItem,
|
||||
currencyCost: reforgeCost.currencyCost,
|
||||
};
|
||||
}
|
||||
|
||||
export function getReforgeCostView(item: InventoryItem, worldType: WorldType | null) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
const cost = getReforgeCost(slot);
|
||||
return {
|
||||
currencyCost: cost.currencyCost,
|
||||
currencyText: formatCurrency(cost.currencyCost, worldType),
|
||||
requirements: cost.requirements.map(requirement => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildForgeSuccessText(action: 'craft' | 'dismantle' | 'reforge', params: {
|
||||
sourceItemName?: string;
|
||||
recipeName?: string;
|
||||
createdItemName?: string;
|
||||
outputNames?: string[];
|
||||
currencyText?: string;
|
||||
}) {
|
||||
if (action === 'craft') {
|
||||
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
||||
}
|
||||
|
||||
if (action === 'reforge') {
|
||||
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
||||
}
|
||||
|
||||
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`;
|
||||
}
|
||||
61
src/data/functionCatalog/flow/campTravelHomeScene.ts
Normal file
61
src/data/functionCatalog/flow/campTravelHomeScene.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AnimationState, type StoryOption } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* camp_travel_home_scene
|
||||
*
|
||||
* 从营地与同伴对话结束后,正式前往角色主线场景的控制 function。
|
||||
* 这里除了元信息,也直接收口了它的按钮构造与判定 helper。
|
||||
*/
|
||||
export const CAMP_TRAVEL_HOME_OPTION_VISUALS: StoryOption['visuals'] = {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 1.1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
|
||||
export function buildCampTravelHomeOption(sceneName: string): StoryOption {
|
||||
return {
|
||||
functionId: CAMP_TRAVEL_HOME_FUNCTION.id,
|
||||
actionText: `前往 ${sceneName}`,
|
||||
text: `前往 ${sceneName}`,
|
||||
detailText: `离开营地,前往 ${sceneName}。`,
|
||||
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCampTravelHomeFunctionId(functionId: string) {
|
||||
return functionId === CAMP_TRAVEL_HOME_FUNCTION.id;
|
||||
}
|
||||
|
||||
export function isCampTravelHomeOption(option: StoryOption) {
|
||||
return isCampTravelHomeFunctionId(option.functionId);
|
||||
}
|
||||
|
||||
export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'camp_travel_home_scene',
|
||||
domain: 'flow',
|
||||
title: '前往角色主场景',
|
||||
source: 'src/data/functionCatalog/flow/campTravelHomeScene.ts',
|
||||
summary: '营地开场后的专用旅行控制项。',
|
||||
detailedDescription:
|
||||
'它负责把开局同伴营地流程平稳切到角色真正的起始场景,并清理当前营地 encounter、战斗态和镜头残留状态。',
|
||||
trigger: '常见于开局同伴营地对话后的跟进选项。',
|
||||
execution:
|
||||
'点击后不会走普通 state function 结算,而是执行一次定制的场景迁移和历史写入。',
|
||||
result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_travel',
|
||||
uiMode: 'none',
|
||||
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
|
||||
executor: 'src/hooks/story/choiceActions.ts -> handleCampTravelHome',
|
||||
animationNote:
|
||||
'先播放营地离场的 run 演出,再切到正式场景并生成 encounter preview。',
|
||||
storyNote:
|
||||
'通过 commitGeneratedStateWithEncounterEntry 写入离营结果,并在新场景继续后续剧情。',
|
||||
uiNote: '这是专用旅行流程,不会打开 modal。',
|
||||
},
|
||||
};
|
||||
10
src/data/functionCatalog/flow/index.ts
Normal file
10
src/data/functionCatalog/flow/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { CAMP_TRAVEL_HOME_FUNCTION } from './campTravelHomeScene';
|
||||
import { CONTINUE_ADVENTURE_FUNCTION } from './storyContinueAdventure';
|
||||
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from './storyOpeningCampDialogue';
|
||||
|
||||
export const FLOW_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
CONTINUE_ADVENTURE_FUNCTION,
|
||||
CAMP_TRAVEL_HOME_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
];
|
||||
61
src/data/functionCatalog/flow/storyContinueAdventure.ts
Normal file
61
src/data/functionCatalog/flow/storyContinueAdventure.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AnimationState, type StoryOption } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* story_continue_adventure
|
||||
*
|
||||
* 聊天或特殊流程已经提前完成推理后,用于“把延后展示的 options 放出来”的控制 function。
|
||||
* 这里除了说明文本外,也直接收口了这个 function 的按钮视觉和判定 helper。
|
||||
*/
|
||||
export const CONTINUE_ADVENTURE_OPTION_VISUALS: StoryOption['visuals'] = {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 1.1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
|
||||
export function buildContinueAdventureOption(): StoryOption {
|
||||
return {
|
||||
functionId: CONTINUE_ADVENTURE_FUNCTION.id,
|
||||
actionText: CONTINUE_ADVENTURE_FUNCTION.title,
|
||||
text: CONTINUE_ADVENTURE_FUNCTION.title,
|
||||
priority: 99,
|
||||
visuals: CONTINUE_ADVENTURE_OPTION_VISUALS,
|
||||
};
|
||||
}
|
||||
|
||||
export function isContinueAdventureFunctionId(functionId: string) {
|
||||
return functionId === CONTINUE_ADVENTURE_FUNCTION.id;
|
||||
}
|
||||
|
||||
export function isContinueAdventureOption(option: StoryOption) {
|
||||
return isContinueAdventureFunctionId(option.functionId);
|
||||
}
|
||||
|
||||
export const CONTINUE_ADVENTURE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'story_continue_adventure',
|
||||
domain: 'flow',
|
||||
title: '继续冒险',
|
||||
source: 'src/data/functionCatalog/flow/storyContinueAdventure.ts',
|
||||
summary: '承接 deferredOptions 的延迟展示控制项。',
|
||||
detailedDescription:
|
||||
'它不是重新推理剧情,而是在某些流程已经先算好后续 options 时,给玩家一个清晰的继续按钮,再把 deferredOptions 真正放回界面。',
|
||||
trigger: '常见于 npc_chat 等先生成正文、后延迟显示选项的链路。',
|
||||
execution:
|
||||
'点击后主要走本地 UI / state 还原逻辑,而不是再请求一次新的故事推理。',
|
||||
result:
|
||||
'玩家会看到之前已经准备好的后续冒险选项,误以为“没继续生成”的风险也会降低。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'reveal_deferred_options',
|
||||
uiMode: 'none',
|
||||
visuals: CONTINUE_ADVENTURE_OPTION_VISUALS,
|
||||
executor: 'src/hooks/story/choiceActions.ts -> handleChoice',
|
||||
animationNote: '按钮本身沿用轻量前进动画,但不驱动新的战斗或场景演出。',
|
||||
storyNote:
|
||||
'点击时直接把 deferredOptions 放回 currentStory.options,不再请求新的 generateNextStep。',
|
||||
uiNote: '这是一个流程确认按钮,不会弹 modal。',
|
||||
},
|
||||
};
|
||||
37
src/data/functionCatalog/flow/storyOpeningCampDialogue.ts
Normal file
37
src/data/functionCatalog/flow/storyOpeningCampDialogue.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* story_opening_camp_dialogue
|
||||
*
|
||||
* 开局营地场景的特殊对话控制 function。
|
||||
* 这里同时提供判定 helper,供 prompt 和故事流程判断是否进入营地开场对白模式。
|
||||
*/
|
||||
export function isOpeningCampDialogueFunctionId(
|
||||
functionId: string | null | undefined,
|
||||
) {
|
||||
return functionId === STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
}
|
||||
|
||||
export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry =
|
||||
{
|
||||
id: 'story_opening_camp_dialogue',
|
||||
domain: 'flow',
|
||||
title: '营地开场对话',
|
||||
source: 'src/data/functionCatalog/flow/storyOpeningCampDialogue.ts',
|
||||
summary: '驱动开局营地 4 到 6 行开场对白的流程项。',
|
||||
detailedDescription:
|
||||
'它告诉 prompt 与运行时:当前不是普通探索推进,而是要围绕营地背景、初始同伴态度和刚进入世界的紧张感生成一段结构化开场对白。',
|
||||
trigger: '开局同伴营地场景进入正式对话时出现。',
|
||||
execution:
|
||||
'点击后会进入 opening adventure 的特殊对话生成链,而不是普通 function option 链路。',
|
||||
result: '玩家会先看到一段营地对白,再衔接后续 npc_chat 或离营流程。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/openingAdventure.ts + src/services/prompt.ts',
|
||||
animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。',
|
||||
storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。',
|
||||
uiNote: '不弹 modal,直接进入对白流。',
|
||||
},
|
||||
};
|
||||
60
src/data/functionCatalog/index.ts
Normal file
60
src/data/functionCatalog/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { FLOW_FUNCTION_DOCUMENTATION } from './flow';
|
||||
import { NPC_FUNCTION_DOCUMENTATION } from './npc';
|
||||
import { PANEL_FUNCTION_DOCUMENTATION } from './panel';
|
||||
import {
|
||||
STATE_FUNCTION_DEFINITIONS,
|
||||
STATE_FUNCTION_DOCUMENTATION,
|
||||
STATE_FUNCTION_PROMPT_DESCRIPTIONS,
|
||||
STATE_FUNCTION_SOURCES,
|
||||
} from './state';
|
||||
import { TREASURE_FUNCTION_DOCUMENTATION } from './treasure';
|
||||
import type { FunctionDocumentationEntry } from './types';
|
||||
|
||||
export * from './flow/campTravelHomeScene';
|
||||
export * from './flow/storyContinueAdventure';
|
||||
export * from './flow/storyOpeningCampDialogue';
|
||||
export * from './npc/npcChat';
|
||||
export * from './npc/npcFight';
|
||||
export * from './npc/npcGift';
|
||||
export * from './npc/npcHelp';
|
||||
export * from './npc/npcLeave';
|
||||
export * from './npc/npcPreviewTalk';
|
||||
export * from './npc/npcQuestAccept';
|
||||
export * from './npc/npcQuestTurnIn';
|
||||
export * from './npc/npcRecruit';
|
||||
export * from './npc/npcSpar';
|
||||
export * from './npc/npcTrade';
|
||||
export * from './panel/equipmentEquip';
|
||||
export * from './panel/equipmentUnequip';
|
||||
export * from './panel/forgeCraft';
|
||||
export * from './panel/forgeDismantle';
|
||||
export * from './panel/forgeReforge';
|
||||
export * from './panel/inventoryUse';
|
||||
export * from './state';
|
||||
export * from './treasure/treasureInspect';
|
||||
export * from './treasure/treasureLeave';
|
||||
export * from './treasure/treasureSecure';
|
||||
export * from './types';
|
||||
|
||||
export const ALL_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
...STATE_FUNCTION_DOCUMENTATION,
|
||||
...NPC_FUNCTION_DOCUMENTATION,
|
||||
...TREASURE_FUNCTION_DOCUMENTATION,
|
||||
...FLOW_FUNCTION_DOCUMENTATION,
|
||||
...PANEL_FUNCTION_DOCUMENTATION,
|
||||
];
|
||||
|
||||
export const ALL_FUNCTION_DOCUMENTATION_MAP = new Map(
|
||||
ALL_FUNCTION_DOCUMENTATION.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
|
||||
export function getFunctionDocumentationById(functionId: string) {
|
||||
return ALL_FUNCTION_DOCUMENTATION_MAP.get(functionId) ?? null;
|
||||
}
|
||||
|
||||
export {
|
||||
STATE_FUNCTION_DEFINITIONS,
|
||||
STATE_FUNCTION_DOCUMENTATION,
|
||||
STATE_FUNCTION_PROMPT_DESCRIPTIONS,
|
||||
STATE_FUNCTION_SOURCES,
|
||||
};
|
||||
26
src/data/functionCatalog/npc/index.ts
Normal file
26
src/data/functionCatalog/npc/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { NPC_CHAT_FUNCTION } from './npcChat';
|
||||
import { NPC_FIGHT_FUNCTION } from './npcFight';
|
||||
import { NPC_GIFT_FUNCTION } from './npcGift';
|
||||
import { NPC_HELP_FUNCTION } from './npcHelp';
|
||||
import { NPC_LEAVE_FUNCTION } from './npcLeave';
|
||||
import { NPC_PREVIEW_TALK_FUNCTION } from './npcPreviewTalk';
|
||||
import { NPC_QUEST_ACCEPT_FUNCTION } from './npcQuestAccept';
|
||||
import { NPC_QUEST_TURN_IN_FUNCTION } from './npcQuestTurnIn';
|
||||
import { NPC_RECRUIT_FUNCTION } from './npcRecruit';
|
||||
import { NPC_SPAR_FUNCTION } from './npcSpar';
|
||||
import { NPC_TRADE_FUNCTION } from './npcTrade';
|
||||
|
||||
export const NPC_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
NPC_TRADE_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_SPAR_FUNCTION,
|
||||
NPC_HELP_FUNCTION,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_GIFT_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
NPC_QUEST_ACCEPT_FUNCTION,
|
||||
NPC_QUEST_TURN_IN_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
];
|
||||
33
src/data/functionCatalog/npc/npcChat.ts
Normal file
33
src/data/functionCatalog/npc/npcChat.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_chat
|
||||
*
|
||||
* 与眼前 NPC 围绕当前话题继续交谈的 function。
|
||||
*/
|
||||
export const NPC_CHAT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_chat',
|
||||
domain: 'npc',
|
||||
title: '继续交谈',
|
||||
source: 'src/data/functionCatalog/npc/npcChat.ts',
|
||||
summary: '围绕当前话题展开聊天并累积关系推进。',
|
||||
detailedDescription:
|
||||
'它会先生成一段聊天正文,再在后台继续生成新的冒险选项。当前 UI 中,新选项通常会被延后到 story_continue_adventure 之后再展示。',
|
||||
trigger:
|
||||
'在 NPC 交互菜单里按不同话题重复出现,functionId 相同但 actionText 和 detailText 可不同。',
|
||||
execution:
|
||||
'点击后先进入流式聊天,再触发一次新的剧情推理,并把真正的新 options 放入 deferredOptions。',
|
||||
result:
|
||||
'玩家会看到对话正文、关系变化和后续继续冒险入口,而不是立刻显示新一轮选项。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'stream_then_defer',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> commitNpcChatState',
|
||||
animationNote: '重点在流式对白和轻量站场,不额外打开窗口。',
|
||||
storyNote:
|
||||
'先生成聊天正文,再把真正的新选项放入 deferredOptions,等待 continue adventure。',
|
||||
uiNote: '不弹 modal,直接进入聊天流。',
|
||||
compactDetailText: '聊聊并试探口风',
|
||||
},
|
||||
};
|
||||
31
src/data/functionCatalog/npc/npcFight.ts
Normal file
31
src/data/functionCatalog/npc/npcFight.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_fight
|
||||
*
|
||||
* 与眼前 NPC 直接开战的强制冲突 function。
|
||||
*/
|
||||
export const NPC_FIGHT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_fight',
|
||||
domain: 'npc',
|
||||
title: '与对方战斗',
|
||||
source: 'src/data/functionCatalog/npc/npcFight.ts',
|
||||
summary: '把当前 NPC 交互直接导向敌对战斗。',
|
||||
detailedDescription:
|
||||
'无论对方原本是中立还是敌对,选择这个 function 都表示玩家主动接受或制造正面冲突,后续会切到 NPC 战斗模式。',
|
||||
trigger: '在敌对 NPC 遭遇或普通 NPC 交互菜单里都可能出现。',
|
||||
execution:
|
||||
'点击后会切换 currentBattleNpcId / currentNpcBattleMode,并进入本地战斗结算链路。',
|
||||
result:
|
||||
'交互界面转为战斗,战后会按 fight_victory 等结果处理掉落、好感和任务推进。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。',
|
||||
storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。',
|
||||
uiNote: '不弹 modal,直接进入战斗。',
|
||||
compactDetailText: '战斗决胜负',
|
||||
},
|
||||
};
|
||||
47
src/data/functionCatalog/npc/npcGift.ts
Normal file
47
src/data/functionCatalog/npc/npcGift.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { GiftModalState } from '../../../hooks/story/uiTypes';
|
||||
import type { Encounter, GameState } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_gift
|
||||
*
|
||||
* 向眼前 NPC 送礼的入口 function。
|
||||
* 这里直接提供 gift modal 的默认构造逻辑。
|
||||
*/
|
||||
export function buildNpcGiftModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
): GiftModalState {
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId: state.playerInventory[0]?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_gift',
|
||||
domain: 'npc',
|
||||
title: '向该角色送礼',
|
||||
source: 'src/data/functionCatalog/npc/npcGift.ts',
|
||||
summary: '打开送礼面板并根据礼物质量结算 affinity 变化。',
|
||||
detailedDescription:
|
||||
'它会把当前互动引到礼物选择 modal,通过本地规则估算礼物对该 NPC 的吸引力和好感增益,避免送礼结果漂移。',
|
||||
trigger: '玩家背包里存在可送出的物品时出现在 NPC 交互菜单里。',
|
||||
execution:
|
||||
'首次点击只打开 gift modal,确认礼物后再调用 commitGeneratedState 把送礼结果写回主流程。',
|
||||
result: '玩家可立即看到好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'modal_then_generate',
|
||||
uiMode: 'gift_modal',
|
||||
executor:
|
||||
'src/hooks/story/storyGenerationState.ts + src/hooks/story/npcInteraction.ts',
|
||||
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
|
||||
uiNote: '会先打开 gift modal,并默认选中背包第一件可见物品。',
|
||||
compactDetailText: '送礼提升好感',
|
||||
},
|
||||
};
|
||||
30
src/data/functionCatalog/npc/npcHelp.ts
Normal file
30
src/data/functionCatalog/npc/npcHelp.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_help
|
||||
*
|
||||
* 向眼前 NPC 寻求帮助或支援的 function。
|
||||
*/
|
||||
export const NPC_HELP_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_help',
|
||||
domain: 'npc',
|
||||
title: '向对方寻求帮助',
|
||||
source: 'src/data/functionCatalog/npc/npcHelp.ts',
|
||||
summary: '从 NPC 处申请一次性补给、回复或援助。',
|
||||
detailedDescription:
|
||||
'它把 NPC 互动导向资源支持,奖励内容由本地规则预先计算,避免关键数值完全交给模型临场决定。',
|
||||
trigger: 'NPC 允许帮助且该角色尚未消耗过 helpUsed 时出现。',
|
||||
execution: '点击后直接按本地奖励规则结算,然后继续推进后续剧情。',
|
||||
result:
|
||||
'玩家可能获得生命、灵力、冷却收益或道具补给,并让故事承接“被对方照应了一次”。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_effect_then_generate',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
animationNote: '不单独开窗口,直接在当前交互里结算帮助结果。',
|
||||
storyNote: '点击后立即按本地奖励规则结算,并继续生成新的故事状态。',
|
||||
uiNote: '不弹 modal,直接获得帮助反馈。',
|
||||
compactDetailText: '看看能得到什么帮助',
|
||||
},
|
||||
};
|
||||
29
src/data/functionCatalog/npc/npcLeave.ts
Normal file
29
src/data/functionCatalog/npc/npcLeave.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_leave
|
||||
*
|
||||
* 结束当前 NPC 互动、回到探索态的 function。
|
||||
*/
|
||||
export const NPC_LEAVE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_leave',
|
||||
domain: 'npc',
|
||||
title: '不作停留,继续前行',
|
||||
source: 'src/data/functionCatalog/npc/npcLeave.ts',
|
||||
summary: '退出当前 NPC 交互并把注意力拉回前路。',
|
||||
detailedDescription:
|
||||
'它为玩家提供一个明确的“暂时结束这段互动”的出口,避免必须通过交易、聊天或战斗才能离开当前 encounter。',
|
||||
trigger: '绝大多数普通 NPC 菜单的默认退出项。',
|
||||
execution: '点击后清理当前 NPC 交互态,并继续进入下一轮探索或故事推进。',
|
||||
result: '玩家会离开当前角色,恢复到探索导向的故事节奏。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_effect_then_generate',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
animationNote: '通常只做轻量离场,不单独打开窗口。',
|
||||
storyNote: '点击后结束当前 NPC 交互,并回到新的探索剧情。',
|
||||
uiNote: '不弹 modal,直接退出互动。',
|
||||
compactDetailText: '离开并继续探索',
|
||||
},
|
||||
};
|
||||
71
src/data/functionCatalog/npc/npcPreviewTalk.ts
Normal file
71
src/data/functionCatalog/npc/npcPreviewTalk.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type StoryOption,
|
||||
} from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_preview_talk
|
||||
*
|
||||
* 眼前出现 NPC 预览后,把玩家从“远处观察”切换到“正式交互”的入口 function。
|
||||
* 这里直接收口了这个选项的视觉和构造 helper。
|
||||
*/
|
||||
export const NPC_PREVIEW_TALK_OPTION_VISUALS: StoryOption['visuals'] = {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
|
||||
export function buildNpcPreviewTalkOption(encounter: Encounter): StoryOption {
|
||||
const actionText = `与 ${encounter.npcName} 交谈`;
|
||||
return {
|
||||
functionId: NPC_PREVIEW_TALK_FUNCTION.id,
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '先专注于眼前的人,再决定如何回应。',
|
||||
priority: 3,
|
||||
visuals: NPC_PREVIEW_TALK_OPTION_VISUALS,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNpcPreviewTalkFunctionId(functionId: string) {
|
||||
return functionId === NPC_PREVIEW_TALK_FUNCTION.id;
|
||||
}
|
||||
|
||||
export function isNpcPreviewTalkOption(option: StoryOption) {
|
||||
return isNpcPreviewTalkFunctionId(option.functionId);
|
||||
}
|
||||
|
||||
export const NPC_PREVIEW_TALK_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_preview_talk',
|
||||
domain: 'npc',
|
||||
title: '转向眼前角色',
|
||||
source: 'src/data/functionCatalog/npc/npcPreviewTalk.ts',
|
||||
summary: '把当前遭遇从前探预览切入正式 NPC 交互层。',
|
||||
detailedDescription:
|
||||
'它不直接完成一轮聊天,而是把镜头、当前 encounter 和可选项池真正切换到角色互动上下文,为后续 trade / chat / recruit 等动作铺路。',
|
||||
trigger:
|
||||
'通常在探索过程中已经锁定眼前 NPC,并且玩家准备正式和对方互动时出现。',
|
||||
execution:
|
||||
'第一次点击后主要进入 NPC interaction 流程,而不是直接结算完整剧情回合。',
|
||||
result:
|
||||
'玩家会进入针对该 NPC 的专属交互菜单,并开始看到交易、聊天、切磋等本地规则项。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'enter_interaction',
|
||||
uiMode: 'npc_interaction_entry',
|
||||
visuals: NPC_PREVIEW_TALK_OPTION_VISUALS,
|
||||
executor:
|
||||
'src/hooks/story/choiceActions.ts + src/hooks/story/npcEncounterActions.ts',
|
||||
animationNote:
|
||||
'保持轻量 idle 站场,不做额外位移,重点是把交互焦点切到 NPC 身上。',
|
||||
storyNote:
|
||||
'普通 NPC 直接进入 enterNpcInteraction;初始同伴会改走 opening adventure 特殊序列。',
|
||||
uiNote: '不弹 modal,而是切换到 NPC interaction 选项面板。',
|
||||
compactDetailText: '先专注于眼前的人',
|
||||
},
|
||||
};
|
||||
20
src/data/functionCatalog/npc/npcQuestAccept.ts
Normal file
20
src/data/functionCatalog/npc/npcQuestAccept.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_quest_accept
|
||||
*
|
||||
* 接下眼前 NPC 委托的 function。
|
||||
*/
|
||||
export const NPC_QUEST_ACCEPT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_quest_accept',
|
||||
domain: 'npc',
|
||||
title: '接下委托',
|
||||
source: 'src/data/functionCatalog/npc/npcQuestAccept.ts',
|
||||
summary: '把 NPC 提供的任务写入 quest log。',
|
||||
detailedDescription:
|
||||
'它用于把当前交互中的委托正式变成可追踪任务,并让故事明确承接“玩家已经答应了这件事”。',
|
||||
trigger: 'NPC 当前没有活跃任务且本地规则成功为其生成了一个可接任务时出现。',
|
||||
execution: '点击后会在本地 questFlow 中创建 active quest,并继续推进剧情。',
|
||||
result: '玩家获得新的任务目标、任务文本与后续交付条件。',
|
||||
active: true,
|
||||
};
|
||||
21
src/data/functionCatalog/npc/npcQuestTurnIn.ts
Normal file
21
src/data/functionCatalog/npc/npcQuestTurnIn.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_quest_turn_in
|
||||
*
|
||||
* 向眼前 NPC 交付已完成委托的 function。
|
||||
*/
|
||||
export const NPC_QUEST_TURN_IN_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_quest_turn_in',
|
||||
domain: 'npc',
|
||||
title: '交付委托',
|
||||
source: 'src/data/functionCatalog/npc/npcQuestTurnIn.ts',
|
||||
summary: '完成任务回报与任务状态收尾的 NPC function。',
|
||||
detailedDescription:
|
||||
'当相关任务已经完成,它负责把“领奖、交付、兑现承诺”从普通聊天里独立出来,确保 quest log 与剧情反馈同步更新。',
|
||||
trigger: '玩家在该 NPC 名下拥有 status=completed 的任务时出现。',
|
||||
execution:
|
||||
'点击后走本地 questFlow 的 turn-in 逻辑,结算奖励并推进 story history。',
|
||||
result: '任务状态会被正式收尾,玩家获得奖励与交付文本。',
|
||||
active: true,
|
||||
};
|
||||
57
src/data/functionCatalog/npc/npcRecruit.ts
Normal file
57
src/data/functionCatalog/npc/npcRecruit.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { RecruitModalState } from '../../../hooks/story/uiTypes';
|
||||
import type { Encounter, GameState } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_recruit
|
||||
*
|
||||
* 邀请眼前 NPC 加入队伍的 function。
|
||||
* 这里直接收口了“队伍已满时弹窗,否则立即进入招募序列”的分流逻辑。
|
||||
*/
|
||||
export function buildNpcRecruitModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
): RecruitModalState {
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
selectedReleaseNpcId: state.companions[0]?.npcId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldNpcRecruitOpenModal(
|
||||
companionCount: number,
|
||||
maxCompanions: number,
|
||||
) {
|
||||
return companionCount >= maxCompanions;
|
||||
}
|
||||
|
||||
export const NPC_RECRUIT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_recruit',
|
||||
domain: 'npc',
|
||||
title: '邀请该角色加入队伍',
|
||||
source: 'src/data/functionCatalog/npc/npcRecruit.ts',
|
||||
summary: '把 NPC 转化为同伴的招募入口。',
|
||||
detailedDescription:
|
||||
'它负责承接好感达标或开局同行的特殊情境,让 NPC 进入 recruitment 流程。若当前队伍已满,会先弹出替换同伴的确认流程。',
|
||||
trigger: 'NPC 可招募、尚未 recruited,且满足 affinity 或特殊开局条件时出现。',
|
||||
execution:
|
||||
'队伍未满时可直接进入招募流程;队伍已满时先打开 recruit modal,再确认替换目标。',
|
||||
result:
|
||||
'成功后 NPC 会加入 companions / roster,并改写后续剧情关系与队伍构成。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'recruit_modal_or_sequence',
|
||||
executor:
|
||||
'src/hooks/story/storyGenerationState.ts + src/hooks/story/npcInteraction.ts',
|
||||
animationNote:
|
||||
'若直接进入招募,会先播放招募对话流;若队伍已满,先进入替换弹窗。',
|
||||
storyNote:
|
||||
'队伍未满时第一次点击就会进入招募对白序列;队伍已满时要等 modal 确认后再继续。',
|
||||
uiNote:
|
||||
'会根据队伍人数决定是立刻招募,还是先打开 recruit modal 选择释放对象。',
|
||||
compactDetailText: '谈谈是否愿意入队',
|
||||
},
|
||||
};
|
||||
29
src/data/functionCatalog/npc/npcSpar.ts
Normal file
29
src/data/functionCatalog/npc/npcSpar.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_spar
|
||||
*
|
||||
* 与眼前 NPC 切磋武艺的非致命战斗 function。
|
||||
*/
|
||||
export const NPC_SPAR_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_spar',
|
||||
domain: 'npc',
|
||||
title: '与对方切磋武艺',
|
||||
source: 'src/data/functionCatalog/npc/npcSpar.ts',
|
||||
summary: '把 NPC 互动切到点到为止的 spar 战斗模式。',
|
||||
detailedDescription:
|
||||
'它和 npc_fight 共用战斗骨架,但数值与结算目标不同,重点是以低伤害对局换取关系推进,而不是击杀或掠夺。',
|
||||
trigger: '在可交流的 NPC 菜单里作为友好或试探性过招选项出现。',
|
||||
execution: '点击后进入 spar 模式,本地规则会限制伤害与战后回场逻辑。',
|
||||
result: '切磋结束后通常返回 NPC 场景,并小幅提高 affinity 或推进相关任务。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
animationNote: '切到 spar 战斗模式后,由战斗播放链路驱动切磋演出。',
|
||||
storyNote: '不会先弹窗,直接进入点到为止的切磋流程。',
|
||||
uiNote: '不弹 modal,直接切磋。',
|
||||
compactDetailText: '切磋几招看身手',
|
||||
},
|
||||
};
|
||||
51
src/data/functionCatalog/npc/npcTrade.ts
Normal file
51
src/data/functionCatalog/npc/npcTrade.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { TradeModalState } from '../../../hooks/story/uiTypes';
|
||||
import type { Encounter, GameState, InventoryItem } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_trade
|
||||
*
|
||||
* 与眼前 NPC 发起交易的入口 function。
|
||||
* 这里直接提供 trade modal 的默认构造逻辑,避免窗口初始化散落在别处。
|
||||
*/
|
||||
export function buildNpcTradeModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
npcInventory: InventoryItem[],
|
||||
): TradeModalState {
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
mode: 'buy',
|
||||
selectedNpcItemId: npcInventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: state.playerInventory[0]?.id ?? null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export const NPC_TRADE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_trade',
|
||||
domain: 'npc',
|
||||
title: '与对方交易',
|
||||
source: 'src/data/functionCatalog/npc/npcTrade.ts',
|
||||
summary: '打开 NPC 交易流程并结算买卖或交换。',
|
||||
detailedDescription:
|
||||
'它负责把当前交互引到交易面板,展示 NPC 库存、折扣和可交换物。第一次点击通常只打开 modal,真正确认后才继续推进剧情。',
|
||||
trigger: '当 NPC 允许交易且自身库存非空时出现在 NPC 交互菜单里。',
|
||||
execution:
|
||||
'首次点击进入 trade modal,确认后再通过 commitGeneratedState 把结果写回主流程。',
|
||||
result: '玩家可以买入、以物易物,或在失败时得到明确的价值差提示。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'modal_then_generate',
|
||||
uiMode: 'trade_modal',
|
||||
executor:
|
||||
'src/hooks/story/storyGenerationState.ts + src/hooks/story/npcInteraction.ts',
|
||||
animationNote: '第一次点击不播额外战斗或位移动画,重点是切到交易窗口。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmTrade 之后,而不是打开 modal 的瞬间。',
|
||||
uiNote: '会先打开交易 modal,并预选 NPC 第一件商品与玩家第一件可卖物品。',
|
||||
compactDetailText: '查看库存与价格',
|
||||
},
|
||||
};
|
||||
21
src/data/functionCatalog/panel/equipmentEquip.ts
Normal file
21
src/data/functionCatalog/panel/equipmentEquip.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* equipment_equip
|
||||
*
|
||||
* 在装备面板中把背包物品穿戴到对应槽位的 function。
|
||||
*/
|
||||
export const EQUIPMENT_EQUIP_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'equipment_equip',
|
||||
domain: 'panel',
|
||||
title: '装备物品',
|
||||
source: 'src/data/functionCatalog/panel/equipmentEquip.ts',
|
||||
summary: '负责角色装备替换、背包回收和属性重算。',
|
||||
detailedDescription:
|
||||
'它是装备面板触发的局部动作,不通过自由叙事来决定结果,而是由本地规则严格处理槽位、被替换装备和属性改写。',
|
||||
trigger: '玩家在非战斗状态下从背包点击一件可装备物品时触发。',
|
||||
execution:
|
||||
'先更新 equipment loadout 与背包,再通过 commitGeneratedState 把装备结果写进故事历史。',
|
||||
result: '角色装备变更生效,必要时旧装备回到背包,故事中会留下装备变动说明。',
|
||||
active: true,
|
||||
};
|
||||
20
src/data/functionCatalog/panel/equipmentUnequip.ts
Normal file
20
src/data/functionCatalog/panel/equipmentUnequip.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* equipment_unequip
|
||||
*
|
||||
* 从装备槽位卸下一件装备并放回背包的 function。
|
||||
*/
|
||||
export const EQUIPMENT_UNEQUIP_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'equipment_unequip',
|
||||
domain: 'panel',
|
||||
title: '卸下装备',
|
||||
source: 'src/data/functionCatalog/panel/equipmentUnequip.ts',
|
||||
summary: '处理卸装、背包回收和属性回退。',
|
||||
detailedDescription:
|
||||
'它是装备系统的反向动作,确保角色在非战斗状态下可以安全地卸装,而不会破坏背包数量或 loadout 一致性。',
|
||||
trigger: '玩家在非战斗状态下从装备面板点击某个已装备槽位时触发。',
|
||||
execution: '先把装备放回背包,再重算应用到 GameState 的角色装备效果。',
|
||||
result: '装备槽位清空、背包新增对应物品,并留下卸装结果文本。',
|
||||
active: true,
|
||||
};
|
||||
21
src/data/functionCatalog/panel/forgeCraft.ts
Normal file
21
src/data/functionCatalog/panel/forgeCraft.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* forge_craft
|
||||
*
|
||||
* 在锻造面板中制作配方产物的 function。
|
||||
*/
|
||||
export const FORGE_CRAFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'forge_craft',
|
||||
domain: 'panel',
|
||||
title: '制作配方',
|
||||
source: 'src/data/functionCatalog/panel/forgeCraft.ts',
|
||||
summary: '执行锻造配方、扣除材料和货币并产出新物品。',
|
||||
detailedDescription:
|
||||
'它由锻造系统本地校验配方合法性与资源足额情况,再把产物、消耗和结果文本统一写回游戏状态。',
|
||||
trigger: '玩家在非战斗状态下从锻造面板选择一条可制作配方时触发。',
|
||||
execution:
|
||||
'先执行 executeForgeRecipe,再通过 commitGeneratedState 写回制作结果。',
|
||||
result: '玩家消耗材料和钱币,获得新物品,同时故事历史记录一次制作行为。',
|
||||
active: true,
|
||||
};
|
||||
21
src/data/functionCatalog/panel/forgeDismantle.ts
Normal file
21
src/data/functionCatalog/panel/forgeDismantle.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* forge_dismantle
|
||||
*
|
||||
* 在锻造面板中拆解物品回收材料的 function。
|
||||
*/
|
||||
export const FORGE_DISMANTLE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'forge_dismantle',
|
||||
domain: 'panel',
|
||||
title: '拆解物品',
|
||||
source: 'src/data/functionCatalog/panel/forgeDismantle.ts',
|
||||
summary: '执行拆解并返还材料收益的锻造 function。',
|
||||
detailedDescription:
|
||||
'它允许玩家把现有物品重新拆回材料,由本地锻造规则决定返还内容,避免拆解产出与物品设计脱节。',
|
||||
trigger: '玩家在非战斗状态下于锻造面板选择可拆解物品时触发。',
|
||||
execution:
|
||||
'先执行 executeDismantleItem,再通过 commitGeneratedState 记录拆解结果。',
|
||||
result: '原物品被移除,背包增加拆解产物,并留下拆解说明文本。',
|
||||
active: true,
|
||||
};
|
||||
22
src/data/functionCatalog/panel/forgeReforge.ts
Normal file
22
src/data/functionCatalog/panel/forgeReforge.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* forge_reforge
|
||||
*
|
||||
* 在锻造面板中重铸现有物品的 function。
|
||||
*/
|
||||
export const FORGE_REFORGE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'forge_reforge',
|
||||
domain: 'panel',
|
||||
title: '重铸物品',
|
||||
source: 'src/data/functionCatalog/panel/forgeReforge.ts',
|
||||
summary: '支付货币后重构物品结果的锻造 function。',
|
||||
detailedDescription:
|
||||
'它用于把已有物品重新洗练成新的结果,由本地规则负责消耗、生成与可视化说明,避免重铸结果脱离装备系统。',
|
||||
trigger: '玩家在非战斗状态下于锻造面板选择可重铸物品时触发。',
|
||||
execution:
|
||||
'先执行 executeReforgeItem 和花费计算,再通过 commitGeneratedState 写回重铸结果。',
|
||||
result:
|
||||
'玩家消耗货币、失去旧版本物品并获得重铸后的新物品,同时剧情历史记录本次重铸。',
|
||||
active: true,
|
||||
};
|
||||
16
src/data/functionCatalog/panel/index.ts
Normal file
16
src/data/functionCatalog/panel/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { EQUIPMENT_EQUIP_FUNCTION } from './equipmentEquip';
|
||||
import { EQUIPMENT_UNEQUIP_FUNCTION } from './equipmentUnequip';
|
||||
import { FORGE_CRAFT_FUNCTION } from './forgeCraft';
|
||||
import { FORGE_DISMANTLE_FUNCTION } from './forgeDismantle';
|
||||
import { FORGE_REFORGE_FUNCTION } from './forgeReforge';
|
||||
import { INVENTORY_USE_FUNCTION } from './inventoryUse';
|
||||
|
||||
export const PANEL_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
INVENTORY_USE_FUNCTION,
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
];
|
||||
21
src/data/functionCatalog/panel/inventoryUse.ts
Normal file
21
src/data/functionCatalog/panel/inventoryUse.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* inventory_use
|
||||
*
|
||||
* 在背包中消耗一个可用物品的面板动作 function。
|
||||
*/
|
||||
export const INVENTORY_USE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'inventory_use',
|
||||
domain: 'panel',
|
||||
title: '使用背包物品',
|
||||
source: 'src/data/functionCatalog/panel/inventoryUse.ts',
|
||||
summary: '从背包面板结算药品、灵力物或 build buff 道具。',
|
||||
detailedDescription:
|
||||
'它不属于场景探索目录,而是 UI 面板动作。点击具体物品后,本地规则会先结算回复与 buff,再把结果写回剧情历史。',
|
||||
trigger: '玩家在 InventoryPanel 中点击可使用物品时触发。',
|
||||
execution:
|
||||
'本地结算 hp / mana / cooldown / buildBuffs,然后通过 commitGeneratedState 把结果挂回主故事。',
|
||||
result: '物品数量减少,角色资源更新,故事文本会明确记录“使用了什么”。',
|
||||
active: true,
|
||||
};
|
||||
60
src/data/functionCatalog/state/battleAllInCrush.ts
Normal file
60
src/data/functionCatalog/state/battleAllInCrush.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_all_in_crush
|
||||
*
|
||||
* 战斗中的正面爆发动作。它要求主角不绕、不拖,直接把当前回合的叙事、
|
||||
* 技能权重和视觉表现都推向“强压正面敌人”的方向。
|
||||
*/
|
||||
export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_all_in_crush',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
text: '战斗:全力压上',
|
||||
description:
|
||||
'正面强攻,优先触发高爆发和终结类技能,伤害更高,但承受的反击也更重。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.SKILL3,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterActionTemplate: '{monster}被正面强攻逼得节节后退',
|
||||
monsterAnimation: 'attack',
|
||||
monsterMoveMeters: 0,
|
||||
},
|
||||
effect: {
|
||||
damageMultiplier: 1.3,
|
||||
incomingDamageMultiplier: 1.15,
|
||||
turnTimeMultiplier: 1,
|
||||
skillWeights: {
|
||||
finisher: 5,
|
||||
burst: 4,
|
||||
mobility: 2,
|
||||
steady: 1.5,
|
||||
projectile: 1.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'对面前敌人全力猛攻,文案可以自然改写,但仍要保持这是正面强攻而不是别的行为。',
|
||||
documentation: {
|
||||
id: 'battle_all_in_crush',
|
||||
domain: 'state',
|
||||
title: '战斗:全力压上',
|
||||
source: 'src/data/functionCatalog/state/battleAllInCrush.ts',
|
||||
summary: '战斗阶段的高风险高收益强攻 function。',
|
||||
detailedDescription:
|
||||
'这个 function 用于把当前回合塑造成硬碰硬的压制回合,让主角优先打出爆发和终结倾向更强的技能或叙事动作。',
|
||||
trigger: '仅在 battle 状态且场上仍有存活敌人时参与候选。',
|
||||
execution:
|
||||
'提高 damageMultiplier,并抬高 finisher / burst 权重,同时略微提高 incomingDamageMultiplier,突出换血压力。',
|
||||
result: '适合在残局抢收头、需要快速压血,或者希望把敌人直接压回去时使用。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
55
src/data/functionCatalog/state/battleEscapeBreakout.ts
Normal file
55
src/data/functionCatalog/state/battleEscapeBreakout.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_escape_breakout
|
||||
*
|
||||
* 战斗中的脱离动作。它不是继续换血,而是明确让主角放弃当前缠斗,
|
||||
* 把叙事重心切到“拉开距离、甩开追击、离开战场”。
|
||||
*/
|
||||
export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_escape_breakout',
|
||||
state: 'battle',
|
||||
category: 'escape',
|
||||
text: '逃跑:转身甩开',
|
||||
description: '立刻放弃缠斗,转身拉开距离,冲向下一片区域。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: -0.6,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'left',
|
||||
scrollWorld: true,
|
||||
monsterActionTemplate: '{monster}在后方死死咬住不放',
|
||||
monsterAnimation: 'idle',
|
||||
monsterMoveMeters: 0,
|
||||
},
|
||||
effect: {
|
||||
escapeDurationMs: 5000,
|
||||
escapeDistance: 5,
|
||||
monsterLagStart: 0.52,
|
||||
monsterLagEnd: 0.34,
|
||||
sceneShift: 0,
|
||||
enterBattle: false,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'从当前战斗中脱身、拉开距离或撤离,文案可以自然改写,但仍要保持这是逃脱而不是继续交战。',
|
||||
documentation: {
|
||||
id: 'battle_escape_breakout',
|
||||
domain: 'state',
|
||||
title: '逃跑:转身甩开',
|
||||
source: 'src/data/functionCatalog/state/battleEscapeBreakout.ts',
|
||||
summary: '用于脱战、逃离和切离镜头压力的战斗 function。',
|
||||
detailedDescription:
|
||||
'它让回合从“继续打”切换到“先活下来”,并带出逃跑镜头、距离拉开和怪物追击落后的演出逻辑。',
|
||||
trigger: '仅在 battle 状态下参与候选,并会在低血时提升优先级。',
|
||||
execution:
|
||||
'不追求伤害,而是提供 escapeDurationMs、escapeDistance 与 monsterLag 参数,驱动逃跑流程与镜头表现。',
|
||||
result: '适合生命见底、资源不够,或玩家主动决定放弃当前战斗时使用。',
|
||||
state: 'battle',
|
||||
category: 'escape',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
60
src/data/functionCatalog/state/battleFeintStep.ts
Normal file
60
src/data/functionCatalog/state/battleFeintStep.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_feint_step
|
||||
*
|
||||
* 战斗中的机动切入动作。它把重点放在虚晃、变线与抢身位,
|
||||
* 让战斗叙事更偏向灵活切入而不是硬扛伤害。
|
||||
*/
|
||||
export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_feint_step',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
text: '战斗:虚晃切入',
|
||||
description:
|
||||
'通过假动作和变线切入制造破绽,偏向机动技能,伤害适中但更安全。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.SKILL1_JUMP,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterActionTemplate: '{monster}被晃得节奏发虚,动作明显迟疑',
|
||||
monsterAnimation: 'attack',
|
||||
monsterMoveMeters: 0,
|
||||
},
|
||||
effect: {
|
||||
damageMultiplier: 1.05,
|
||||
incomingDamageMultiplier: 0.8,
|
||||
skillWeights: {
|
||||
mobility: 5,
|
||||
burst: 2.4,
|
||||
steady: 2,
|
||||
finisher: 1.4,
|
||||
projectile: 1.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'通过虚晃、变线或切步逼近面前敌人,文案可以自然改写,但仍要保持这是机动切入。',
|
||||
documentation: {
|
||||
id: 'battle_feint_step',
|
||||
domain: 'state',
|
||||
title: '战斗:虚晃切入',
|
||||
source: 'src/data/functionCatalog/state/battleFeintStep.ts',
|
||||
summary: '通过身位变化制造安全破绽的机动战斗 function。',
|
||||
detailedDescription:
|
||||
'它适合把回合讲成“先骗、再切、再进”的过程,让主角依靠节奏误导和位移逼出敌人的迟疑。',
|
||||
trigger: '仅在 battle 状态下参与候选。',
|
||||
execution:
|
||||
'小幅提高输出,显著降低 incomingDamageMultiplier,并把技能权重偏到 mobility,让系统更愿意选机动型动作。',
|
||||
result:
|
||||
'适合想稳一点地逼近敌人、减少硬吃反击,或需要围绕灵巧角色塑造打法时使用。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
59
src/data/functionCatalog/state/battleFinisherWindow.ts
Normal file
59
src/data/functionCatalog/state/battleFinisherWindow.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_finisher_window
|
||||
*
|
||||
* 战斗中的终结窗口动作。它要求系统把这一回合理解为“敌人已经露出空档”,
|
||||
* 因而优先演出收割、补刀和终结技。
|
||||
*/
|
||||
export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_finisher_window',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
text: '战斗:抓住破绽',
|
||||
description: '专门针对敌人露出的空档压上终结技,节奏更短、更猛。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.SKILL3,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterActionTemplate: '{monster}来不及调整姿态,只能仓促迎击',
|
||||
monsterAnimation: 'attack',
|
||||
monsterMoveMeters: 0,
|
||||
},
|
||||
effect: {
|
||||
damageMultiplier: 1.4,
|
||||
incomingDamageMultiplier: 1.05,
|
||||
turnTimeMultiplier: 0.92,
|
||||
skillWeights: {
|
||||
finisher: 6,
|
||||
burst: 3.5,
|
||||
mobility: 1.5,
|
||||
steady: 0.8,
|
||||
projectile: 0.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'抓住面前敌人的破绽收割或终结,文案可以自然改写,但仍要保持这是终结窗口。',
|
||||
documentation: {
|
||||
id: 'battle_finisher_window',
|
||||
domain: 'state',
|
||||
title: '战斗:抓住破绽',
|
||||
source: 'src/data/functionCatalog/state/battleFinisherWindow.ts',
|
||||
summary: '围绕残局补刀与爆发终结的战斗 function。',
|
||||
detailedDescription:
|
||||
'它会强烈推动“敌人已经失衡、主角准备收口”的叙事判断,让 actionText 与技能选择都更像最后一击。',
|
||||
trigger: '仅在 battle 状态下参与候选,并会在敌方血量偏低时明显升优先级。',
|
||||
execution:
|
||||
'提供最高档位之一的 damageMultiplier,缩短 turnTimeMultiplier,并极度偏向 finisher / burst 技能。',
|
||||
result: '适合在敌方残血、明显露出空档,或需要一口气结束战斗时使用。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
58
src/data/functionCatalog/state/battleGuardBreak.ts
Normal file
58
src/data/functionCatalog/state/battleGuardBreak.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_guard_break
|
||||
*
|
||||
* 战斗中的破架重击动作。它强调“针对敌人当前动作强拆架势”,
|
||||
* 比纯换血更讲究把敌人的节奏打断。
|
||||
*/
|
||||
export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_guard_break',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
text: '战斗:重击破架',
|
||||
description: '针对怪物当前动作强拆架势,伤害偏高,反击压力略低。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.SKILL2,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterActionTemplate: '{monster}被重击震得动作一滞',
|
||||
monsterAnimation: 'attack',
|
||||
monsterMoveMeters: 0,
|
||||
},
|
||||
effect: {
|
||||
damageMultiplier: 1.2,
|
||||
incomingDamageMultiplier: 0.9,
|
||||
skillWeights: {
|
||||
burst: 4.5,
|
||||
finisher: 3,
|
||||
steady: 2.2,
|
||||
mobility: 1.5,
|
||||
projectile: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'针对面前敌人的架势或破绽重击破防,文案可以自然改写,但仍要保持这是破架强攻。',
|
||||
documentation: {
|
||||
id: 'battle_guard_break',
|
||||
domain: 'state',
|
||||
title: '战斗:重击破架',
|
||||
source: 'src/data/functionCatalog/state/battleGuardBreak.ts',
|
||||
summary: '围绕破架、震停和打断敌人节奏的战斗 function。',
|
||||
detailedDescription:
|
||||
'它不是单纯地压血,而是把回合重心放在“砸开架势、制造空档”,让后续剧情更容易承接敌人失衡的结果。',
|
||||
trigger: '仅在 battle 状态且敌人仍在场时参与候选。',
|
||||
execution:
|
||||
'维持较高 damageMultiplier,同时降低 incomingDamageMultiplier,并偏向 burst / steady 组合,突出稳中带狠的重击感。',
|
||||
result: '适合对付正在招架、准备反扑或看起来露出结构性破绽的敌人。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
58
src/data/functionCatalog/state/battleProbePressure.ts
Normal file
58
src/data/functionCatalog/state/battleProbePressure.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_probe_pressure
|
||||
*
|
||||
* 战斗中的稳扎试探动作。适合在局势未明、资源需要保留时,
|
||||
* 先用安全且持续的压制把信息和节奏摸出来。
|
||||
*/
|
||||
export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_probe_pressure',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
text: '战斗:稳扎试探',
|
||||
description: '以持续压制和稳健试探为主,更容易发动常规连段和中段技能。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.SKILL1,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterActionTemplate: '{monster}一边招架一边寻找反扑的缝隙',
|
||||
monsterAnimation: 'attack',
|
||||
monsterMoveMeters: 0,
|
||||
},
|
||||
effect: {
|
||||
damageMultiplier: 1,
|
||||
incomingDamageMultiplier: 0.95,
|
||||
skillWeights: {
|
||||
steady: 5,
|
||||
burst: 2.2,
|
||||
mobility: 2,
|
||||
projectile: 2,
|
||||
finisher: 1.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'对面前敌人稳扎稳打地试探施压,文案可以自然改写,但仍要保持这是试探压制。',
|
||||
documentation: {
|
||||
id: 'battle_probe_pressure',
|
||||
domain: 'state',
|
||||
title: '战斗:稳扎试探',
|
||||
source: 'src/data/functionCatalog/state/battleProbePressure.ts',
|
||||
summary: '强调观察、压迫和中段连段的稳健战斗 function。',
|
||||
detailedDescription:
|
||||
'这个 function 让战斗回合更像“边压边看”,适合在想控制资源消耗、等待更好机会,或需要防止自己出手过重时使用。',
|
||||
trigger: '仅在 battle 状态下参与候选。',
|
||||
execution:
|
||||
'保持标准 damageMultiplier,轻微降低 incomingDamageMultiplier,并把 skillWeights 明显偏向 steady,兼顾少量 mobility / projectile。',
|
||||
result: '适合拉平节奏、压住敌人反扑窗口,或在低蓝时维持安全输出。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
61
src/data/functionCatalog/state/battleRecoverBreath.ts
Normal file
61
src/data/functionCatalog/state/battleRecoverBreath.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_recover_breath
|
||||
*
|
||||
* 战斗中的恢复动作。它会把当前回合塑造成“先稳住伤势与灵力”,
|
||||
* 让数值、冷却和叙事都朝回气与整顿节奏的方向靠拢。
|
||||
*/
|
||||
export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'battle_recover_breath',
|
||||
state: 'battle',
|
||||
category: 'recovery',
|
||||
text: '战斗:收势调息',
|
||||
description: '边守边调息,恢复少量生命和灵力,并让技能冷却更快转动。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterActionTemplate: '{monster}仍在逼近,不给你轻松喘息的空当',
|
||||
monsterAnimation: 'move',
|
||||
monsterMoveMeters: -0.1,
|
||||
},
|
||||
effect: {
|
||||
damageMultiplier: 0.72,
|
||||
incomingDamageMultiplier: 0.7,
|
||||
healAmount: 12,
|
||||
manaRestore: 18,
|
||||
cooldownTickBonus: 1,
|
||||
skillWeights: {
|
||||
steady: 3,
|
||||
mobility: 2,
|
||||
projectile: 1.8,
|
||||
burst: 1,
|
||||
finisher: 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'在战斗中收势调息、稳住伤势或回气,文案可以自然改写,但仍要保持这是恢复而不是继续猛攻。',
|
||||
documentation: {
|
||||
id: 'battle_recover_breath',
|
||||
domain: 'state',
|
||||
title: '战斗:收势调息',
|
||||
source: 'src/data/functionCatalog/state/battleRecoverBreath.ts',
|
||||
summary: '战斗中的保命与回气 function。',
|
||||
detailedDescription:
|
||||
'这个 function 专门为低血、低蓝或技能轮转吃紧时准备,让一回合承担“止血、回蓝、缓冷却”的综合恢复职责。',
|
||||
trigger: '仅在 battle 状态下参与候选,且通常会在低血或低蓝时被提权。',
|
||||
execution:
|
||||
'显著降低 damageMultiplier 和 incomingDamageMultiplier,同时提供 healAmount、manaRestore 与 cooldownTickBonus。',
|
||||
result: '适合临时止损、等关键技能转好,或把高压战斗拉回可控节奏。',
|
||||
state: 'battle',
|
||||
category: 'recovery',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
48
src/data/functionCatalog/state/idleCallOut.ts
Normal file
48
src/data/functionCatalog/state/idleCallOut.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_call_out
|
||||
*
|
||||
* 空闲状态下的主动喊话动作。它会把探索从“静悄悄地摸过去”
|
||||
* 转成“先出声试探,看谁先回应”的节奏。
|
||||
*/
|
||||
export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'idle_call_out',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
text: '主动出声试探',
|
||||
description: '朝前方主动喊话试探,可能把藏着的角色、怪物或其他动静逼出来。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
},
|
||||
effect: {
|
||||
sceneShift: 0,
|
||||
enterBattle: false,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'主动朝前方出声试探,文案可以自然改写,但仍要保持这是出声试探。',
|
||||
documentation: {
|
||||
id: 'idle_call_out',
|
||||
domain: 'state',
|
||||
title: '主动出声试探',
|
||||
source: 'src/data/functionCatalog/state/idleCallOut.ts',
|
||||
summary: '通过主动发声引出附近实体反应的空闲 function。',
|
||||
detailedDescription:
|
||||
'它把探索节奏从被动观察切成主动打破寂静,更适合在玩家想逼出暗处角色、敌人或潜伏动静时使用。',
|
||||
trigger: '仅在 idle 状态下参与候选,且在当前设计里优先级较高。',
|
||||
execution:
|
||||
'维持站立演出,不推动场景位移;上层会把它理解为向前方喊话,从而让实体更容易被直接引到眼前。',
|
||||
result: '适合探草、叫阵、主动试探潜伏目标,或把隐藏互动直接拉到台前。',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
48
src/data/functionCatalog/state/idleExploreForward.ts
Normal file
48
src/data/functionCatalog/state/idleExploreForward.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_explore_forward
|
||||
*
|
||||
* 空闲状态下最核心的推进动作。它负责把“继续往前探”从一句泛化文案,
|
||||
* 落成真正会引出下一幕遭遇的运行时 function。
|
||||
*/
|
||||
export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'idle_explore_forward',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
text: '继续向前探索',
|
||||
description: '沿当前场景继续深入,很可能立刻撞上新的敌人或危险。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 0.9,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
},
|
||||
effect: {
|
||||
sceneShift: 0,
|
||||
enterBattle: true,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'继续向前推进当前场景,文案可以自然改写,但仍要保持这是往前探索。',
|
||||
documentation: {
|
||||
id: 'idle_explore_forward',
|
||||
domain: 'state',
|
||||
title: '继续向前探索',
|
||||
source: 'src/data/functionCatalog/state/idleExploreForward.ts',
|
||||
summary: '空闲阶段默认的主推进 function。',
|
||||
detailedDescription:
|
||||
'它负责把玩家继续深入当前场景的意图交给运行时,让场景实体池、前探预览和下一幕遭遇有机会真正落地。',
|
||||
trigger: '仅在 idle 状态下参与候选;营地场景会在运行时被额外过滤。',
|
||||
execution:
|
||||
'保持空闲探索态,视觉上推动角色向前,effect 中通过 enterBattle / sceneShift 让下一步遭遇有机会进入前探或战斗链路。',
|
||||
result: '适合在当前场景继续摸深、主动触发新的角色、怪物、宝藏或危险。',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
48
src/data/functionCatalog/state/idleFollowClue.ts
Normal file
48
src/data/functionCatalog/state/idleFollowClue.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_follow_clue
|
||||
*
|
||||
* 空闲状态下的循线推进动作。它在源码定义层仍然存在,
|
||||
* 但当前运行时会在聚合阶段被过滤,因此属于保留中的停用 function。
|
||||
*/
|
||||
export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'idle_follow_clue',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
text: '顺着线索靠近',
|
||||
description: '沿着眼前痕迹、小道或声音来源继续靠近,可能更快撞上新的目标。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 0.6,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
},
|
||||
effect: {
|
||||
sceneShift: 0,
|
||||
enterBattle: false,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'顺着眼前线索继续靠近目标,文案可以自然改写,但仍要保持这是循线逼近。',
|
||||
documentation: {
|
||||
id: 'idle_follow_clue',
|
||||
domain: 'state',
|
||||
title: '顺着线索靠近',
|
||||
source: 'src/data/functionCatalog/state/idleFollowClue.ts',
|
||||
summary: '保留在源码中的循线 function,目前默认不进入运行时候选池。',
|
||||
detailedDescription:
|
||||
'它原本用于把观察结果进一步收束成“沿线索追过去”的动作,但当前项目在 applyRuntimeFunctionAdjustments 中会将其过滤。',
|
||||
trigger: '定义上属于 idle 状态,但默认运行时不会提供给玩家。',
|
||||
execution:
|
||||
'保留了向前靠近的视觉与 effect 配置,方便后续重新启用或用于编辑器审计。',
|
||||
result: '当前主要用于保留设计意图和支持后续恢复,不是默认可点选项。',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
active: false,
|
||||
},
|
||||
};
|
||||
|
||||
48
src/data/functionCatalog/state/idleObserveSigns.ts
Normal file
48
src/data/functionCatalog/state/idleObserveSigns.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_observe_signs
|
||||
*
|
||||
* 空闲状态下的侦察动作。它把当前回合定义成“停下来观察”,
|
||||
* 重点不是立刻推进,而是为后续选择生成可引用的观察结果。
|
||||
*/
|
||||
export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'idle_observe_signs',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
text: '停步观察动静',
|
||||
description: '先收住脚步观察附近痕迹与风吹草动,判断前方是否有异样。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
},
|
||||
effect: {
|
||||
sceneShift: 0,
|
||||
enterBattle: false,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'停步观察周围动静、痕迹或征兆,文案可以自然改写,但仍要保持这是观察判断。',
|
||||
documentation: {
|
||||
id: 'idle_observe_signs',
|
||||
domain: 'state',
|
||||
title: '停步观察动静',
|
||||
source: 'src/data/functionCatalog/state/idleObserveSigns.ts',
|
||||
summary: '围绕侦察、判断和前情确认的空闲 function。',
|
||||
detailedDescription:
|
||||
'这个 function 不要求马上遇敌或推进,而是把回合用在收集线索、确认附近实体池与下一步风险上。',
|
||||
trigger: '仅在 idle 状态下参与候选。',
|
||||
execution:
|
||||
'保持原地观察姿态,不推进 sceneShift;上层 prompt 会把这一回合视为观察请求,要求模型输出可延续的侦察结论。',
|
||||
result: '适合进入未知区域前先看风向、脚印、气息或异响,减少盲走。',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
50
src/data/functionCatalog/state/idleRestFocus.ts
Normal file
50
src/data/functionCatalog/state/idleRestFocus.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_rest_focus
|
||||
*
|
||||
* 空闲状态下的原地恢复动作。它不会推进遭遇,而是给玩家一个
|
||||
* 在非战斗场景里回收少量血蓝的缓冲回合。
|
||||
*/
|
||||
export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'idle_rest_focus',
|
||||
state: 'idle',
|
||||
category: 'recovery',
|
||||
text: '原地调息恢复',
|
||||
description: '暂时停步整理呼吸,恢复少量生命与灵力,继续保持空闲状态。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
},
|
||||
effect: {
|
||||
healAmount: 10,
|
||||
manaRestore: 15,
|
||||
sceneShift: 0,
|
||||
enterBattle: false,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'原地调息、休整或恢复状态,文案可以自然改写,但仍要保持这是恢复。',
|
||||
documentation: {
|
||||
id: 'idle_rest_focus',
|
||||
domain: 'state',
|
||||
title: '原地调息恢复',
|
||||
source: 'src/data/functionCatalog/state/idleRestFocus.ts',
|
||||
summary: '空闲阶段用于回血回蓝的恢复 function。',
|
||||
detailedDescription:
|
||||
'它承担非战斗状态下的短暂停步与内息整理,让玩家不推进主线遭遇,也能通过一回合修整状态。',
|
||||
trigger: '仅在 idle 状态下参与候选,并会在生命或灵力偏低时提升优先级。',
|
||||
execution:
|
||||
'不推动场景前进,effect 直接提供 healAmount 与 manaRestore,确保结果稳定由本地规则控制。',
|
||||
result: '适合战后休整、资源吃紧,或玩家想稳一下再继续探路时使用。',
|
||||
state: 'idle',
|
||||
category: 'recovery',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
48
src/data/functionCatalog/state/idleTravelNextScene.ts
Normal file
48
src/data/functionCatalog/state/idleTravelNextScene.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_travel_next_scene
|
||||
*
|
||||
* 空闲状态下的切场景动作。它代表玩家主动离开当前地点,
|
||||
* 进入相邻场景重新开启新的遭遇周期。
|
||||
*/
|
||||
export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
definition: {
|
||||
id: 'idle_travel_next_scene',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
text: '前往其他场景',
|
||||
description: '离开当前场景,进入下一处地点,并在那里重新遭遇新的威胁。',
|
||||
visual: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 1.1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
},
|
||||
effect: {
|
||||
sceneShift: 1,
|
||||
enterBattle: true,
|
||||
},
|
||||
},
|
||||
promptDescription:
|
||||
'离开当前场景并前往下一个场景,文案可以自然改写,但仍要保持这是切换场景。',
|
||||
documentation: {
|
||||
id: 'idle_travel_next_scene',
|
||||
domain: 'state',
|
||||
title: '前往其他场景',
|
||||
source: 'src/data/functionCatalog/state/idleTravelNextScene.ts',
|
||||
summary: '空闲阶段的地图流转 function。',
|
||||
detailedDescription:
|
||||
'当玩家不再想在当前场景深挖,而是希望切换地形、敌人池和环境压力时,这个 function 负责驱动合法的场景迁移。',
|
||||
trigger: '仅在 idle 状态下参与候选。',
|
||||
execution:
|
||||
'视觉上继续向前奔跑,effect 里通过 sceneShift: 1 把结果导向相邻场景,并允许新的遭遇在新场景刷新。',
|
||||
result: '适合主动换图、回避当前区域、或推进地图探索节奏时使用。',
|
||||
state: 'idle',
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
46
src/data/functionCatalog/state/index.ts
Normal file
46
src/data/functionCatalog/state/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import { BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE } from './battleAllInCrush';
|
||||
import { BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE } from './battleEscapeBreakout';
|
||||
import { BATTLE_FEINT_STEP_FUNCTION_SOURCE } from './battleFeintStep';
|
||||
import { BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE } from './battleFinisherWindow';
|
||||
import { BATTLE_GUARD_BREAK_FUNCTION_SOURCE } from './battleGuardBreak';
|
||||
import { BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE } from './battleProbePressure';
|
||||
import { BATTLE_RECOVER_BREATH_FUNCTION_SOURCE } from './battleRecoverBreath';
|
||||
import { IDLE_CALL_OUT_FUNCTION_SOURCE } from './idleCallOut';
|
||||
import { IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE } from './idleExploreForward';
|
||||
import { IDLE_FOLLOW_CLUE_FUNCTION_SOURCE } from './idleFollowClue';
|
||||
import { IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE } from './idleObserveSigns';
|
||||
import { IDLE_REST_FOCUS_FUNCTION_SOURCE } from './idleRestFocus';
|
||||
import { IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE } from './idleTravelNextScene';
|
||||
|
||||
export const STATE_FUNCTION_SOURCES: StateFunctionSource[] = [
|
||||
BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE,
|
||||
BATTLE_GUARD_BREAK_FUNCTION_SOURCE,
|
||||
BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE,
|
||||
BATTLE_FEINT_STEP_FUNCTION_SOURCE,
|
||||
BATTLE_RECOVER_BREATH_FUNCTION_SOURCE,
|
||||
BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE,
|
||||
BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE,
|
||||
IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE,
|
||||
IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE,
|
||||
IDLE_REST_FOCUS_FUNCTION_SOURCE,
|
||||
IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE,
|
||||
IDLE_FOLLOW_CLUE_FUNCTION_SOURCE,
|
||||
IDLE_CALL_OUT_FUNCTION_SOURCE,
|
||||
];
|
||||
|
||||
export const STATE_FUNCTION_DEFINITIONS = STATE_FUNCTION_SOURCES.map(
|
||||
(source) => source.definition,
|
||||
);
|
||||
|
||||
export const STATE_FUNCTION_PROMPT_DESCRIPTIONS = Object.fromEntries(
|
||||
STATE_FUNCTION_SOURCES.map((source) => [
|
||||
source.definition.id,
|
||||
source.promptDescription,
|
||||
]),
|
||||
) as Record<string, string>;
|
||||
|
||||
export const STATE_FUNCTION_DOCUMENTATION = STATE_FUNCTION_SOURCES.map(
|
||||
(source) => source.documentation,
|
||||
);
|
||||
|
||||
10
src/data/functionCatalog/treasure/index.ts
Normal file
10
src/data/functionCatalog/treasure/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { TREASURE_INSPECT_FUNCTION } from './treasureInspect';
|
||||
import { TREASURE_LEAVE_FUNCTION } from './treasureLeave';
|
||||
import { TREASURE_SECURE_FUNCTION } from './treasureSecure';
|
||||
|
||||
export const TREASURE_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
TREASURE_SECURE_FUNCTION,
|
||||
TREASURE_INSPECT_FUNCTION,
|
||||
TREASURE_LEAVE_FUNCTION,
|
||||
];
|
||||
20
src/data/functionCatalog/treasure/treasureInspect.ts
Normal file
20
src/data/functionCatalog/treasure/treasureInspect.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* treasure_inspect
|
||||
*
|
||||
* 先调查机关、线索与伪装,再收取宝藏的 function。
|
||||
*/
|
||||
export const TREASURE_INSPECT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'treasure_inspect',
|
||||
domain: 'treasure',
|
||||
title: '先排查机关与线索',
|
||||
source: 'src/data/functionCatalog/treasure/treasureInspect.ts',
|
||||
summary: '以更谨慎的方式获取宝藏收益的调查项。',
|
||||
detailedDescription:
|
||||
'它强调先看清环境与机关,再拆开伪装拿收益,因此通常会附带额外恢复、线索或更丰富的战利品组合。',
|
||||
trigger: '遭遇宝藏交互时的标准选项之一。',
|
||||
execution: '点击后生成 inspect 变体奖励,并用更细致的结果文本描述排查过程。',
|
||||
result: '玩家通常获得更完整的道具、金钱与恢复收益,但叙事上会多花一步检查。',
|
||||
active: true,
|
||||
};
|
||||
21
src/data/functionCatalog/treasure/treasureLeave.ts
Normal file
21
src/data/functionCatalog/treasure/treasureLeave.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* treasure_leave
|
||||
*
|
||||
* 暂时放过眼前宝藏、回到主流程的 function。
|
||||
*/
|
||||
export const TREASURE_LEAVE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'treasure_leave',
|
||||
domain: 'treasure',
|
||||
title: '先记下位置离开',
|
||||
source: 'src/data/functionCatalog/treasure/treasureLeave.ts',
|
||||
summary: '放弃本次收取、只保留线索记忆的宝藏退出项。',
|
||||
detailedDescription:
|
||||
'它允许玩家在风险、资源或节奏不合适时先不碰宝藏,把互动结果定格为“记住位置和异常”,而不是强行收取。',
|
||||
trigger: '遭遇宝藏交互时的标准退出项。',
|
||||
execution:
|
||||
'点击后不发放宝藏奖励,而是直接写入 leave 结果文本并回到后续剧情。',
|
||||
result: '玩家不会获得物品,但故事会保留“这里有异常宝藏”的记忆。',
|
||||
active: true,
|
||||
};
|
||||
20
src/data/functionCatalog/treasure/treasureSecure.ts
Normal file
20
src/data/functionCatalog/treasure/treasureSecure.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* treasure_secure
|
||||
*
|
||||
* 直接收取眼前宝藏的 function。
|
||||
*/
|
||||
export const TREASURE_SECURE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'treasure_secure',
|
||||
domain: 'treasure',
|
||||
title: '先把宝藏收下',
|
||||
source: 'src/data/functionCatalog/treasure/treasureSecure.ts',
|
||||
summary: '快速结算宝藏收益的直接收取项。',
|
||||
detailedDescription:
|
||||
'它代表玩家不再额外排查机关,而是优先把主要收获拿到手。奖励仍由本地规则根据当前 encounter 和构筑上下文生成。',
|
||||
trigger: '遭遇宝藏交互时的标准选项之一。',
|
||||
execution: '点击后直接生成 secure 变体奖励,并继续推进主故事。',
|
||||
result: '玩家立刻拿到主要物品和钱币,但放弃 inspect 路线的额外侦查收益。',
|
||||
active: true,
|
||||
};
|
||||
58
src/data/functionCatalog/types.ts
Normal file
58
src/data/functionCatalog/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
FunctionCategory,
|
||||
PlayerStateMode,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { StateFunctionDefinition } from '../stateFunctions';
|
||||
|
||||
export type FunctionDomain = 'state' | 'npc' | 'treasure' | 'flow' | 'panel';
|
||||
|
||||
export type FunctionStoryMode =
|
||||
| 'local_effect_then_generate'
|
||||
| 'modal_then_generate'
|
||||
| 'stream_then_defer'
|
||||
| 'reveal_deferred_options'
|
||||
| 'enter_interaction'
|
||||
| 'special_sequence'
|
||||
| 'special_travel'
|
||||
| 'local_only';
|
||||
|
||||
export type FunctionUiMode =
|
||||
| 'none'
|
||||
| 'npc_interaction_entry'
|
||||
| 'trade_modal'
|
||||
| 'gift_modal'
|
||||
| 'recruit_modal_or_sequence';
|
||||
|
||||
export interface FunctionRuntimeGuide {
|
||||
storyMode: FunctionStoryMode;
|
||||
uiMode: FunctionUiMode;
|
||||
visuals?: StoryOption['visuals'];
|
||||
executor: string;
|
||||
animationNote: string;
|
||||
storyNote: string;
|
||||
uiNote: string;
|
||||
compactDetailText?: string;
|
||||
}
|
||||
|
||||
export interface FunctionDocumentationEntry {
|
||||
id: string;
|
||||
domain: FunctionDomain;
|
||||
title: string;
|
||||
source: string;
|
||||
summary: string;
|
||||
detailedDescription: string;
|
||||
trigger: string;
|
||||
execution: string;
|
||||
result: string;
|
||||
state?: PlayerStateMode;
|
||||
category?: FunctionCategory;
|
||||
active?: boolean;
|
||||
runtime?: FunctionRuntimeGuide;
|
||||
}
|
||||
|
||||
export interface StateFunctionSource {
|
||||
definition: StateFunctionDefinition;
|
||||
documentation: FunctionDocumentationEntry;
|
||||
promptDescription: string;
|
||||
}
|
||||
1
src/data/hostileNpcOverrides.json
Normal file
1
src/data/hostileNpcOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1016
src/data/hostileNpcPresets.ts
Normal file
1016
src/data/hostileNpcPresets.ts
Normal file
File diff suppressed because it is too large
Load Diff
392
src/data/hostileNpcs.ts
Normal file
392
src/data/hostileNpcs.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { HostileNpcSpriteConfig } from '../components/HostileNpcAnimator';
|
||||
import {
|
||||
AnimationState,
|
||||
Encounter,
|
||||
FacingDirection,
|
||||
HostileNpcRenderAnimation,
|
||||
SceneDirective,
|
||||
SceneHostileNpc,
|
||||
SceneHostileNpcChange,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveRuleWorldType } 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.WUXIA].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 = resolveRuleWorldType(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',
|
||||
hostileNpcPresetId: preset.id,
|
||||
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 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: preset.baseStats.speed,
|
||||
hp: preset.baseStats.hp,
|
||||
maxHp: preset.baseStats.maxHp,
|
||||
renderKind: 'npc',
|
||||
combatTags: preset.combatTags,
|
||||
attributeProfile: preset.attributeProfile,
|
||||
behaviorVectors: preset.behaviorVectors,
|
||||
encounter: buildHostileNpcEncounter(worldType, preset.id, {
|
||||
xMeters: position.xMeters,
|
||||
}) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const createSceneMonster = createSceneHostileNpc;
|
||||
|
||||
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 const createSceneMonstersFromIds = createSceneHostileNpcsFromIds;
|
||||
|
||||
export function createSceneHostileNpcsFromEncounters(
|
||||
worldType: WorldType,
|
||||
encounters: Encounter[],
|
||||
playerX = PLAYER_BASE_X_METERS,
|
||||
): SceneHostileNpc[] {
|
||||
const hostileEncounters = encounters.filter(
|
||||
(encounter): encounter is Encounter & { hostileNpcPresetId: string } => Boolean(encounter.hostileNpcPresetId),
|
||||
);
|
||||
if (hostileEncounters.length === 0) return [];
|
||||
|
||||
const baseMonsters = createSceneHostileNpcsFromIds(
|
||||
worldType,
|
||||
hostileEncounters.map(encounter => encounter.hostileNpcPresetId),
|
||||
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 const createSceneNpcMonstersFromEncounters = createSceneHostileNpcsFromEncounters;
|
||||
|
||||
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 const getClosestMonster = getClosestHostileNpc;
|
||||
|
||||
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 const settleMonsterAnimations = settleHostileNpcAnimations;
|
||||
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
102
src/data/inventoryEffects.ts
Normal file
102
src/data/inventoryEffects.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Character, InventoryItem, TimedBuildBuff } from "../types";
|
||||
|
||||
export type InventoryUseEffect = {
|
||||
hpRestore: number;
|
||||
manaRestore: number;
|
||||
cooldownReduction: number;
|
||||
buildBuffs: TimedBuildBuff[];
|
||||
};
|
||||
|
||||
function getRarityMultiplier(rarity: InventoryItem["rarity"]) {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return 2.4;
|
||||
case "epic":
|
||||
return 1.9;
|
||||
case "rare":
|
||||
return 1.55;
|
||||
case "uncommon":
|
||||
return 1.2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInventoryItemUsable(item: InventoryItem) {
|
||||
return (
|
||||
Boolean(item.useProfile) ||
|
||||
item.tags.includes("healing") ||
|
||||
item.tags.includes("mana")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveInventoryItemUseEffect(
|
||||
item: InventoryItem,
|
||||
character: Character,
|
||||
): InventoryUseEffect | null {
|
||||
if (!isInventoryItemUsable(item)) return null;
|
||||
|
||||
if (item.useProfile) {
|
||||
return {
|
||||
hpRestore: item.useProfile.hpRestore ?? 0,
|
||||
manaRestore: item.useProfile.manaRestore ?? 0,
|
||||
cooldownReduction: item.useProfile.cooldownReduction ?? 0,
|
||||
buildBuffs: item.useProfile.buildBuffs ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const rarityMultiplier = getRarityMultiplier(item.rarity);
|
||||
const hasHealing =
|
||||
item.tags.includes("healing") ||
|
||||
/药|包|补给|恢复|疗伤|meat|apple|mushroom|water/i.test(item.name);
|
||||
const hasMana =
|
||||
item.tags.includes("mana") ||
|
||||
/灵液|法力|mana|crystal|essence|spirit/i.test(item.name);
|
||||
|
||||
const hpRestore = hasHealing
|
||||
? Math.max(
|
||||
10,
|
||||
Math.round((14 + character.attributes.spirit * 1.4) * rarityMultiplier),
|
||||
)
|
||||
: 0;
|
||||
const manaRestore = hasMana
|
||||
? Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
(12 + character.attributes.intelligence * 1.4) * rarityMultiplier,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const cooldownReduction = /凝神|回气|醒神|booster|essence/i.test(item.name)
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
if (hpRestore <= 0 && manaRestore <= 0 && cooldownReduction <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hpRestore,
|
||||
manaRestore,
|
||||
cooldownReduction,
|
||||
buildBuffs: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInventoryUseResultText(
|
||||
item: InventoryItem,
|
||||
effect: InventoryUseEffect,
|
||||
) {
|
||||
const parts = [
|
||||
effect.hpRestore > 0 ? `恢复 ${effect.hpRestore} 点气血` : null,
|
||||
effect.manaRestore > 0 ? `恢复 ${effect.manaRestore} 点灵力` : null,
|
||||
effect.cooldownReduction > 0
|
||||
? `额外推进 ${effect.cooldownReduction} 回合冷却`
|
||||
: null,
|
||||
effect.buildBuffs.length > 0
|
||||
? `获得 ${effect.buildBuffs.map(buff => buff.name).join("、")}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return `你取出${item.name}立刻使用,${parts.join(",")}。`;
|
||||
}
|
||||
335
src/data/itemCatalog.ts
Normal file
335
src/data/itemCatalog.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type {
|
||||
InventoryItem,
|
||||
ItemCatalogEntry,
|
||||
ItemCatalogOverride,
|
||||
ItemRarity,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildDesignedItemMetadata } from './itemDesign';
|
||||
|
||||
export const ITEM_CATALOG_API_PATH = '/api/item-catalog';
|
||||
export const ITEM_OVERRIDES_API_PATH = '/api/item-overrides';
|
||||
|
||||
export const ITEM_CATEGORY_OPTIONS = [
|
||||
'武器',
|
||||
'护甲',
|
||||
'饰品',
|
||||
'消耗品',
|
||||
'材料',
|
||||
'稀有品',
|
||||
'专属品',
|
||||
] as const;
|
||||
|
||||
const CATEGORY_WEAPON = ITEM_CATEGORY_OPTIONS[0];
|
||||
const CATEGORY_ARMOR = ITEM_CATEGORY_OPTIONS[1];
|
||||
const CATEGORY_RELIC = ITEM_CATEGORY_OPTIONS[2];
|
||||
const CATEGORY_CONSUMABLE = ITEM_CATEGORY_OPTIONS[3];
|
||||
const CATEGORY_MATERIAL = ITEM_CATEGORY_OPTIONS[4];
|
||||
const CATEGORY_RARE = ITEM_CATEGORY_OPTIONS[5];
|
||||
const CATEGORY_EXCLUSIVE = ITEM_CATEGORY_OPTIONS[6];
|
||||
|
||||
const WEAPON_KEYWORDS = [
|
||||
'weapon',
|
||||
'sword',
|
||||
'axe',
|
||||
'bow',
|
||||
'arrow',
|
||||
'mace',
|
||||
'wand',
|
||||
'staff',
|
||||
'pick',
|
||||
'spade',
|
||||
'blade',
|
||||
'dagger',
|
||||
'spear',
|
||||
'hammer',
|
||||
];
|
||||
|
||||
const ARMOR_KEYWORDS = [
|
||||
'armor',
|
||||
'armour',
|
||||
'helm',
|
||||
'helmet',
|
||||
'chest',
|
||||
'pants',
|
||||
'boots',
|
||||
'glove',
|
||||
'glowes',
|
||||
'shield',
|
||||
'cloak',
|
||||
'robe',
|
||||
'cap',
|
||||
];
|
||||
|
||||
const ACCESSORY_KEYWORDS = [
|
||||
'ring',
|
||||
'neck',
|
||||
'amulet',
|
||||
'jewel',
|
||||
'jewelry',
|
||||
'bracelet',
|
||||
'relic',
|
||||
'gem',
|
||||
];
|
||||
|
||||
const CONSUMABLE_KEYWORDS = [
|
||||
'potion',
|
||||
'bottle',
|
||||
'water',
|
||||
'meat',
|
||||
'apple',
|
||||
'mushroom',
|
||||
'bandage',
|
||||
'torch',
|
||||
'candle',
|
||||
'food',
|
||||
];
|
||||
|
||||
const MATERIAL_KEYWORDS = [
|
||||
'wood',
|
||||
'stone',
|
||||
'leaf',
|
||||
'flower',
|
||||
'skin',
|
||||
'rope',
|
||||
'coin',
|
||||
'silverbar',
|
||||
'ore',
|
||||
'bar',
|
||||
'material',
|
||||
];
|
||||
|
||||
const RARE_KEYWORDS = [
|
||||
'scroll',
|
||||
'book',
|
||||
'bag',
|
||||
'skull',
|
||||
'cross',
|
||||
'stairway',
|
||||
'crystal',
|
||||
'magic',
|
||||
];
|
||||
|
||||
const EXCLUSIVE_KEYWORDS = [
|
||||
'treasure',
|
||||
'relic',
|
||||
'artifact',
|
||||
'legend',
|
||||
'sacred',
|
||||
];
|
||||
|
||||
function normalizeAssetPath(sourcePath: string) {
|
||||
return sourcePath
|
||||
.replace(/^public[\\/]/iu, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/u, '');
|
||||
}
|
||||
|
||||
function stripExtension(value: string) {
|
||||
return value.replace(/\.[^.]+$/u, '');
|
||||
}
|
||||
|
||||
function humanizeAssetPart(value: string) {
|
||||
const cleaned = stripExtension(value)
|
||||
.replace(/^\d+[_-]*/u, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!cleaned) return '';
|
||||
|
||||
return cleaned
|
||||
.split(' ')
|
||||
.map(part => {
|
||||
const firstCharacter = part[0];
|
||||
return part && firstCharacter ? firstCharacter.toUpperCase() + part.slice(1) : '';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function includesAnyKeyword(text: string, keywords: string[]) {
|
||||
return keywords.some(keyword => text.includes(keyword));
|
||||
}
|
||||
|
||||
function dedupeTags(tags: string[]) {
|
||||
return [...new Set(tags.filter(Boolean))];
|
||||
}
|
||||
|
||||
export function buildItemCatalogId(sourcePath: string) {
|
||||
return stripExtension(normalizeAssetPath(sourcePath))
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9/]+/g, '-')
|
||||
.replace(/\/+/g, '__')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '');
|
||||
}
|
||||
|
||||
export function buildItemCatalogName(sourcePath: string) {
|
||||
const normalized = normalizeAssetPath(sourcePath);
|
||||
const parts = normalized.split('/');
|
||||
const leafName = humanizeAssetPart(parts[parts.length - 1] ?? '');
|
||||
const parentName = humanizeAssetPart(parts[parts.length - 2] ?? '');
|
||||
|
||||
if (!leafName && parentName) return parentName;
|
||||
if (!leafName) return '未命名物品';
|
||||
if (/^(Items|Icons|Singles|Variants|Text)$/u.test(leafName) && parentName) {
|
||||
return `${parentName} ${leafName}`;
|
||||
}
|
||||
|
||||
return leafName;
|
||||
}
|
||||
|
||||
export function inferItemCatalogCategory(sourcePath: string) {
|
||||
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
|
||||
|
||||
if (includesAnyKeyword(normalized, WEAPON_KEYWORDS)) return '武器';
|
||||
if (includesAnyKeyword(normalized, ARMOR_KEYWORDS)) return '护甲';
|
||||
if (includesAnyKeyword(normalized, ACCESSORY_KEYWORDS)) return '饰品';
|
||||
if (includesAnyKeyword(normalized, CONSUMABLE_KEYWORDS)) return '消耗品';
|
||||
if (includesAnyKeyword(normalized, MATERIAL_KEYWORDS)) return '材料';
|
||||
if (includesAnyKeyword(normalized, EXCLUSIVE_KEYWORDS)) return '专属品';
|
||||
if (includesAnyKeyword(normalized, RARE_KEYWORDS)) return '稀有品';
|
||||
|
||||
return '稀有品';
|
||||
}
|
||||
|
||||
export function inferItemCatalogRarity(sourcePath: string, category: string): ItemRarity {
|
||||
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
|
||||
|
||||
if (includesAnyKeyword(normalized, EXCLUSIVE_KEYWORDS)) return 'legendary';
|
||||
if (includesAnyKeyword(normalized, ['magic', 'crystal', 'wand', 'gem', 'gold'])) return 'epic';
|
||||
if (category === '武器' || category === '护甲' || category === '饰品' || category === '专属品') return 'rare';
|
||||
if (category === '消耗品' || category === '材料') return 'uncommon';
|
||||
|
||||
return 'common';
|
||||
}
|
||||
|
||||
export function inferItemCatalogTags(sourcePath: string, category: string) {
|
||||
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
|
||||
const tags: string[] = [];
|
||||
|
||||
if (category === '武器') tags.push('weapon');
|
||||
if (category === '护甲') tags.push('armor');
|
||||
if (category === '饰品' || category === '专属品') tags.push('relic');
|
||||
if (category === '材料') tags.push('material');
|
||||
|
||||
if (includesAnyKeyword(normalized, ['potion', 'bandage', 'water', 'meat', 'apple', 'mushroom'])) {
|
||||
tags.push('healing');
|
||||
}
|
||||
|
||||
if (includesAnyKeyword(normalized, ['mana', 'magic', 'crystal', 'gem', 'wand'])) {
|
||||
tags.push('mana');
|
||||
}
|
||||
|
||||
return dedupeTags(tags);
|
||||
}
|
||||
|
||||
export function buildItemCatalogDescription(
|
||||
sourcePath: string,
|
||||
category: string,
|
||||
name: string,
|
||||
) {
|
||||
return `由图标素材 ${normalizeAssetPath(sourcePath)} 自动生成的${category}物品“${name}”,可在编辑器中继续调整名称、稀有度、标签与描述。`;
|
||||
}
|
||||
|
||||
export function buildBaseItemCatalogEntry(sourcePath: string): ItemCatalogEntry {
|
||||
const normalizedSourcePath = normalizeAssetPath(sourcePath);
|
||||
const name = buildItemCatalogName(normalizedSourcePath);
|
||||
const category = inferItemCatalogCategory(normalizedSourcePath);
|
||||
const rarity = inferItemCatalogRarity(normalizedSourcePath, category);
|
||||
const tags = inferItemCatalogTags(normalizedSourcePath, category);
|
||||
const designed = buildDesignedItemMetadata(
|
||||
normalizedSourcePath,
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
tags,
|
||||
{
|
||||
weapon: CATEGORY_WEAPON,
|
||||
armor: CATEGORY_ARMOR,
|
||||
relic: CATEGORY_RELIC,
|
||||
consumable: CATEGORY_CONSUMABLE,
|
||||
material: CATEGORY_MATERIAL,
|
||||
rare: CATEGORY_RARE,
|
||||
exclusive: CATEGORY_EXCLUSIVE,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: buildItemCatalogId(normalizedSourcePath),
|
||||
sourcePath: normalizedSourcePath,
|
||||
iconSrc: `/${normalizedSourcePath}`,
|
||||
name: designed.name ?? name,
|
||||
category: designed.category ?? category,
|
||||
rarity: designed.rarity ?? rarity,
|
||||
tags: dedupeTags(designed.tags ?? tags),
|
||||
description: designed.description ?? buildItemCatalogDescription(normalizedSourcePath, category, name),
|
||||
worldAffinity: designed.worldAffinity ?? 'neutral',
|
||||
equipmentSlotId: designed.equipmentSlotId ?? null,
|
||||
worldProfiles: designed.worldProfiles,
|
||||
statProfile: designed.statProfile ?? null,
|
||||
useProfile: designed.useProfile ?? null,
|
||||
buildProfile: designed.buildProfile ?? null,
|
||||
value: designed.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyItemCatalogOverride(
|
||||
baseItem: ItemCatalogEntry,
|
||||
override?: ItemCatalogOverride | null,
|
||||
): ItemCatalogEntry {
|
||||
if (!override) return baseItem;
|
||||
|
||||
return {
|
||||
...baseItem,
|
||||
...override,
|
||||
tags: override.tags ? dedupeTags(override.tags) : baseItem.tags,
|
||||
worldProfiles: override.worldProfiles ?? baseItem.worldProfiles,
|
||||
statProfile: override.statProfile ?? baseItem.statProfile,
|
||||
useProfile: override.useProfile ?? baseItem.useProfile,
|
||||
buildProfile: override.buildProfile ?? baseItem.buildProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildItemCatalogFromAssetPaths(
|
||||
assetPaths: string[],
|
||||
overrideMap: Record<string, ItemCatalogOverride> = {},
|
||||
) {
|
||||
return assetPaths
|
||||
.map(sourcePath => buildBaseItemCatalogEntry(sourcePath))
|
||||
.map(item => applyItemCatalogOverride(item, overrideMap[item.id]))
|
||||
.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
|
||||
}
|
||||
|
||||
export function createInventoryItemFromCatalogEntry(
|
||||
item: ItemCatalogEntry,
|
||||
quantity = 1,
|
||||
worldType: WorldType | null = null,
|
||||
): InventoryItem {
|
||||
const worldProfile = worldType ? item.worldProfiles?.[worldType] : null;
|
||||
return {
|
||||
id: `catalog:${item.id}`,
|
||||
catalogId: item.id,
|
||||
category: item.category,
|
||||
name: worldProfile?.name ?? item.name,
|
||||
quantity,
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
iconSrc: item.iconSrc,
|
||||
description: worldProfile?.description ?? item.description,
|
||||
worldAffinity: item.worldAffinity,
|
||||
equipmentSlotId: item.equipmentSlotId,
|
||||
worldProfiles: item.worldProfiles,
|
||||
statProfile: item.statProfile,
|
||||
useProfile: item.useProfile,
|
||||
buildProfile: item.buildProfile,
|
||||
value: item.value,
|
||||
runtimeMetadata: {
|
||||
origin: 'catalog',
|
||||
generationChannel: 'discovery',
|
||||
seedKey: `catalog:${item.id}`,
|
||||
sourceReason: '来自静态物品目录。',
|
||||
},
|
||||
};
|
||||
}
|
||||
962
src/data/itemDesign.ts
Normal file
962
src/data/itemDesign.ts
Normal file
@@ -0,0 +1,962 @@
|
||||
import {
|
||||
type EquipmentSlotId,
|
||||
type ItemBuildProfile,
|
||||
type ItemCatalogEntry,
|
||||
type ItemRarity,
|
||||
type ItemStatProfile,
|
||||
type ItemUseProfile,
|
||||
type ItemWorldAffinity,
|
||||
type ItemWorldProfile,
|
||||
WorldType,
|
||||
} from "../types";
|
||||
import { normalizeBuildRole, normalizeBuildTags } from "./buildTags";
|
||||
|
||||
export type ItemCategoryLabels = {
|
||||
weapon: string;
|
||||
armor: string;
|
||||
relic: string;
|
||||
consumable: string;
|
||||
material: string;
|
||||
rare: string;
|
||||
exclusive: string;
|
||||
};
|
||||
|
||||
type DesignedItemMetadata = Pick<
|
||||
ItemCatalogEntry,
|
||||
| "name"
|
||||
| "category"
|
||||
| "rarity"
|
||||
| "tags"
|
||||
| "description"
|
||||
| "worldAffinity"
|
||||
| "equipmentSlotId"
|
||||
| "worldProfiles"
|
||||
| "statProfile"
|
||||
| "useProfile"
|
||||
| "buildProfile"
|
||||
| "value"
|
||||
>;
|
||||
|
||||
type MaterialTheme = {
|
||||
wuxia: string;
|
||||
xianxia: string;
|
||||
worldAffinity: ItemWorldAffinity;
|
||||
role: string;
|
||||
rarity: ItemRarity;
|
||||
setWuxia: string;
|
||||
setXianxia: string;
|
||||
tags: string[];
|
||||
synergy: string[];
|
||||
};
|
||||
|
||||
const MATERIAL_THEMES: Record<string, MaterialTheme> = {
|
||||
Wooden: {
|
||||
wuxia: "乌木",
|
||||
xianxia: "灵木",
|
||||
worldAffinity: "neutral",
|
||||
role: "fieldcraft",
|
||||
rarity: "common",
|
||||
setWuxia: "山行木作",
|
||||
setXianxia: "灵木行旅",
|
||||
tags: ["探索", "制作"],
|
||||
synergy: ["探索", "采集", "过渡装备"],
|
||||
},
|
||||
Copper: {
|
||||
wuxia: "赤铜",
|
||||
xianxia: "赤炼铜",
|
||||
worldAffinity: "wuxia",
|
||||
role: "breaker",
|
||||
rarity: "common",
|
||||
setWuxia: "赤铜开山",
|
||||
setXianxia: "赤炼破锋",
|
||||
tags: ["破甲", "爆发"],
|
||||
synergy: ["破甲", "前期开荒", "刚猛流"],
|
||||
},
|
||||
Iron: {
|
||||
wuxia: "寒铁",
|
||||
xianxia: "玄铁",
|
||||
worldAffinity: "wuxia",
|
||||
role: "vanguard",
|
||||
rarity: "uncommon",
|
||||
setWuxia: "寒铁镇岳",
|
||||
setXianxia: "玄铁镇山",
|
||||
tags: ["先锋", "守卫"],
|
||||
synergy: ["承伤", "反击", "稳扎稳打"],
|
||||
},
|
||||
Steel: {
|
||||
wuxia: "百炼钢",
|
||||
xianxia: "灵钢",
|
||||
worldAffinity: "neutral",
|
||||
role: "duelist",
|
||||
rarity: "rare",
|
||||
setWuxia: "百炼争锋",
|
||||
setXianxia: "灵钢斗枢",
|
||||
tags: ["决斗者", "节奏"],
|
||||
synergy: ["连击", "对拼", "压制"],
|
||||
},
|
||||
Silver: {
|
||||
wuxia: "霜银",
|
||||
xianxia: "月银",
|
||||
worldAffinity: "xianxia",
|
||||
role: "ward",
|
||||
rarity: "rare",
|
||||
setWuxia: "霜银辟祟",
|
||||
setXianxia: "月银镇邪",
|
||||
tags: ["守卫", "灵体"],
|
||||
synergy: ["克制邪祟", "回复", "护体"],
|
||||
},
|
||||
Gold: {
|
||||
wuxia: "鎏金",
|
||||
xianxia: "金霞",
|
||||
worldAffinity: "neutral",
|
||||
role: "fortune",
|
||||
rarity: "epic",
|
||||
setWuxia: "鎏金富贵",
|
||||
setXianxia: "金霞天赐",
|
||||
tags: ["财富", "支援"],
|
||||
synergy: ["经济", "爆发", "贵重馈赠"],
|
||||
},
|
||||
Cobalt: {
|
||||
wuxia: "苍钴",
|
||||
xianxia: "苍穹钴晶",
|
||||
worldAffinity: "xianxia",
|
||||
role: "caster",
|
||||
rarity: "epic",
|
||||
setWuxia: "苍钴引雷",
|
||||
setXianxia: "钴晶御雷",
|
||||
tags: ["施法者", "法力"],
|
||||
synergy: ["法力", "远程", "雷系构筑"],
|
||||
},
|
||||
Crimson: {
|
||||
wuxia: "绯钢",
|
||||
xianxia: "赤煞晶钢",
|
||||
worldAffinity: "wuxia",
|
||||
role: "berserker",
|
||||
rarity: "rare",
|
||||
setWuxia: "绯钢狂锋",
|
||||
setXianxia: "赤煞断岳",
|
||||
tags: ["狂战士", "爆发"],
|
||||
synergy: ["压血爆发", "破阵", "重击"],
|
||||
},
|
||||
Altair: {
|
||||
wuxia: "星游",
|
||||
xianxia: "天狼星辉",
|
||||
worldAffinity: "xianxia",
|
||||
role: "assassin",
|
||||
rarity: "epic",
|
||||
setWuxia: "星游夜行",
|
||||
setXianxia: "星辉掠影",
|
||||
tags: ["刺客", "机动性"],
|
||||
synergy: ["身法", "暴击", "切后"],
|
||||
},
|
||||
Adamantine: {
|
||||
wuxia: "玄钢",
|
||||
xianxia: "玄金陨铁",
|
||||
worldAffinity: "neutral",
|
||||
role: "fortress",
|
||||
rarity: "legendary",
|
||||
setWuxia: "玄钢不坏",
|
||||
setXianxia: "陨铁镇界",
|
||||
tags: ["堡垒", "坦克"],
|
||||
synergy: ["高承伤", "套装成型", "守中反打"],
|
||||
},
|
||||
Angelic: {
|
||||
wuxia: "天辉",
|
||||
xianxia: "羽化天灵",
|
||||
worldAffinity: "xianxia",
|
||||
role: "paladin",
|
||||
rarity: "legendary",
|
||||
setWuxia: "天辉护心",
|
||||
setXianxia: "羽化圣辉",
|
||||
tags: ["圣骑士", "支援"],
|
||||
synergy: ["护盾", "回复", "圣光构筑"],
|
||||
},
|
||||
Nova: {
|
||||
wuxia: "星火",
|
||||
xianxia: "星爆灵核",
|
||||
worldAffinity: "xianxia",
|
||||
role: "spellblade",
|
||||
rarity: "epic",
|
||||
setWuxia: "星火裂空",
|
||||
setXianxia: "星爆御剑",
|
||||
tags: ["法术之刃", "法力"],
|
||||
synergy: ["法武双修", "中距离压制", "星辰构筑"],
|
||||
},
|
||||
Platinum: {
|
||||
wuxia: "白金",
|
||||
xianxia: "霜白灵金",
|
||||
worldAffinity: "neutral",
|
||||
role: "commander",
|
||||
rarity: "epic",
|
||||
setWuxia: "白金威仪",
|
||||
setXianxia: "灵金统御",
|
||||
tags: ["指挥官", "平衡"],
|
||||
synergy: ["全能", "队伍增益", "中后期构筑"],
|
||||
},
|
||||
Fateful: {
|
||||
wuxia: "命纹",
|
||||
xianxia: "天命玄纹",
|
||||
worldAffinity: "xianxia",
|
||||
role: "fate",
|
||||
rarity: "legendary",
|
||||
setWuxia: "命纹转祸",
|
||||
setXianxia: "天命轮转",
|
||||
tags: ["命运", "实用"],
|
||||
synergy: ["冷却", "机缘", "运势构筑"],
|
||||
},
|
||||
};
|
||||
|
||||
const ARMOR_PIECE_LABELS: Record<
|
||||
string,
|
||||
{ wuxia: string; xianxia: string; pieceName: string; slot: EquipmentSlotId }
|
||||
> = {
|
||||
Boots: { wuxia: "踏云靴", xianxia: "凌霄履", pieceName: "boots", slot: "armor" },
|
||||
Chestplate: { wuxia: "护心甲", xianxia: "灵铠", pieceName: "chest", slot: "armor" },
|
||||
Gloves: { wuxia: "护腕", xianxia: "灵纹手甲", pieceName: "gloves", slot: "armor" },
|
||||
Helmet: { wuxia: "冠盔", xianxia: "灵盔", pieceName: "helm", slot: "armor" },
|
||||
Leggings: { wuxia: "行岳腿甲", xianxia: "踏虚护胫", pieceName: "leggings", slot: "armor" },
|
||||
Shield: { wuxia: "镇势盾", xianxia: "护界灵盾", pieceName: "shield", slot: "armor" },
|
||||
Weapon: { wuxia: "战兵", xianxia: "灵兵", pieceName: "weapon", slot: "weapon" },
|
||||
};
|
||||
|
||||
const RARITY_ORDER: ItemRarity[] = ["common", "uncommon", "rare", "epic", "legendary"];
|
||||
|
||||
const GENERIC_TOKEN_LABELS: Record<string, { wuxia: string; xianxia: string }> = {
|
||||
scroll: { wuxia: "秘卷", xianxia: "玉简" },
|
||||
ring: { wuxia: "戒", xianxia: "灵戒" },
|
||||
torch: { wuxia: "火把", xianxia: "明焰灯" },
|
||||
helm: { wuxia: "盔", xianxia: "灵盔" },
|
||||
helmet: { wuxia: "盔", xianxia: "灵盔" },
|
||||
chest: { wuxia: "胸甲", xianxia: "灵铠" },
|
||||
pants: { wuxia: "护腿", xianxia: "灵裤" },
|
||||
boots: { wuxia: "靴", xianxia: "云履" },
|
||||
gem: { wuxia: "宝石", xianxia: "灵晶" },
|
||||
crystal: { wuxia: "晶簇", xianxia: "灵晶" },
|
||||
cross: { wuxia: "镇煞十字", xianxia: "镇灵法印" },
|
||||
potion: { wuxia: "药剂", xianxia: "灵液" },
|
||||
water: { wuxia: "清水", xianxia: "灵泉水" },
|
||||
bottle: { wuxia: "药瓶", xianxia: "灵瓶" },
|
||||
neck: { wuxia: "项坠", xianxia: "灵坠" },
|
||||
mushroom: { wuxia: "山菌", xianxia: "灵菌" },
|
||||
meat: { wuxia: "肉脯", xianxia: "灵兽肉" },
|
||||
apple: { wuxia: "果实", xianxia: "灵果" },
|
||||
skull: { wuxia: "颅骨", xianxia: "骨印" },
|
||||
bag: { wuxia: "行囊", xianxia: "乾坤袋" },
|
||||
mace: { wuxia: "钉头锤", xianxia: "镇岳杵" },
|
||||
spade: { wuxia: "铲", xianxia: "灵铲" },
|
||||
coin: { wuxia: "铜钱", xianxia: "灵钱" },
|
||||
stone: { wuxia: "石料", xianxia: "灵石料" },
|
||||
wood: { wuxia: "木料", xianxia: "灵木材" },
|
||||
glowes: { wuxia: "护手", xianxia: "灵纹手套" },
|
||||
gloves: { wuxia: "护手", xianxia: "灵纹手套" },
|
||||
book: { wuxia: "册页", xianxia: "道卷" },
|
||||
leaf: { wuxia: "叶片", xianxia: "灵叶" },
|
||||
sword: { wuxia: "剑", xianxia: "灵剑" },
|
||||
bow: { wuxia: "弓", xianxia: "灵弓" },
|
||||
arrow: { wuxia: "箭", xianxia: "灵矢" },
|
||||
shield: { wuxia: "盾", xianxia: "灵盾" },
|
||||
rope: { wuxia: "绳索", xianxia: "缚灵索" },
|
||||
skin: { wuxia: "兽皮", xianxia: "妖皮" },
|
||||
treasure: { wuxia: "宝匣", xianxia: "秘藏灵匣" },
|
||||
pick: { wuxia: "鹤嘴镐", xianxia: "开脉灵镐" },
|
||||
silverbar: { wuxia: "银锭", xianxia: "月银锭" },
|
||||
flower: { wuxia: "花", xianxia: "灵花" },
|
||||
wand: { wuxia: "法杖", xianxia: "灵杖" },
|
||||
magic: { wuxia: "秘术核心", xianxia: "灵法结晶" },
|
||||
};
|
||||
|
||||
function clampRarity(rank: number): ItemRarity {
|
||||
return RARITY_ORDER[Math.max(0, Math.min(RARITY_ORDER.length - 1, rank))] ?? "common";
|
||||
}
|
||||
|
||||
function rarityToRank(rarity: ItemRarity) {
|
||||
return RARITY_ORDER.indexOf(rarity);
|
||||
}
|
||||
|
||||
function bumpRarity(rarity: ItemRarity, delta: number) {
|
||||
return clampRarity(rarityToRank(rarity) + delta);
|
||||
}
|
||||
|
||||
function parseVariantIndex(normalizedSourcePath: string) {
|
||||
const match = normalizedSourcePath.match(/(\d+)(?=\.png$)/iu);
|
||||
return match ? Number(match[1]) : 1;
|
||||
}
|
||||
|
||||
function buildWorldProfiles(
|
||||
wuxiaName: string,
|
||||
xianxiaName: string,
|
||||
wuxiaDescription: string,
|
||||
xianxiaDescription: string,
|
||||
): Partial<Record<WorldType, ItemWorldProfile>> {
|
||||
return {
|
||||
WUXIA: {
|
||||
name: wuxiaName,
|
||||
description: wuxiaDescription,
|
||||
},
|
||||
XIANXIA: {
|
||||
name: xianxiaName,
|
||||
description: xianxiaDescription,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dedupe(values: string[]) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildEquipmentStats(
|
||||
slot: EquipmentSlotId,
|
||||
rarity: ItemRarity,
|
||||
role: string,
|
||||
pieceName: string,
|
||||
): ItemStatProfile {
|
||||
const rank = rarityToRank(rarity) + 1;
|
||||
|
||||
if (slot === "weapon") {
|
||||
const outgoingDamageBonus = Number((0.04 + rank * 0.018).toFixed(2));
|
||||
const maxManaBonus = role === "caster" || role === "spellblade" ? 8 + rank * 4 : 0;
|
||||
return {
|
||||
outgoingDamageBonus,
|
||||
maxManaBonus,
|
||||
maxHpBonus: role === "fortress" ? 8 + rank * 6 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const baseHp =
|
||||
pieceName === "shield"
|
||||
? 16 + rank * 10
|
||||
: pieceName === "chest"
|
||||
? 14 + rank * 9
|
||||
: pieceName === "helm"
|
||||
? 10 + rank * 6
|
||||
: 8 + rank * 5;
|
||||
const incomingDamageMultiplier = Number(
|
||||
Math.max(0.72, 0.99 - rank * 0.03 - (role === "fortress" ? 0.04 : 0)).toFixed(2),
|
||||
);
|
||||
|
||||
return {
|
||||
maxHpBonus: baseHp,
|
||||
maxManaBonus: role === "caster" || role === "paladin" ? 6 + rank * 4 : 0,
|
||||
outgoingDamageBonus:
|
||||
role === "duelist" || role === "berserker" || role === "assassin"
|
||||
? Number((0.02 + rank * 0.01).toFixed(2))
|
||||
: 0,
|
||||
incomingDamageMultiplier,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRelicStats(rarity: ItemRarity, role: string): ItemStatProfile {
|
||||
const rank = rarityToRank(rarity) + 1;
|
||||
return {
|
||||
maxHpBonus: role === "ward" || role === "paladin" ? 8 + rank * 4 : 0,
|
||||
maxManaBonus:
|
||||
role === "caster" || role === "fate" || role === "support"
|
||||
? 12 + rank * 6
|
||||
: 6 + rank * 3,
|
||||
outgoingDamageBonus:
|
||||
role === "assassin" || role === "berserker" || role === "spellblade"
|
||||
? Number((0.03 + rank * 0.012).toFixed(2))
|
||||
: Number((0.01 + rank * 0.008).toFixed(2)),
|
||||
incomingDamageMultiplier:
|
||||
role === "ward" || role === "support"
|
||||
? Number(Math.max(0.8, 0.98 - rank * 0.02).toFixed(2))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBuildProfile(
|
||||
role: string,
|
||||
tags: string[],
|
||||
options: {
|
||||
setId?: string;
|
||||
setName?: string;
|
||||
pieceName?: string;
|
||||
synergy?: string[];
|
||||
} = {},
|
||||
): ItemBuildProfile {
|
||||
return {
|
||||
role: normalizeBuildRole(role),
|
||||
tags: normalizeBuildTags([role, ...tags]),
|
||||
setId: options.setId,
|
||||
setName: options.setName,
|
||||
pieceName: options.pieceName,
|
||||
synergy: options.synergy ?? [],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItemBuildBuffs(sourceId: string, name: string, tags: string[], durationTurns: number) {
|
||||
return [{
|
||||
id: `${sourceId}-buff`,
|
||||
sourceType: "item" as const,
|
||||
sourceId,
|
||||
name,
|
||||
tags: normalizeBuildTags(tags),
|
||||
durationTurns,
|
||||
}];
|
||||
}
|
||||
|
||||
function rankValue(rarity: ItemRarity, slot: EquipmentSlotId | null, useProfile: ItemUseProfile | null) {
|
||||
const rank = rarityToRank(rarity) + 1;
|
||||
let value = 18 + rank * 14;
|
||||
if (slot === "weapon") value += 22;
|
||||
if (slot === "armor") value += 18;
|
||||
if (slot === "relic") value += 20;
|
||||
if (useProfile?.hpRestore || useProfile?.manaRestore) value += 16;
|
||||
if (useProfile?.cooldownReduction) value += 22;
|
||||
return value;
|
||||
}
|
||||
|
||||
function detectRoleFromDescriptor(descriptor: string) {
|
||||
const source = descriptor.toLowerCase();
|
||||
if (/(wind|gust|nimble|rogue|hawk|arrow|sword_long|spear)/u.test(source)) return "assassin";
|
||||
if (/(thunder|fierce|mighty|flame|serrated|punch|booster_iron|booster_steel)/u.test(source)) return "berserker";
|
||||
if (/(arcane|esoteric|mage|ethereal|lunar|time|nightvision|copy|grimoire)/u.test(source)) return "caster";
|
||||
if (/(fortitude|fortress|protector|shield|hearty|adaptable|honorable|cross)/u.test(source)) return "ward";
|
||||
if (/(wedding|lovely|bud|oceanic|star|marbled|rich|vibrant|vivacious)/u.test(source)) return "support";
|
||||
return "balanced";
|
||||
}
|
||||
|
||||
function buildGenericTokenName(token: string, worldType: WorldType) {
|
||||
const normalized = token.toLowerCase();
|
||||
const mapped = GENERIC_TOKEN_LABELS[normalized];
|
||||
if (mapped) {
|
||||
return worldType === WorldType.WUXIA ? mapped.wuxia : mapped.xianxia;
|
||||
}
|
||||
|
||||
return token
|
||||
.replace(/\.[^.]+$/u, "")
|
||||
.replace(/_/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildLegacyDesign(
|
||||
normalizedSourcePath: string,
|
||||
name: string,
|
||||
category: string,
|
||||
rarity: ItemRarity,
|
||||
tags: string[],
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata | null {
|
||||
if (normalizedSourcePath.includes("/")) return null;
|
||||
|
||||
const baseToken = normalizedSourcePath
|
||||
.replace(/^\d+[_-]*/u, "")
|
||||
.replace(/\.png$/iu, "")
|
||||
.split(/[_-]/u)
|
||||
.filter(Boolean)[0] ?? name;
|
||||
const wuxiaName = buildGenericTokenName(baseToken, WorldType.WUXIA);
|
||||
const xianxiaName = buildGenericTokenName(baseToken, WorldType.XIANXIA);
|
||||
const slot: EquipmentSlotId | null =
|
||||
category === labels.weapon ? "weapon" : category === labels.armor ? "armor" : category === labels.relic ? "relic" : null;
|
||||
const useProfile: ItemUseProfile | null =
|
||||
category === labels.consumable || tags.includes("healing") || tags.includes("mana")
|
||||
? {
|
||||
hpRestore: tags.includes("healing") ? 22 : 0,
|
||||
manaRestore: tags.includes("mana") ? 18 : 0,
|
||||
cooldownReduction: /power|mana|magic|bandage|torch/u.test(normalizedSourcePath) ? 1 : 0,
|
||||
}
|
||||
: null;
|
||||
const statProfile =
|
||||
slot === "weapon"
|
||||
? buildEquipmentStats("weapon", rarity, "balanced", "weapon")
|
||||
: slot === "armor"
|
||||
? buildEquipmentStats("armor", rarity, "balanced", "armor")
|
||||
: slot === "relic"
|
||||
? buildRelicStats(rarity, "support")
|
||||
: null;
|
||||
|
||||
return {
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
tags: dedupe(tags),
|
||||
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两个世界中以不同风格登场,适合作为${category}基础模板继续扩展。`,
|
||||
worldAffinity: "neutral",
|
||||
equipmentSlotId: slot,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName},适用于武侠世界的基础${category}条目。`,
|
||||
`${xianxiaName},适用于仙侠世界的基础${category}条目。`,
|
||||
),
|
||||
statProfile,
|
||||
useProfile,
|
||||
buildProfile: buildBuildProfile("starter", ["legacy", ...tags]),
|
||||
value: rankValue(rarity, slot, useProfile),
|
||||
};
|
||||
}
|
||||
|
||||
function buildArmoryDesign(
|
||||
normalizedSourcePath: string,
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata | null {
|
||||
const match = normalizedSourcePath.match(
|
||||
/Armory\/Singles\/(Armor Singles|Weapon Singles)\/([^/]+)\/([^/]+)\.png$/u,
|
||||
);
|
||||
if (!match) return null;
|
||||
|
||||
const family = match[2];
|
||||
const filename = match[3];
|
||||
if (!family || !filename) return null;
|
||||
const theme = MATERIAL_THEMES[family];
|
||||
if (!theme) return null;
|
||||
|
||||
const pieceMatch = filename.match(/_(Boots|Chestplate|Gloves|Helmet|Leggings|Shield|Weapon)(\d+)$/u);
|
||||
if (!pieceMatch) return null;
|
||||
|
||||
const pieceKey = pieceMatch[1];
|
||||
const variantIndex = Number(pieceMatch[2]);
|
||||
if (!pieceKey || Number.isNaN(variantIndex)) return null;
|
||||
const piece = ARMOR_PIECE_LABELS[pieceKey];
|
||||
if (!piece) return null;
|
||||
const gradeTier = variantIndex >= 25 ? 2 : variantIndex >= 13 ? 1 : 0;
|
||||
const rarity: ItemRarity = bumpRarity(theme.rarity, gradeTier);
|
||||
const gradeWuxia = ["初式", "精铸", "真传"][gradeTier];
|
||||
const gradeXianxia = ["凡品", "灵铸", "道印"][gradeTier];
|
||||
const wuxiaName = `${theme.wuxia}${piece.wuxia}${gradeWuxia}`;
|
||||
const xianxiaName = `${theme.xianxia}${piece.xianxia}${gradeXianxia}`;
|
||||
const slot = piece.slot;
|
||||
const category = slot === "weapon" ? labels.weapon : labels.armor;
|
||||
const setId = `set-armory-${family.toLowerCase()}`;
|
||||
const setName = `${theme.setWuxia} / ${theme.setXianxia}`;
|
||||
const statProfile =
|
||||
slot === "weapon"
|
||||
? buildEquipmentStats(slot, rarity, theme.role, piece.pieceName)
|
||||
: buildEquipmentStats(slot, rarity, theme.role, piece.pieceName);
|
||||
const tags = dedupe([
|
||||
category === labels.weapon ? "weapon" : "armor",
|
||||
theme.worldAffinity,
|
||||
theme.role,
|
||||
...theme.tags,
|
||||
`set:${family.toLowerCase()}`,
|
||||
`piece:${piece.pieceName}`,
|
||||
]);
|
||||
|
||||
return {
|
||||
name: theme.worldAffinity === "xianxia" ? xianxiaName : wuxiaName,
|
||||
category,
|
||||
rarity,
|
||||
tags,
|
||||
description: `${theme.setWuxia} / ${theme.setXianxia} 套装中的 ${piece.pieceName} 位。相邻编号代表同家族不同锻造阶段,适合围绕 ${theme.synergy.join("、")} 组 build。`,
|
||||
worldAffinity: theme.worldAffinity,
|
||||
equipmentSlotId: slot,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}的武侠 build。`,
|
||||
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑仙侠战法。`,
|
||||
),
|
||||
statProfile,
|
||||
useProfile: null,
|
||||
buildProfile: buildBuildProfile(theme.role, theme.tags, {
|
||||
setId,
|
||||
setName,
|
||||
pieceName: piece.pieceName,
|
||||
synergy: theme.synergy,
|
||||
}),
|
||||
value: rankValue(rarity, slot, null),
|
||||
};
|
||||
}
|
||||
|
||||
function buildJewelryDesign(
|
||||
normalizedSourcePath: string,
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata | null {
|
||||
const match = normalizedSourcePath.match(
|
||||
/Jewelry\/(Rings|Necklaces|Bracelets)\/Singles(?:\/[^/]+)*\/([^/]+)\.png$/u,
|
||||
);
|
||||
if (!match) return null;
|
||||
|
||||
const jewelryType = match[1];
|
||||
const filename = match[2];
|
||||
if (!jewelryType || !filename) return null;
|
||||
const descriptor = filename.replace(/^\d+_/u, "");
|
||||
const role = detectRoleFromDescriptor(descriptor);
|
||||
const sizeTier =
|
||||
/Large|Fancy|Holder/u.test(descriptor) ? 2 : /Medium|Necklace|Bracelet/u.test(descriptor) ? 1 : 0;
|
||||
const rarity: ItemRarity = bumpRarity(
|
||||
sizeTier === 2 ? "rare" : sizeTier === 1 ? "uncommon" : "common",
|
||||
/Arcane|Esoteric|Lunar|Relic/u.test(descriptor) ? 1 : 0,
|
||||
);
|
||||
const worldAffinity = role === "caster" ? "xianxia" : role === "berserker" || role === "assassin" ? "wuxia" : "neutral";
|
||||
const baseWuxiaType = jewelryType === "Rings" ? "戒" : jewelryType === "Necklaces" ? "坠" : "镯";
|
||||
const baseXianxiaType = jewelryType === "Rings" ? "灵戒" : jewelryType === "Necklaces" ? "灵坠" : "灵镯";
|
||||
const leadingToken = descriptor.split("_").find(Boolean) ?? jewelryType;
|
||||
const wuxiaName = `${buildGenericTokenName(leadingToken, WorldType.WUXIA)}${baseWuxiaType}`;
|
||||
const xianxiaName = `${buildGenericTokenName(leadingToken, WorldType.XIANXIA)}${baseXianxiaType}`;
|
||||
const tags = dedupe(["relic", role, jewelryType.toLowerCase(), worldAffinity]);
|
||||
const setId = `set-jewelry-${role}`;
|
||||
|
||||
return {
|
||||
name: worldAffinity === "xianxia" ? xianxiaName : wuxiaName,
|
||||
category: labels.relic,
|
||||
rarity,
|
||||
tags,
|
||||
description: `${jewelryType} 家族的 ${descriptor.replace(/_/g, " ")} 款式。围绕 ${role} build 提供核心词条,也可以与同角色定位的项链/手镯/戒指拼成饰品流派。`,
|
||||
worldAffinity,
|
||||
equipmentSlotId: "relic",
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}偏向${role}向的武侠搭配,可作为饰品核心件。`,
|
||||
`${xianxiaName}更适合${role}向仙侠构筑,用于补足法力、爆发或护体短板。`,
|
||||
),
|
||||
statProfile: buildRelicStats(rarity, role),
|
||||
useProfile: null,
|
||||
buildProfile: buildBuildProfile(role, tags, {
|
||||
setId,
|
||||
setName: `${role} 饰品系`,
|
||||
pieceName: jewelryType.toLowerCase(),
|
||||
synergy: ["饰品 build", "定向补短", "三件成型"],
|
||||
}),
|
||||
value: rankValue(rarity, "relic", null),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPotionDesign(
|
||||
normalizedSourcePath: string,
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata | null {
|
||||
if (!/Potions\/Singles\//u.test(normalizedSourcePath)) return null;
|
||||
|
||||
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
|
||||
if (/Glass|Bottle_/u.test(filename) && !/Health|Mana|Pure|Essence|Soul/u.test(filename)) {
|
||||
return {
|
||||
name: "空药瓶",
|
||||
category: labels.material,
|
||||
rarity: "common",
|
||||
tags: ["material", "alchemy"],
|
||||
description: "炼药与装液容器,可作为配方材料或支线道具。",
|
||||
worldAffinity: "neutral",
|
||||
equipmentSlotId: null,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
"药瓶",
|
||||
"灵瓶",
|
||||
"武侠世界常见的炼药容器。",
|
||||
"仙侠世界常用的盛装灵液器皿。",
|
||||
),
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
buildProfile: buildBuildProfile("alchemy", ["material", "alchemy"]),
|
||||
value: 14,
|
||||
};
|
||||
}
|
||||
|
||||
const index = parseVariantIndex(normalizedSourcePath);
|
||||
const isHealth = /Health/u.test(filename);
|
||||
const isMana = /Mana/u.test(filename);
|
||||
const isPure = /Pure/u.test(filename);
|
||||
const isEssence = /Essence/u.test(filename);
|
||||
const isSoul = /Soul/u.test(filename);
|
||||
const rarity = isSoul
|
||||
? "legendary"
|
||||
: isPure || isEssence
|
||||
? "epic"
|
||||
: index > 118
|
||||
? "rare"
|
||||
: index > 108
|
||||
? "uncommon"
|
||||
: "common";
|
||||
const gradeText = isSoul ? "封魂" : isPure ? "澄澈" : isEssence ? "萃华" : rarity === "rare" ? "上品" : rarity === "uncommon" ? "精制" : "常备";
|
||||
const wuxiaName = isHealth
|
||||
? `${gradeText}回春药`
|
||||
: isMana
|
||||
? `${gradeText}养神露`
|
||||
: `${gradeText}奇药`;
|
||||
const xianxiaName = isHealth
|
||||
? `${gradeText}补元灵液`
|
||||
: isMana
|
||||
? `${gradeText}聚灵露`
|
||||
: `${gradeText}灵酿`;
|
||||
const useProfile: ItemUseProfile = {
|
||||
hpRestore: isHealth ? (isSoul ? 120 : isPure ? 82 : isEssence ? 64 : rarity === "rare" ? 44 : rarity === "uncommon" ? 28 : 18) : 0,
|
||||
manaRestore: isMana ? (isSoul ? 96 : isPure ? 70 : isEssence ? 54 : rarity === "rare" ? 38 : rarity === "uncommon" ? 24 : 16) : 0,
|
||||
cooldownReduction: isSoul || /Power/u.test(filename) ? 1 : 0,
|
||||
buildBuffs: isHealth
|
||||
? buildItemBuildBuffs(
|
||||
`potion-${filename.replace(/\.png$/iu, "").toLowerCase()}`,
|
||||
"续战药势",
|
||||
["回复", "续战"],
|
||||
isSoul ? 3 : 2,
|
||||
)
|
||||
: isMana
|
||||
? buildItemBuildBuffs(
|
||||
`potion-${filename.replace(/\.png$/iu, "").toLowerCase()}`,
|
||||
"聚灵药势",
|
||||
["法力", "过载"],
|
||||
isSoul ? 3 : 2,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
const tags = dedupe([
|
||||
"alchemy",
|
||||
"consumable",
|
||||
isHealth ? "healing" : "",
|
||||
isMana ? "mana" : "",
|
||||
isSoul ? "legendary-tonic" : "",
|
||||
]);
|
||||
|
||||
return {
|
||||
name: wuxiaName,
|
||||
category: labels.consumable,
|
||||
rarity,
|
||||
tags,
|
||||
description: "同形药瓶按纯度和封装级别区分强度,越靠后的高阶药剂越适合核心战斗循环与极限保命。",
|
||||
worldAffinity: isMana || isSoul ? "xianxia" : "neutral",
|
||||
equipmentSlotId: null,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}常见于江湖行囊,用于快速续战或调息。`,
|
||||
`${xianxiaName}多用于洞府与试炼前后,负责补元、聚灵与压缩冷却。`,
|
||||
),
|
||||
statProfile: null,
|
||||
useProfile,
|
||||
buildProfile: buildBuildProfile("alchemy", tags, {
|
||||
synergy: ["续航", "爆发前准备", "战中救急"],
|
||||
}),
|
||||
value: rankValue(rarity, null, useProfile),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGemDesign(
|
||||
normalizedSourcePath: string,
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata | null {
|
||||
if (!/Gems(?: II)?\/Singles\//u.test(normalizedSourcePath)) return null;
|
||||
|
||||
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
|
||||
const tokenMatch = filename.match(/(Ruby|Onyx|Sapphire|Morganite|Emerald|Topaz|Amethyst|Diamond|Opal)/iu);
|
||||
const token = tokenMatch?.[1] ?? "Crystal";
|
||||
const role =
|
||||
/Ruby|Crimson/u.test(token) ? "berserker"
|
||||
: /Sapphire|Amethyst|Morganite/u.test(token) ? "caster"
|
||||
: /Onyx|Diamond/u.test(token) ? "ward"
|
||||
: /Topaz|Opal/u.test(token) ? "assassin"
|
||||
: "support";
|
||||
const rarity = /Dust/u.test(filename) ? "uncommon" : /Crystal/u.test(filename) ? "epic" : "rare";
|
||||
const category = /Dust/u.test(filename) ? labels.material : labels.relic;
|
||||
const wuxiaName = `${buildGenericTokenName(token, WorldType.WUXIA)}${/Dust/u.test(filename) ? "碎屑" : /Crystal/u.test(filename) ? "晶魄" : "宝石"}`;
|
||||
const xianxiaName = `${buildGenericTokenName(token, WorldType.XIANXIA)}${/Dust/u.test(filename) ? "粉末" : /Crystal/u.test(filename) ? "灵髓" : "灵晶"}`;
|
||||
const tags = dedupe([
|
||||
category === labels.material ? "material" : "relic",
|
||||
token.toLowerCase(),
|
||||
role,
|
||||
"socket",
|
||||
]);
|
||||
|
||||
return {
|
||||
name: category === labels.material ? wuxiaName : xianxiaName,
|
||||
category,
|
||||
rarity,
|
||||
tags,
|
||||
description: `${token} 系晶石适合做强度梯度:粉尘是材料,宝石是中阶插件,晶体是高阶核心件。`,
|
||||
worldAffinity: category === labels.relic ? "xianxia" : "neutral",
|
||||
equipmentSlotId: category === labels.relic ? "relic" : null,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}偏向江湖匠造、镶嵌与兵刃锻造。`,
|
||||
`${xianxiaName}更适合灵器镶嵌与灵力 build 的核心堆叠。`,
|
||||
),
|
||||
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
||||
useProfile: null,
|
||||
buildProfile: buildBuildProfile(role, tags, {
|
||||
setId: `gem-${token.toLowerCase()}`,
|
||||
setName: `${token} 晶石谱系`,
|
||||
pieceName: /Dust/u.test(filename) ? "dust" : /Crystal/u.test(filename) ? "crystal" : "gem",
|
||||
synergy: ["镶嵌", "词条放大", "build 补强"],
|
||||
}),
|
||||
value: rankValue(rarity, category === labels.relic ? "relic" : null, null),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkillRelicDesign(
|
||||
normalizedSourcePath: string,
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata | null {
|
||||
if (!/Skills\/Singles\//u.test(normalizedSourcePath) && !/Librarium\/Singles\//u.test(normalizedSourcePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
|
||||
const role = detectRoleFromDescriptor(filename);
|
||||
const isBookLike = /Book|Grimoire|Literature/u.test(filename);
|
||||
const isBooster = /Booster/u.test(filename);
|
||||
const isPassive = /Passive/u.test(filename);
|
||||
const isUtility = /Echolocation|Nightvision|Copy|Shout|Panic/u.test(filename);
|
||||
const category = isBooster ? labels.consumable : isBookLike || isPassive || isUtility ? labels.relic : labels.rare;
|
||||
const rarity = isPassive ? "epic" : isBooster ? "rare" : isBookLike ? "epic" : "rare";
|
||||
const wuxiaName = isBookLike
|
||||
? `武学残卷·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`
|
||||
: `秘术符印·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`;
|
||||
const xianxiaName = isBookLike
|
||||
? `灵诀玉简·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`
|
||||
: `神通法印·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`;
|
||||
const useProfile =
|
||||
category === labels.consumable
|
||||
? {
|
||||
hpRestore: 0,
|
||||
manaRestore: 24 + rarityToRank(rarity) * 8,
|
||||
cooldownReduction: 1,
|
||||
buildBuffs: buildItemBuildBuffs(
|
||||
`skill-relic-${filename.replace(/\.png$/iu, "").toLowerCase()}`,
|
||||
"功法激发",
|
||||
[role, /Arrow|Spear/u.test(filename) ? "远射" : /Shield/u.test(filename) ? "护体" : "爆发"],
|
||||
2,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const tags = dedupe([
|
||||
category === labels.consumable ? "consumable" : category === labels.relic ? "relic" : "rare",
|
||||
role,
|
||||
isBooster ? "cooldown" : "",
|
||||
/Fire/u.test(filename) ? "fire" : "",
|
||||
/Lightning/u.test(filename) ? "lightning" : "",
|
||||
/Shield/u.test(filename) ? "ward" : "",
|
||||
/Sword|Punch/u.test(filename) ? "burst" : "",
|
||||
/Arrow|Spear/u.test(filename) ? "projectile" : "",
|
||||
]);
|
||||
|
||||
return {
|
||||
name: xianxiaName,
|
||||
category,
|
||||
rarity,
|
||||
tags,
|
||||
description: "技能图标类物品会被设计成功法、符印、强化器或秘卷,用于支撑特定流派的 build 想象。",
|
||||
worldAffinity: "xianxia",
|
||||
equipmentSlotId: category === labels.relic ? "relic" : null,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}适合在武侠世界里解释为武学秘卷、战术符印或绝招凭证。`,
|
||||
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
|
||||
),
|
||||
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
||||
useProfile,
|
||||
buildProfile: buildBuildProfile(role, tags, {
|
||||
setId: `skill-${role}`,
|
||||
setName: `${role} 功法谱`,
|
||||
synergy: ["职业核心", "技能联动", "法术 build"],
|
||||
}),
|
||||
value: rankValue(rarity, category === labels.relic ? "relic" : null, useProfile),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUtilityDesign(
|
||||
normalizedSourcePath: string,
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata {
|
||||
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
|
||||
const readable = filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "");
|
||||
const lower = readable.toLowerCase();
|
||||
const category =
|
||||
/ore|ingot|dust|bone|nail|card|flag|plushie|fish|string|rope|hook|cage|flower|seed|leaf|feather|skin|wood|stone/u.test(lower)
|
||||
? labels.material
|
||||
: /throwable|snowballs|meat|apple|mushroom/u.test(lower)
|
||||
? labels.consumable
|
||||
: /rod|pickaxe|sword|bow|mace|shield/u.test(lower)
|
||||
? /shield/u.test(lower)
|
||||
? labels.armor
|
||||
: labels.weapon
|
||||
: /book|relic|amulet|charm|skull|eyepatch|hook/u.test(lower)
|
||||
? labels.relic
|
||||
: labels.rare;
|
||||
const rarity =
|
||||
/gold|angelic|sacred|relic|crystal/u.test(lower)
|
||||
? "epic"
|
||||
: /steel|silver|special|pirate|magic/u.test(lower)
|
||||
? "rare"
|
||||
: /iron|bundle|advanced|fresh/u.test(lower)
|
||||
? "uncommon"
|
||||
: "common";
|
||||
const slot: EquipmentSlotId | null =
|
||||
category === labels.weapon ? "weapon" : category === labels.armor ? "armor" : category === labels.relic ? "relic" : null;
|
||||
const role = detectRoleFromDescriptor(lower);
|
||||
const useProfile: ItemUseProfile | null =
|
||||
category === labels.consumable
|
||||
? {
|
||||
hpRestore: /meat|apple|mushroom/u.test(lower) ? 16 + rarityToRank(rarity) * 8 : 0,
|
||||
manaRestore: /magic|crystal|water/u.test(lower) ? 14 + rarityToRank(rarity) * 6 : 0,
|
||||
cooldownReduction: /throwable|snowballs/u.test(lower) ? 1 : 0,
|
||||
}
|
||||
: null;
|
||||
const statProfile =
|
||||
slot === "weapon"
|
||||
? buildEquipmentStats("weapon", rarity, role, "weapon")
|
||||
: slot === "armor"
|
||||
? buildEquipmentStats("armor", rarity, role, "armor")
|
||||
: slot === "relic"
|
||||
? buildRelicStats(rarity, role)
|
||||
: null;
|
||||
const wuxiaName = readable
|
||||
.split(" ")
|
||||
.map((token) => buildGenericTokenName(token, WorldType.WUXIA))
|
||||
.join("");
|
||||
const xianxiaName = readable
|
||||
.split(" ")
|
||||
.map((token) => buildGenericTokenName(token, WorldType.XIANXIA))
|
||||
.join("");
|
||||
|
||||
return {
|
||||
name: wuxiaName || readable,
|
||||
category,
|
||||
rarity,
|
||||
tags: dedupe([
|
||||
...(/meat|apple|mushroom/u.test(lower) ? ["healing"] : []),
|
||||
...(/magic|crystal|water/u.test(lower) ? ["mana"] : []),
|
||||
...(slot === "weapon" ? ["weapon"] : slot === "armor" ? ["armor"] : slot === "relic" ? ["relic"] : []),
|
||||
...(category === labels.material ? ["material"] : []),
|
||||
role,
|
||||
]),
|
||||
description: `${readable} 根据视觉和路径被自动归入 ${category} 家族,可作为 ${role} 向 build 的支撑件或素材件。`,
|
||||
worldAffinity: /magic|crystal|sacred|angelic|spirit|astral/u.test(lower) ? "xianxia" : "neutral",
|
||||
equipmentSlotId: slot,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName || readable,
|
||||
xianxiaName || readable,
|
||||
`${wuxiaName || readable}更适合武侠世界的江湖使用语境。`,
|
||||
`${xianxiaName || readable}更适合仙侠世界的灵物/法器语境。`,
|
||||
),
|
||||
statProfile,
|
||||
useProfile,
|
||||
buildProfile: buildBuildProfile(role, [category, role], {
|
||||
synergy: ["素材拓展", "过渡 build", "题材补完"],
|
||||
}),
|
||||
value: rankValue(rarity, slot, useProfile),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDesignedItemMetadata(
|
||||
normalizedSourcePath: string,
|
||||
baseName: string,
|
||||
baseCategory: string,
|
||||
baseRarity: ItemRarity,
|
||||
baseTags: string[],
|
||||
labels: ItemCategoryLabels,
|
||||
): DesignedItemMetadata {
|
||||
const specialized =
|
||||
buildLegacyDesign(normalizedSourcePath, baseName, baseCategory, baseRarity, baseTags, labels) ??
|
||||
buildArmoryDesign(normalizedSourcePath, labels) ??
|
||||
buildJewelryDesign(normalizedSourcePath, labels) ??
|
||||
buildPotionDesign(normalizedSourcePath, labels) ??
|
||||
buildGemDesign(normalizedSourcePath, labels) ??
|
||||
buildSkillRelicDesign(normalizedSourcePath, labels);
|
||||
|
||||
if (specialized) {
|
||||
return {
|
||||
...specialized,
|
||||
tags: dedupe([...(specialized.tags ?? []), ...baseTags]),
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = buildUtilityDesign(normalizedSourcePath, labels);
|
||||
return {
|
||||
...fallback,
|
||||
name: fallback.name || baseName,
|
||||
category: fallback.category || baseCategory,
|
||||
rarity: fallback.rarity || baseRarity,
|
||||
tags: dedupe([...(fallback.tags ?? []), ...baseTags]),
|
||||
};
|
||||
}
|
||||
1
src/data/itemOverrides.json
Normal file
1
src/data/itemOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
806
src/data/medievalNpcVisuals.ts
Normal file
806
src/data/medievalNpcVisuals.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
import { type CustomWorldNpcVisual, type CustomWorldNpcVisualGear, Encounter } from '../types';
|
||||
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
|
||||
import npcVisualOverridesJson from './npcVisualOverrides.json';
|
||||
|
||||
export type MedievalRace = 'human' | 'elf' | 'orc' | 'goblin';
|
||||
export type MedievalAtlasSourceType = 'cloth' | 'leather' | 'metal' | 'melee' | 'magic' | 'ranged';
|
||||
export type MedievalAtlasUsage = 'headgear' | 'mainHand' | 'offHand';
|
||||
|
||||
export interface AtlasTileSpec {
|
||||
src: string;
|
||||
frameIndex: number;
|
||||
columns: number;
|
||||
tileWidth?: number;
|
||||
tileHeight?: number;
|
||||
renderOffsetX?: number;
|
||||
renderOffsetY?: number;
|
||||
}
|
||||
|
||||
export interface MedievalNpcVisualSpec {
|
||||
race: MedievalRace;
|
||||
bodySrc: string;
|
||||
headSrc: string;
|
||||
hairSrc: string;
|
||||
handSrc: string;
|
||||
facialHairSrc?: string;
|
||||
headgear?: AtlasTileSpec;
|
||||
mainHand?: AtlasTileSpec;
|
||||
offHand?: AtlasTileSpec;
|
||||
bodyFrames: number[];
|
||||
headFrame: number;
|
||||
hairFrame: number;
|
||||
handFrame: number;
|
||||
facialHairFrame?: number;
|
||||
}
|
||||
|
||||
export type MedievalNpcVisualOverride = Partial<MedievalNpcVisualSpec> & {
|
||||
race?: MedievalRace;
|
||||
};
|
||||
|
||||
export interface MedievalAtlasAssetDefinition {
|
||||
file: string;
|
||||
label: string;
|
||||
src: string;
|
||||
columns: number;
|
||||
frameCount: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
}
|
||||
|
||||
export interface MedievalPoseOption {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type NpcRoleStyle = 'warrior' | 'guardian' | 'ranger' | 'mystic' | 'civilian' | 'rogue' | 'bruiser';
|
||||
|
||||
const BODY_COLORS = [
|
||||
'black',
|
||||
'blue',
|
||||
'brown',
|
||||
'gold',
|
||||
'green',
|
||||
'grey',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'silver',
|
||||
'yellow',
|
||||
] as const;
|
||||
|
||||
const RACE_SPRITE_COUNTS: Record<MedievalRace, { head: number; hair: number; facialHair: number }> = {
|
||||
human: { head: 7, hair: 8, facialHair: 8 },
|
||||
elf: { head: 8, hair: 8, facialHair: 8 },
|
||||
orc: { head: 4, hair: 8, facialHair: 8 },
|
||||
goblin: { head: 4, hair: 8, facialHair: 8 },
|
||||
};
|
||||
|
||||
const HEAD_TONE_LABELS_BY_RACE: Record<MedievalRace, string[]> = {
|
||||
human: ['象牙肤', '暖米肤', '小麦肤', '日晒肤', '古铜肤', '栗棕肤', '冷棕肤'],
|
||||
elf: ['月白肤', '晨光肤', '青杏肤', '薄金肤', '雾灰肤', '玫瑰肤', '银青肤', '古木肤'],
|
||||
orc: ['浅橄榄肤', '深橄榄肤', '岩绿色', '灰褐绿肤'],
|
||||
goblin: ['苔绿肤', '黄绿肤', '灰绿肤', '泥褐肤'],
|
||||
};
|
||||
|
||||
const CLOTH_HAT_ASSETS = {
|
||||
'hat_black.png': createAtlasAsset('cloth', 'hat_black.png', '黑布便帽', 10, 49, 32, 32),
|
||||
'hat_blue.png': createAtlasAsset('cloth', 'hat_blue.png', '靛蓝便帽', 10, 49, 32, 32),
|
||||
'hat_green.png': createAtlasAsset('cloth', 'hat_green.png', '苔绿便帽', 10, 49, 32, 32),
|
||||
'hat_orange.png': createAtlasAsset('cloth', 'hat_orange.png', '赭橙便帽', 10, 49, 32, 32),
|
||||
'hat_pink.png': createAtlasAsset('cloth', 'hat_pink.png', '胭粉便帽', 10, 49, 32, 32),
|
||||
'hat_purple.png': createAtlasAsset('cloth', 'hat_purple.png', '紫布便帽', 10, 49, 32, 32),
|
||||
'hat_red.png': createAtlasAsset('cloth', 'hat_red.png', '赤布便帽', 10, 49, 32, 32),
|
||||
'hat_straw.png': createAtlasAsset('cloth', 'hat_straw.png', '草编宽檐帽', 5, 5, 32, 32),
|
||||
'hat_yellow.png': createAtlasAsset('cloth', 'hat_yellow.png', '土黄便帽', 10, 49, 32, 32),
|
||||
} as const;
|
||||
|
||||
const LEATHER_ASSETS = {
|
||||
'leather01.png': createAtlasAsset('leather', 'leather01.png', '轻皮头帽', 10, 37, 32, 32),
|
||||
'leather02.png': createAtlasAsset('leather', 'leather02.png', '束带皮盔', 4, 4, 32, 48),
|
||||
} as const;
|
||||
|
||||
const METAL_ASSETS = {
|
||||
'metal.png': createAtlasAsset('metal', 'metal.png', '铁面头盔', 10, 47, 32, 32),
|
||||
'metal_black.png': createAtlasAsset('metal', 'metal_black.png', '黑钢重盔', 10, 23, 32, 48),
|
||||
'metal_blue.png': createAtlasAsset('metal', 'metal_blue.png', '蓝钢重盔', 10, 23, 32, 48),
|
||||
'metal_green.png': createAtlasAsset('metal', 'metal_green.png', '青钢重盔', 10, 23, 32, 48),
|
||||
'metal_orange.png': createAtlasAsset('metal', 'metal_orange.png', '铜色重盔', 10, 23, 32, 48),
|
||||
'metal_pink.png': createAtlasAsset('metal', 'metal_pink.png', '粉漆重盔', 10, 23, 32, 48),
|
||||
'metal_purple.png': createAtlasAsset('metal', 'metal_purple.png', '紫钢重盔', 10, 23, 32, 48),
|
||||
'metal_red.png': createAtlasAsset('metal', 'metal_red.png', '赤钢重盔', 10, 23, 32, 48),
|
||||
'metal_yellow.png': createAtlasAsset('metal', 'metal_yellow.png', '金黄重盔', 10, 23, 32, 48),
|
||||
} as const;
|
||||
|
||||
const MELEE_ASSETS = {
|
||||
'axe.png': createAtlasAsset('melee', 'axe.png', '单手战斧', 10, 19, 32, 32),
|
||||
'axe_big.png': createAtlasAsset('melee', 'axe_big.png', '巨刃战斧', 5, 5, 32, 48),
|
||||
'blunt.png': createAtlasAsset('melee', 'blunt.png', '钉头战锤', 10, 19, 32, 32),
|
||||
'dagger.png': createAtlasAsset('melee', 'dagger.png', '短匕首', 7, 14, 32, 32),
|
||||
'polearm.png': createAtlasAsset('melee', 'polearm.png', '长柄武器', 12, 35, 32, 64),
|
||||
'shield.png': createAtlasAsset('melee', 'shield.png', '圆盾', 10, 56, 32, 32),
|
||||
'sword.png': createAtlasAsset('melee', 'sword.png', '骑士长剑', 7, 13, 32, 32),
|
||||
'sword_big.png': createAtlasAsset('melee', 'sword_big.png', '阔身巨剑', 10, 20, 32, 48),
|
||||
} as const;
|
||||
|
||||
const MAGIC_ASSETS = {
|
||||
'staff.png': createAtlasAsset('magic', 'staff.png', '长法杖', 13, 25, 32, 64),
|
||||
'wand.png': createAtlasAsset('magic', 'wand.png', '短魔杖', 6, 12, 32, 32),
|
||||
} as const;
|
||||
|
||||
const RANGED_ASSETS = {
|
||||
'arquebus_shot.png': createAtlasAsset('ranged', 'arquebus_shot.png', '火绳枪射击组', 4, 8, 64, 32),
|
||||
'blunderbuss.png': createAtlasAsset('ranged', 'blunderbuss.png', '喇叭火枪', 5, 10, 64, 32),
|
||||
'bow.png': createAtlasAsset('ranged', 'bow.png', '短弓', 7, 7, 32, 32),
|
||||
'bow_shot.png': createAtlasAsset('ranged', 'bow_shot.png', '短弓满弦组', 12, 84, 64, 32),
|
||||
'crossbow.png': createAtlasAsset('ranged', 'crossbow.png', '十字弩', 4, 4, 32, 32),
|
||||
'crossbow_shot.png': createAtlasAsset('ranged', 'crossbow_shot.png', '十字弩发射组', 17, 68, 32, 32),
|
||||
'musket.png': createAtlasAsset('ranged', 'musket.png', '长火枪', 5, 10, 64, 32),
|
||||
'pistol.png': createAtlasAsset('ranged', 'pistol.png', '单手火枪', 4, 24, 32, 32),
|
||||
'repeater_musket.png': createAtlasAsset('ranged', 'repeater_musket.png', '连发火枪', 4, 8, 64, 32),
|
||||
'sling.png': createAtlasAsset('ranged', 'sling.png', '投石索', 10, 20, 64, 64),
|
||||
'stick_sling.png': createAtlasAsset('ranged', 'stick_sling.png', '杖式投石索', 11, 21, 64, 64),
|
||||
} as const;
|
||||
|
||||
const ATLAS_ASSET_MAPS = {
|
||||
cloth: CLOTH_HAT_ASSETS,
|
||||
leather: LEATHER_ASSETS,
|
||||
metal: METAL_ASSETS,
|
||||
melee: MELEE_ASSETS,
|
||||
magic: MAGIC_ASSETS,
|
||||
ranged: RANGED_ASSETS,
|
||||
} satisfies Record<MedievalAtlasSourceType, Record<string, MedievalAtlasAssetDefinition>>;
|
||||
|
||||
const HEADGEAR_POSE_OPTIONS: MedievalPoseOption[] = [
|
||||
{ value: 0, label: '正戴平视' },
|
||||
{ value: 1, label: '低头压檐' },
|
||||
{ value: 2, label: '抬头回正' },
|
||||
{ value: 3, label: '侧肩偏戴' },
|
||||
{ value: 10, label: '行进稳戴' },
|
||||
{ value: 11, label: '行进压檐' },
|
||||
{ value: 20, label: '疾行前压' },
|
||||
{ value: 30, label: '跃起扬帽' },
|
||||
{ value: 40, label: '骑乘稳戴' },
|
||||
];
|
||||
|
||||
const MAIN_HAND_POSE_OPTIONS: MedievalPoseOption[] = [
|
||||
{ value: 0, label: '垂手持握' },
|
||||
{ value: 1, label: '斜举备战' },
|
||||
{ value: 2, label: '横持压迫' },
|
||||
{ value: 3, label: '高举蓄力' },
|
||||
{ value: 4, label: '前伸突进' },
|
||||
{ value: 5, label: '回收护身' },
|
||||
{ value: 6, label: '终势回摆' },
|
||||
{ value: 10, label: '行进持握' },
|
||||
{ value: 11, label: '行进前压' },
|
||||
{ value: 20, label: '冲刺挥击' },
|
||||
{ value: 30, label: '腾空猛挥' },
|
||||
{ value: 40, label: '高位压制' },
|
||||
];
|
||||
|
||||
const OFF_HAND_POSE_OPTIONS: MedievalPoseOption[] = [
|
||||
{ value: 0, label: '垂手副持' },
|
||||
{ value: 1, label: '贴身护侧' },
|
||||
{ value: 2, label: '前探协防' },
|
||||
{ value: 3, label: '抬臂护肩' },
|
||||
{ value: 4, label: '低位挡格' },
|
||||
{ value: 5, label: '回收守势' },
|
||||
{ value: 10, label: '行进协防' },
|
||||
{ value: 20, label: '冲刺护身' },
|
||||
{ value: 30, label: '跃起护面' },
|
||||
];
|
||||
|
||||
const SHIELD_POSE_OPTIONS: MedievalPoseOption[] = [
|
||||
{ value: 0, label: '垂盾待机' },
|
||||
{ value: 1, label: '侧盾待命' },
|
||||
{ value: 40, label: '正面举盾' },
|
||||
{ value: 41, label: '侧身护胸' },
|
||||
{ value: 42, label: '前架格挡' },
|
||||
{ value: 43, label: '抬盾压进' },
|
||||
{ value: 44, label: '护头防守' },
|
||||
{ value: 45, label: '回盾收势' },
|
||||
{ value: 50, label: '骑乘举盾' },
|
||||
];
|
||||
|
||||
const NPC_VISUAL_OVERRIDES = npcVisualOverridesJson as Record<string, MedievalNpcVisualOverride>;
|
||||
|
||||
export const MEDIEVAL_BODY_COLORS = [...BODY_COLORS];
|
||||
export const MEDIEVAL_BODY_COLOR_LABELS: Record<string, string> = {
|
||||
black: '墨黑布袍',
|
||||
blue: '深蓝布袍',
|
||||
brown: '棕褐布袍',
|
||||
gold: '暗金布袍',
|
||||
green: '苔绿布袍',
|
||||
grey: '灰布短衣',
|
||||
orange: '赭橙短衣',
|
||||
pink: '旧粉短衣',
|
||||
purple: '暗紫长衣',
|
||||
red: '深红长衣',
|
||||
silver: '银灰短衣',
|
||||
yellow: '土黄布袍',
|
||||
};
|
||||
|
||||
export const MEDIEVAL_RACE_LABELS: Record<MedievalRace, string> = {
|
||||
human: '人类',
|
||||
elf: '精灵',
|
||||
orc: '兽人',
|
||||
goblin: '地精',
|
||||
};
|
||||
|
||||
export const MEDIEVAL_HEAD_LABELS_BY_RACE = HEAD_TONE_LABELS_BY_RACE;
|
||||
|
||||
export const MEDIEVAL_HAIR_COLOR_LABELS: string[] = ['乌黑', '浅金', '蓝黑', '浅棕', '银白', '赤铜', '深紫', '苍灰'];
|
||||
export const MEDIEVAL_FACIAL_HAIR_COLOR_LABELS: string[] = ['乌黑', '浅金', '蓝黑', '浅棕', '银白', '赤铜', '深紫', '苍灰'];
|
||||
|
||||
export const MEDIEVAL_HAIR_STYLE_LABELS: string[] = [
|
||||
'后梳短发',
|
||||
'偏分短发',
|
||||
'额前碎发',
|
||||
'束起短尾',
|
||||
'厚刘海',
|
||||
'蓬松短发',
|
||||
'短冠发束',
|
||||
'前额碎发',
|
||||
'披肩短发',
|
||||
'两侧内卷',
|
||||
'中分长发',
|
||||
'偏分垂发',
|
||||
'侧束长发',
|
||||
'齐耳短发',
|
||||
'小辫短发',
|
||||
'圆顶短发',
|
||||
'发带束发',
|
||||
'低束长发',
|
||||
'角状长发',
|
||||
'后坠卷发',
|
||||
'碎卷长发',
|
||||
'盘起短发',
|
||||
'散落短卷',
|
||||
'高束马尾',
|
||||
'披散长发',
|
||||
'双层短发',
|
||||
'弧形短刘海',
|
||||
'长辫后束',
|
||||
'半束长发',
|
||||
'短束碎发',
|
||||
];
|
||||
|
||||
export const MEDIEVAL_FACIAL_HAIR_STYLE_LABELS: string[] = [
|
||||
'短尖胡',
|
||||
'细短髭',
|
||||
'八字胡',
|
||||
'唇上短须',
|
||||
'弯弧胡',
|
||||
'短山羊胡',
|
||||
'络腮短须',
|
||||
'短圆胡',
|
||||
'下巴细须',
|
||||
'下颌短须',
|
||||
'方形短胡',
|
||||
'卷尾胡',
|
||||
'下颌垂须',
|
||||
'方口胡',
|
||||
'尖长胡',
|
||||
'细卷髭',
|
||||
'弯月胡',
|
||||
'短髯须',
|
||||
'厚嘴髭',
|
||||
'锥形胡',
|
||||
'大八字胡',
|
||||
'窄下巴须',
|
||||
'短络腮',
|
||||
'双尖胡',
|
||||
'长下巴胡',
|
||||
'卷尖胡',
|
||||
'长弯胡',
|
||||
'翼状胡',
|
||||
'细长胡',
|
||||
'短胡尾',
|
||||
];
|
||||
|
||||
export const MEDIEVAL_CLOTH_HATS = Object.keys(CLOTH_HAT_ASSETS);
|
||||
export const MEDIEVAL_LEATHER_GEAR = Object.keys(LEATHER_ASSETS);
|
||||
export const MEDIEVAL_METAL_GEAR = Object.keys(METAL_ASSETS);
|
||||
export const MEDIEVAL_MELEE_WEAPONS = Object.keys(MELEE_ASSETS);
|
||||
export const MEDIEVAL_MAGIC_WEAPONS = Object.keys(MAGIC_ASSETS);
|
||||
export const MEDIEVAL_RANGED_WEAPONS = Object.keys(RANGED_ASSETS);
|
||||
|
||||
export const MEDIEVAL_CLOTH_HAT_LABELS = toLabelMap(CLOTH_HAT_ASSETS);
|
||||
export const MEDIEVAL_LEATHER_GEAR_LABELS = toLabelMap(LEATHER_ASSETS);
|
||||
export const MEDIEVAL_METAL_GEAR_LABELS = toLabelMap(METAL_ASSETS);
|
||||
export const MEDIEVAL_MELEE_WEAPON_LABELS = toLabelMap(MELEE_ASSETS);
|
||||
export const MEDIEVAL_MAGIC_WEAPON_LABELS = toLabelMap(MAGIC_ASSETS);
|
||||
export const MEDIEVAL_RANGED_WEAPON_LABELS = toLabelMap(RANGED_ASSETS);
|
||||
|
||||
export function getRaceSpriteCounts(race: MedievalRace) {
|
||||
return RACE_SPRITE_COUNTS[race];
|
||||
}
|
||||
|
||||
export function getMedievalHeadOptions(race: MedievalRace): Array<{ value: number; label: string }> {
|
||||
return HEAD_TONE_LABELS_BY_RACE[race].map((label, index) => ({
|
||||
value: index + 1,
|
||||
label,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMedievalAtlasAsset(type: MedievalAtlasSourceType, file: string) {
|
||||
const assetMap = ATLAS_ASSET_MAPS[type] as Record<string, MedievalAtlasAssetDefinition>;
|
||||
return assetMap[file] ?? null;
|
||||
}
|
||||
|
||||
export function getMedievalAtlasOptions(type: MedievalAtlasSourceType) {
|
||||
return Object.values(ATLAS_ASSET_MAPS[type]);
|
||||
}
|
||||
|
||||
export function getMedievalPoseOptions(
|
||||
type: MedievalAtlasSourceType,
|
||||
file: string,
|
||||
usage: MedievalAtlasUsage,
|
||||
): MedievalPoseOption[] {
|
||||
const asset = getMedievalAtlasAsset(type, file);
|
||||
if (!asset) return [];
|
||||
|
||||
const baseOptions = usage === 'offHand' && file === 'shield.png'
|
||||
? SHIELD_POSE_OPTIONS
|
||||
: usage === 'headgear'
|
||||
? HEADGEAR_POSE_OPTIONS
|
||||
: usage === 'mainHand'
|
||||
? MAIN_HAND_POSE_OPTIONS
|
||||
: OFF_HAND_POSE_OPTIONS;
|
||||
|
||||
const filtered = baseOptions.filter(option => option.value < asset.frameCount);
|
||||
if (filtered.length > 0) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return buildFallbackPoseOptions(asset.frameCount, usage);
|
||||
}
|
||||
|
||||
export function clampMedievalAtlasFrame(type: MedievalAtlasSourceType, file: string, frameIndex: number) {
|
||||
const asset = getMedievalAtlasAsset(type, file);
|
||||
if (!asset) return frameIndex;
|
||||
return Math.max(0, Math.min(frameIndex, asset.frameCount - 1));
|
||||
}
|
||||
|
||||
export function buildClothHatPath(file: string) {
|
||||
return CLOTH_HAT_ASSETS[file as keyof typeof CLOTH_HAT_ASSETS]?.src
|
||||
?? `/character/MedievalFantasyCharacters/sprites/wardrobe/cloth/${file}`;
|
||||
}
|
||||
|
||||
export function buildLeatherGearPath(file: string) {
|
||||
return LEATHER_ASSETS[file as keyof typeof LEATHER_ASSETS]?.src
|
||||
?? `/character/MedievalFantasyCharacters/sprites/wardrobe/leather/${file}`;
|
||||
}
|
||||
|
||||
export function buildMetalGearPath(file: string) {
|
||||
return METAL_ASSETS[file as keyof typeof METAL_ASSETS]?.src
|
||||
?? `/character/MedievalFantasyCharacters/sprites/wardrobe/metal/${file}`;
|
||||
}
|
||||
|
||||
export function buildMeleeWeaponPath(file: string) {
|
||||
return MELEE_ASSETS[file as keyof typeof MELEE_ASSETS]?.src
|
||||
?? `/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/${file}`;
|
||||
}
|
||||
|
||||
export function buildMagicWeaponPath(file: string) {
|
||||
return MAGIC_ASSETS[file as keyof typeof MAGIC_ASSETS]?.src
|
||||
?? `/character/MedievalFantasyCharacters/sprites/weapons/magic weapons/${file}`;
|
||||
}
|
||||
|
||||
export function buildRangedWeaponPath(file: string) {
|
||||
return RANGED_ASSETS[file as keyof typeof RANGED_ASSETS]?.src
|
||||
?? `/character/MedievalFantasyCharacters/sprites/weapons/ranged weapons/${file}`;
|
||||
}
|
||||
|
||||
export function buildMedievalAtlasSpec(
|
||||
type: MedievalAtlasSourceType,
|
||||
file: string,
|
||||
frameIndex: number,
|
||||
): AtlasTileSpec | undefined {
|
||||
const asset = getMedievalAtlasAsset(type, file);
|
||||
if (!asset) return undefined;
|
||||
|
||||
return {
|
||||
src: asset.src,
|
||||
frameIndex: clampMedievalAtlasFrame(type, file, frameIndex),
|
||||
columns: asset.columns,
|
||||
tileWidth: asset.tileWidth,
|
||||
tileHeight: asset.tileHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function inferAtlasSourceType(src: string | undefined): MedievalAtlasSourceType | null {
|
||||
if (!src) return null;
|
||||
if (src.includes('/wardrobe/cloth/')) return 'cloth';
|
||||
if (src.includes('/wardrobe/leather/')) return 'leather';
|
||||
if (src.includes('/wardrobe/metal/')) return 'metal';
|
||||
if (src.includes('/weapons/melee weapons/')) return 'melee';
|
||||
if (src.includes('/weapons/magic weapons/')) return 'magic';
|
||||
if (src.includes('/weapons/ranged weapons/')) return 'ranged';
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeCustomWorldNpcVisualGear(
|
||||
gear: CustomWorldNpcVisualGear | null | undefined,
|
||||
usage: MedievalAtlasUsage,
|
||||
): CustomWorldNpcVisualGear | null {
|
||||
if (!gear?.file) return null;
|
||||
|
||||
const poseOptions = getMedievalPoseOptions(gear.type, gear.file, usage);
|
||||
if (poseOptions.length === 0) {
|
||||
return {
|
||||
...gear,
|
||||
frameIndex: clampMedievalAtlasFrame(gear.type, gear.file, gear.frameIndex),
|
||||
};
|
||||
}
|
||||
|
||||
const frameIndex = poseOptions.some(option => option.value === gear.frameIndex)
|
||||
? clampMedievalAtlasFrame(gear.type, gear.file, gear.frameIndex)
|
||||
: poseOptions[0]!.value;
|
||||
|
||||
return {
|
||||
...gear,
|
||||
frameIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCustomWorldNpcVisualGear(
|
||||
spec: AtlasTileSpec | undefined,
|
||||
usage: MedievalAtlasUsage,
|
||||
): CustomWorldNpcVisualGear | null {
|
||||
const type = inferAtlasSourceType(spec?.src);
|
||||
const file = spec?.src.split('/').pop();
|
||||
|
||||
if (!type || !file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitizeCustomWorldNpcVisualGear(
|
||||
{
|
||||
type,
|
||||
file,
|
||||
frameIndex: spec?.frameIndex ?? 0,
|
||||
},
|
||||
usage,
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeCustomWorldNpcVisual(visual: CustomWorldNpcVisual): CustomWorldNpcVisual {
|
||||
const spriteCounts = RACE_SPRITE_COUNTS[visual.race];
|
||||
const bodyColor = BODY_COLORS.includes(visual.bodyColor as (typeof BODY_COLORS)[number])
|
||||
? visual.bodyColor
|
||||
: BODY_COLORS[0];
|
||||
|
||||
return {
|
||||
race: visual.race,
|
||||
bodyColor,
|
||||
headIndex: Math.max(1, Math.min(visual.headIndex, spriteCounts.head)),
|
||||
hairColorIndex: Math.max(1, Math.min(visual.hairColorIndex, spriteCounts.hair)),
|
||||
hairStyleFrame: Math.max(0, Math.min(visual.hairStyleFrame, MEDIEVAL_HAIR_STYLE_LABELS.length - 1)),
|
||||
facialHairEnabled: visual.facialHairEnabled,
|
||||
facialHairColorIndex: Math.max(1, Math.min(visual.facialHairColorIndex, spriteCounts.facialHair)),
|
||||
facialHairStyleFrame: Math.max(0, Math.min(visual.facialHairStyleFrame, MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.length - 1)),
|
||||
headgear: sanitizeCustomWorldNpcVisualGear(visual.headgear, 'headgear'),
|
||||
mainHand: sanitizeCustomWorldNpcVisualGear(visual.mainHand, 'mainHand'),
|
||||
offHand: sanitizeCustomWorldNpcVisualGear(visual.offHand, 'offHand'),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCustomWorldNpcVisualFromSpec(spec: MedievalNpcVisualSpec): CustomWorldNpcVisual {
|
||||
const visual = {
|
||||
race: spec.race,
|
||||
bodyColor: spec.bodySrc.match(/body_(.+)\.png$/u)?.[1] ?? BODY_COLORS[0],
|
||||
headIndex: Number(spec.headSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'),
|
||||
hairColorIndex: Number(spec.hairSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'),
|
||||
hairStyleFrame: spec.hairFrame ?? 0,
|
||||
facialHairEnabled: Boolean(spec.facialHairSrc),
|
||||
facialHairColorIndex: Number(spec.facialHairSrc?.match(/_(\d+)\.png$/u)?.[1] ?? '1'),
|
||||
facialHairStyleFrame: spec.facialHairFrame ?? 0,
|
||||
headgear: parseCustomWorldNpcVisualGear(spec.headgear, 'headgear'),
|
||||
mainHand: parseCustomWorldNpcVisualGear(spec.mainHand, 'mainHand'),
|
||||
offHand: parseCustomWorldNpcVisualGear(spec.offHand, 'offHand'),
|
||||
} satisfies CustomWorldNpcVisual;
|
||||
|
||||
return sanitizeCustomWorldNpcVisual(visual);
|
||||
}
|
||||
|
||||
export function buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual: CustomWorldNpcVisual): MedievalNpcVisualOverride {
|
||||
const sanitizedVisual = sanitizeCustomWorldNpcVisual(visual);
|
||||
const bodyColor = BODY_COLORS.includes(sanitizedVisual.bodyColor as (typeof BODY_COLORS)[number])
|
||||
? sanitizedVisual.bodyColor as (typeof BODY_COLORS)[number]
|
||||
: BODY_COLORS[0];
|
||||
|
||||
return {
|
||||
race: sanitizedVisual.race,
|
||||
bodySrc: buildBodyPath(bodyColor),
|
||||
headSrc: buildRaceAssetPath(sanitizedVisual.race, 'head', sanitizedVisual.headIndex),
|
||||
hairSrc: buildRaceAssetPath(sanitizedVisual.race, 'hair', sanitizedVisual.hairColorIndex),
|
||||
handSrc: buildRaceAssetPath(sanitizedVisual.race, 'hand', 1),
|
||||
facialHairSrc: sanitizedVisual.facialHairEnabled
|
||||
? buildRaceAssetPath(sanitizedVisual.race, 'facialHair', sanitizedVisual.facialHairColorIndex)
|
||||
: undefined,
|
||||
headgear: sanitizedVisual.headgear
|
||||
? buildMedievalAtlasSpec(sanitizedVisual.headgear.type, sanitizedVisual.headgear.file, sanitizedVisual.headgear.frameIndex)
|
||||
: undefined,
|
||||
mainHand: sanitizedVisual.mainHand
|
||||
? buildMedievalAtlasSpec(sanitizedVisual.mainHand.type, sanitizedVisual.mainHand.file, sanitizedVisual.mainHand.frameIndex)
|
||||
: undefined,
|
||||
offHand: sanitizedVisual.offHand
|
||||
? buildMedievalAtlasSpec(sanitizedVisual.offHand.type, sanitizedVisual.offHand.file, sanitizedVisual.offHand.frameIndex)
|
||||
: undefined,
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
headFrame: 0,
|
||||
hairFrame: sanitizedVisual.hairStyleFrame,
|
||||
handFrame: 0,
|
||||
facialHairFrame: sanitizedVisual.facialHairEnabled ? sanitizedVisual.facialHairStyleFrame : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNpcVisualOverrideById(overrideId: string) {
|
||||
return NPC_VISUAL_OVERRIDES[overrideId] ?? null;
|
||||
}
|
||||
|
||||
function getRuntimeCustomWorldNpcOverride(encounter: Encounter) {
|
||||
if (!encounter.id) return null;
|
||||
|
||||
const runtimeProfile = getRuntimeCustomWorldProfile();
|
||||
const storyNpc = runtimeProfile?.storyNpcs.find(npc => npc.id === encounter.id);
|
||||
if (!storyNpc?.visual) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildMedievalNpcVisualOverrideFromCustomWorldVisual(storyNpc.visual);
|
||||
}
|
||||
|
||||
function createAtlasAsset(
|
||||
type: MedievalAtlasSourceType,
|
||||
file: string,
|
||||
label: string,
|
||||
columns: number,
|
||||
frameCount: number,
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
): MedievalAtlasAssetDefinition {
|
||||
return {
|
||||
file,
|
||||
label,
|
||||
src: buildAtlasAssetPath(type, file),
|
||||
columns,
|
||||
frameCount,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAtlasAssetPath(type: MedievalAtlasSourceType, file: string) {
|
||||
if (type === 'cloth') return `/character/MedievalFantasyCharacters/sprites/wardrobe/cloth/${file}`;
|
||||
if (type === 'leather') return `/character/MedievalFantasyCharacters/sprites/wardrobe/leather/${file}`;
|
||||
if (type === 'metal') return `/character/MedievalFantasyCharacters/sprites/wardrobe/metal/${file}`;
|
||||
if (type === 'melee') return `/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/${file}`;
|
||||
if (type === 'magic') return `/character/MedievalFantasyCharacters/sprites/weapons/magic weapons/${file}`;
|
||||
return `/character/MedievalFantasyCharacters/sprites/weapons/ranged weapons/${file}`;
|
||||
}
|
||||
|
||||
function toLabelMap(definitions: Record<string, MedievalAtlasAssetDefinition>) {
|
||||
return Object.fromEntries(
|
||||
Object.values(definitions).map(definition => [definition.file, definition.label]),
|
||||
) as Record<string, string>;
|
||||
}
|
||||
|
||||
function buildFallbackPoseOptions(frameCount: number, usage: MedievalAtlasUsage): MedievalPoseOption[] {
|
||||
const labelsByUsage: Record<MedievalAtlasUsage, string[]> = {
|
||||
headgear: ['平视佩戴', '轻压帽檐', '低头稳帽', '抬头回正', '侧身偏戴', '前行稳帽', '快步压檐', '跃起扬帽'],
|
||||
mainHand: ['基础持握', '低位收势', '中位平举', '高位抬手', '前伸出手', '横持压迫', '回收护身', '终势停稳'],
|
||||
offHand: ['副手待命', '贴身护侧', '前探协防', '抬臂护肩', '低位挡格', '回收守势', '前行护身', '高位封挡'],
|
||||
};
|
||||
|
||||
return Array.from({ length: frameCount }, (_, index) => ({
|
||||
value: index,
|
||||
label: labelsByUsage[usage][index] ?? labelsByUsage[usage][labelsByUsage[usage].length - 1] ?? `${usage}-${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function hashString(value: string) {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function pickFromArray<T>(items: readonly T[], seed: number, salt: number): T {
|
||||
if (items.length === 0) {
|
||||
throw new Error('Cannot pick from an empty array.');
|
||||
}
|
||||
|
||||
const picked = items[(seed + salt) % items.length];
|
||||
const fallbackItem = items[0];
|
||||
if (fallbackItem === undefined) {
|
||||
throw new Error('Expected a fallback item.');
|
||||
}
|
||||
return picked ?? fallbackItem;
|
||||
}
|
||||
|
||||
function pickPoseFrame(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage, seed: number, salt: number) {
|
||||
const poseOptions = getMedievalPoseOptions(type, file, usage);
|
||||
if (poseOptions.length === 0) return 0;
|
||||
return pickFromArray(poseOptions, seed, salt).value;
|
||||
}
|
||||
|
||||
export function buildRaceAssetPath(race: MedievalRace, section: 'head' | 'hair' | 'facialHair' | 'hand', index: number) {
|
||||
const base = '/character/MedievalFantasyCharacters/sprites/Characters';
|
||||
if (section === 'head') {
|
||||
return `${base}/${race}/head/${race}_head_skin_${index}.png`;
|
||||
}
|
||||
if (section === 'hair') {
|
||||
return `${base}/${race}/hair/hairstyle/${race}_hair_${index}.png`;
|
||||
}
|
||||
if (section === 'facialHair') {
|
||||
return `${base}/${race}/hair/facial hair/${race}_facialhair_${index}.png`;
|
||||
}
|
||||
return `${base}/${race}/hand/${race}_hand.png`;
|
||||
}
|
||||
|
||||
export function buildBodyPath(color: (typeof BODY_COLORS)[number]) {
|
||||
return `/character/MedievalFantasyCharacters/sprites/Characters/body/body_${color}.png`;
|
||||
}
|
||||
|
||||
function buildHeadgear(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined {
|
||||
if (roleStyle === 'civilian') {
|
||||
const file = pickFromArray(MEDIEVAL_CLOTH_HATS, seed, 7);
|
||||
return buildMedievalAtlasSpec('cloth', file, pickPoseFrame('cloth', file, 'headgear', seed, 17));
|
||||
}
|
||||
|
||||
if (roleStyle === 'rogue') {
|
||||
const file = pickFromArray(MEDIEVAL_LEATHER_GEAR, seed, 11);
|
||||
return buildMedievalAtlasSpec('leather', file, pickPoseFrame('leather', file, 'headgear', seed, 19));
|
||||
}
|
||||
|
||||
if (roleStyle === 'warrior' || roleStyle === 'guardian') {
|
||||
const file = pickFromArray(MEDIEVAL_METAL_GEAR, seed, 13);
|
||||
return buildMedievalAtlasSpec('metal', file, pickPoseFrame('metal', file, 'headgear', seed, 23));
|
||||
}
|
||||
|
||||
if (roleStyle === 'mystic') {
|
||||
const file = pickFromArray(MEDIEVAL_CLOTH_HATS, seed, 17);
|
||||
return buildMedievalAtlasSpec('cloth', file, pickPoseFrame('cloth', file, 'headgear', seed, 29));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMainHand(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined {
|
||||
if (roleStyle === 'mystic') {
|
||||
const file = pickFromArray(MEDIEVAL_MAGIC_WEAPONS, seed, 23);
|
||||
return buildMedievalAtlasSpec('magic', file, pickPoseFrame('magic', file, 'mainHand', seed, 31));
|
||||
}
|
||||
|
||||
if (roleStyle === 'ranger') {
|
||||
const preferred = ['bow.png', 'crossbow.png', 'sling.png'] as const;
|
||||
const file = pickFromArray(preferred, seed, 29);
|
||||
return buildMedievalAtlasSpec('ranged', file, pickPoseFrame('ranged', file, 'mainHand', seed, 37));
|
||||
}
|
||||
|
||||
if (roleStyle === 'guardian') {
|
||||
return buildMedievalAtlasSpec('melee', 'polearm.png', pickPoseFrame('melee', 'polearm.png', 'mainHand', seed, 41));
|
||||
}
|
||||
|
||||
if (roleStyle === 'warrior' || roleStyle === 'rogue' || roleStyle === 'bruiser') {
|
||||
const preferred = ['sword.png', 'axe.png', 'dagger.png', 'blunt.png', 'sword_big.png', 'axe_big.png'] as const;
|
||||
const file = pickFromArray(preferred, seed, 31);
|
||||
return buildMedievalAtlasSpec('melee', file, pickPoseFrame('melee', file, 'mainHand', seed, 43));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildOffHand(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined {
|
||||
if (roleStyle !== 'guardian') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildMedievalAtlasSpec('melee', 'shield.png', pickPoseFrame('melee', 'shield.png', 'offHand', seed, 47));
|
||||
}
|
||||
|
||||
function inferRoleStyle(encounter: Encounter): NpcRoleStyle {
|
||||
const source = `${encounter.id ?? ''} ${encounter.characterId ?? ''} ${encounter.npcName} ${encounter.context}`.toLowerCase();
|
||||
|
||||
if (source.includes('archer-hero') || source.includes('猎') || source.includes('弓') || source.includes('巡修')) {
|
||||
return 'ranger';
|
||||
}
|
||||
if (source.includes('gate-disciple') || source.includes('门') || source.includes('守使') || source.includes('灵侍')) {
|
||||
return 'guardian';
|
||||
}
|
||||
if (source.includes('sword-princess') || source.includes('守山') || source.includes('宫人') || source.includes('舵手')) {
|
||||
return 'warrior';
|
||||
}
|
||||
if (source.includes('fighter-4') || source.includes('军需') || source.includes('铸匠') || source.includes('炼匠')) {
|
||||
return 'guardian';
|
||||
}
|
||||
if (source.includes('punch-hero') || source.includes('矿') || source.includes('匠') || source.includes('渡工')) {
|
||||
return 'bruiser';
|
||||
}
|
||||
if (source.includes('司录') || source.includes('学者') || source.includes('药师') || source.includes('书生') || source.includes('侍者')) {
|
||||
return 'mystic';
|
||||
}
|
||||
if (source.includes('girl-hero') || source.includes('侍女') || source.includes('散修') || source.includes('访客') || source.includes('琴师')) {
|
||||
return 'rogue';
|
||||
}
|
||||
return 'civilian';
|
||||
}
|
||||
|
||||
function inferRace(encounter: Encounter, roleStyle: NpcRoleStyle, seed: number): MedievalRace {
|
||||
const source = `${encounter.id ?? ''} ${encounter.characterId ?? ''} ${encounter.npcName} ${encounter.context}`.toLowerCase();
|
||||
|
||||
if (source.includes('archer-hero') || source.includes('精灵') || source.includes('琴师') || source.includes('云')) {
|
||||
return 'elf';
|
||||
}
|
||||
if (source.includes('punch-hero') || source.includes('铸匠') || source.includes('矿') || source.includes('熔')) {
|
||||
return 'orc';
|
||||
}
|
||||
if (source.includes('girl-hero') || source.includes('散修') || source.includes('访客')) {
|
||||
return 'goblin';
|
||||
}
|
||||
if (roleStyle === 'bruiser') {
|
||||
return seed % 2 === 0 ? 'orc' : 'human';
|
||||
}
|
||||
return 'human';
|
||||
}
|
||||
|
||||
function shouldUseFacialHair(race: MedievalRace, roleStyle: NpcRoleStyle, seed: number) {
|
||||
if (race === 'elf') return seed % 5 === 0;
|
||||
if (race === 'goblin') return seed % 4 === 0;
|
||||
if (roleStyle === 'civilian') return seed % 2 === 0;
|
||||
return seed % 3 !== 0;
|
||||
}
|
||||
|
||||
export function buildMedievalNpcVisual(encounter: Encounter): MedievalNpcVisualSpec {
|
||||
const seed = hashString(`${encounter.id ?? encounter.npcName}:${encounter.context}:${encounter.characterId ?? ''}`);
|
||||
const override = getRuntimeCustomWorldNpcOverride(encounter) ?? (encounter.id ? NPC_VISUAL_OVERRIDES[encounter.id] : undefined);
|
||||
|
||||
if (override) {
|
||||
return {
|
||||
race: override.race ?? 'human',
|
||||
bodySrc: override.bodySrc ?? buildBodyPath('black'),
|
||||
headSrc: override.headSrc ?? buildRaceAssetPath(override.race ?? 'human', 'head', 1),
|
||||
hairSrc: override.hairSrc ?? buildRaceAssetPath(override.race ?? 'human', 'hair', 1),
|
||||
handSrc: override.handSrc ?? buildRaceAssetPath(override.race ?? 'human', 'hand', 1),
|
||||
facialHairSrc: override.facialHairSrc,
|
||||
headgear: override.headgear,
|
||||
mainHand: override.mainHand,
|
||||
offHand: override.offHand,
|
||||
bodyFrames: override.bodyFrames ?? [0, 1, 2, 3],
|
||||
headFrame: override.headFrame ?? 0,
|
||||
hairFrame: override.hairFrame ?? 0,
|
||||
handFrame: override.handFrame ?? 0,
|
||||
facialHairFrame: override.facialHairFrame,
|
||||
};
|
||||
}
|
||||
|
||||
const roleStyle = inferRoleStyle(encounter);
|
||||
const race = inferRace(encounter, roleStyle, seed);
|
||||
const counts = RACE_SPRITE_COUNTS[race];
|
||||
const bodyColor = pickFromArray(BODY_COLORS, seed, 3);
|
||||
const headIndex = (seed % counts.head) + 1;
|
||||
const hairIndex = ((seed >> 3) % counts.hair) + 1;
|
||||
const facialHairIndex = ((seed >> 5) % counts.facialHair) + 1;
|
||||
const useFacialHair = shouldUseFacialHair(race, roleStyle, seed);
|
||||
|
||||
return {
|
||||
race,
|
||||
bodySrc: buildBodyPath(bodyColor),
|
||||
headSrc: buildRaceAssetPath(race, 'head', headIndex),
|
||||
hairSrc: buildRaceAssetPath(race, 'hair', hairIndex),
|
||||
handSrc: buildRaceAssetPath(race, 'hand', 1),
|
||||
facialHairSrc: useFacialHair ? buildRaceAssetPath(race, 'facialHair', facialHairIndex) : undefined,
|
||||
headgear: buildHeadgear(roleStyle, seed),
|
||||
mainHand: buildMainHand(roleStyle, seed),
|
||||
offHand: buildOffHand(roleStyle, seed),
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
headFrame: 0,
|
||||
hairFrame: 0,
|
||||
handFrame: 0,
|
||||
facialHairFrame: useFacialHair ? 0 : undefined,
|
||||
};
|
||||
}
|
||||
1
src/data/monsterOverrides.json
Normal file
1
src/data/monsterOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/data/monsters.ts
Normal file
1
src/data/monsters.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {createSceneHostileNpcsFromIds, createSceneMonstersFromIds} from './hostileNpcs';
|
||||
387
src/data/npcAttributeInsights.ts
Normal file
387
src/data/npcAttributeInsights.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
type AttributeVector,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type Encounter,
|
||||
type InventoryItem,
|
||||
type NpcPersistentState,
|
||||
type RoleAttributeProfile,
|
||||
type WorldAttributeSchema,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
buildItemAttributeResonance,
|
||||
buildRoleAttributeProfileFromLegacyData,
|
||||
} from './attributeProfileGenerator';
|
||||
import {
|
||||
buildDefaultAxisVector,
|
||||
getSortedAttributeEntries,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
scoreAttributeFit,
|
||||
} from './attributeResolver';
|
||||
import {normalizeAttributeVector} from './attributeValidation';
|
||||
import {getCharacterById} from './characterPresets';
|
||||
import {getRuntimeCustomWorldProfile} from './customWorldRuntime';
|
||||
import {getMonsterPresetById} from './hostileNpcPresets';
|
||||
|
||||
export type GiftAffinityInsight = {
|
||||
resonanceScore: number;
|
||||
resonanceBonus: number;
|
||||
matchedAttributes: string[];
|
||||
reasonText: string;
|
||||
};
|
||||
|
||||
export type ChatAffinityOutcome = {
|
||||
affinityGain: number;
|
||||
matchedAttributes: string[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type RecruitReadinessInsight = {
|
||||
readinessScore: number;
|
||||
resonanceBonus: number;
|
||||
tabooPenalty: number;
|
||||
matchedAttributes: string[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
function resolveCustomWorldProfile(profile?: CustomWorldProfile | null) {
|
||||
return profile ?? getRuntimeCustomWorldProfile();
|
||||
}
|
||||
|
||||
function resolveInteractionWorldType(
|
||||
worldType?: WorldType | null,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
encounter?: Encounter | null,
|
||||
) {
|
||||
if (worldType) {
|
||||
return worldType;
|
||||
}
|
||||
|
||||
if (customWorldProfile || getRuntimeCustomWorldProfile()) {
|
||||
return WorldType.CUSTOM;
|
||||
}
|
||||
|
||||
const schemaId = encounter?.attributeProfile?.schemaId ?? '';
|
||||
if (schemaId.includes('xianxia')) {
|
||||
return WorldType.XIANXIA;
|
||||
}
|
||||
|
||||
return WorldType.WUXIA;
|
||||
}
|
||||
|
||||
function buildSchemaContext(
|
||||
worldType?: WorldType | null,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
encounter?: Encounter | null,
|
||||
) {
|
||||
const resolvedCustomWorldProfile = resolveCustomWorldProfile(customWorldProfile);
|
||||
const resolvedWorldType = resolveInteractionWorldType(
|
||||
worldType,
|
||||
resolvedCustomWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
|
||||
return {
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile: resolvedCustomWorldProfile,
|
||||
schema: resolveAttributeSchema(resolvedWorldType, resolvedCustomWorldProfile),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMonsterProfile(
|
||||
encounter: Encounter,
|
||||
worldType: WorldType,
|
||||
) {
|
||||
if (!encounter.monsterPresetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getMonsterPresetById(worldType, encounter.monsterPresetId)?.attributeProfile
|
||||
?? getMonsterPresetById(WorldType.WUXIA, encounter.monsterPresetId)?.attributeProfile
|
||||
?? getMonsterPresetById(WorldType.XIANXIA, encounter.monsterPresetId)?.attributeProfile
|
||||
?? getMonsterPresetById(WorldType.CUSTOM, encounter.monsterPresetId)?.attributeProfile
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function resolveEncounterAttributeProfile(
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
} = {},
|
||||
) {
|
||||
const schemaContext = buildSchemaContext(
|
||||
options.worldType,
|
||||
options.customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
|
||||
if (encounter.characterId) {
|
||||
const character = getCharacterById(encounter.characterId);
|
||||
if (character) {
|
||||
return resolveCharacterAttributeProfile(
|
||||
character,
|
||||
schemaContext.worldType,
|
||||
schemaContext.customWorldProfile,
|
||||
) ?? encounter.attributeProfile ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (encounter.attributeProfile) {
|
||||
return encounter.attributeProfile;
|
||||
}
|
||||
|
||||
const monsterProfile = resolveMonsterProfile(encounter, schemaContext.worldType);
|
||||
if (monsterProfile) {
|
||||
return monsterProfile;
|
||||
}
|
||||
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: encounter.id ?? encounter.npcName,
|
||||
schema: schemaContext.schema,
|
||||
textBlocks: [
|
||||
encounter.npcName,
|
||||
encounter.context,
|
||||
encounter.npcDescription,
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
function buildFocusVector(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
limit: number,
|
||||
direction: 'top' | 'bottom' = 'top',
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const sourceEntries = getSortedAttributeEntries(profile, schema);
|
||||
const orderedEntries = direction === 'bottom' ? [...sourceEntries].reverse() : sourceEntries;
|
||||
const selectedEntries = orderedEntries.slice(0, limit);
|
||||
const seed = Object.fromEntries(slotIds.map(slotId => [slotId, 0])) as AttributeVector;
|
||||
|
||||
selectedEntries.forEach(entry => {
|
||||
seed[entry.slot.slotId] = entry.value;
|
||||
});
|
||||
|
||||
return normalizeAttributeVector(seed, slotIds);
|
||||
}
|
||||
|
||||
function getTopContributionAttributeNames(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
vector: AttributeVector,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 2,
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const profileWeights = normalizeAttributeVector(profile?.values ?? {}, slotIds);
|
||||
const vectorWeights = normalizeAttributeVector(vector, slotIds);
|
||||
|
||||
return schema.slots
|
||||
.map(slot => ({
|
||||
name: slot.name,
|
||||
contribution: (profileWeights[slot.slotId] ?? 0) * (vectorWeights[slot.slotId] ?? 0),
|
||||
}))
|
||||
.sort((left, right) => right.contribution - left.contribution)
|
||||
.filter(entry => entry.contribution > 0)
|
||||
.slice(0, limit)
|
||||
.map(entry => entry.name);
|
||||
}
|
||||
|
||||
function uniqueNames(names: string[]) {
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildDialogueIntentVector(actionText: string, schema: WorldAttributeSchema) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const source = actionText.trim();
|
||||
const seed = buildDefaultAxisVector({});
|
||||
|
||||
const addWeight = (slotId: string, weight: number, pattern: RegExp) => {
|
||||
if (pattern.test(source)) {
|
||||
seed[slotId] = (seed[slotId] ?? 0) + weight;
|
||||
}
|
||||
};
|
||||
|
||||
addWeight('axis_a', 0.24, /顶|扛|硬闯|守住|挡住/u);
|
||||
addWeight('axis_b', 0.2, /快|抢|追|赶在|绕开|先一步/u);
|
||||
addWeight('axis_c', 0.34, /试探|追问|看穿|真假|真相|线索|判断|观察/u);
|
||||
addWeight('axis_d', 0.3, /直说|表态|逼|压住|摊开|决断|邀你同行|邀请/u);
|
||||
addWeight('axis_e', 0.34, /安抚|诚意|求助|关心|陪|同行|信|礼物|约定/u);
|
||||
addWeight('axis_f', 0.22, /稳|缓一缓|耐心|慢慢|调息|先稳住/u);
|
||||
|
||||
const total = slotIds.reduce((sum, slotId) => sum + (seed[slotId] ?? 0), 0);
|
||||
if (total <= 0) {
|
||||
return normalizeAttributeVector(
|
||||
buildDefaultAxisVector({
|
||||
axis_c: 0.34,
|
||||
axis_e: 0.38,
|
||||
axis_f: 0.28,
|
||||
}),
|
||||
slotIds,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeAttributeVector(seed, slotIds);
|
||||
}
|
||||
|
||||
export function buildEncounterAttributeRumors(
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
limit?: number;
|
||||
} = {},
|
||||
) {
|
||||
const schemaContext = buildSchemaContext(
|
||||
options.worldType,
|
||||
options.customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
const profile = resolveEncounterAttributeProfile(encounter, options);
|
||||
const evidence = profile?.evidence?.slice(0, options.limit ?? 2).map(entry => entry.reason) ?? [];
|
||||
|
||||
if (evidence.length > 0) {
|
||||
return evidence;
|
||||
}
|
||||
|
||||
return getSortedAttributeEntries(profile, schemaContext.schema)
|
||||
.slice(0, options.limit ?? 2)
|
||||
.map(entry => `${entry.slot.name}:${entry.slot.definition}`);
|
||||
}
|
||||
|
||||
export function buildGiftAffinityInsight(
|
||||
item: InventoryItem,
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
} = {},
|
||||
): GiftAffinityInsight {
|
||||
const schemaContext = buildSchemaContext(
|
||||
options.worldType,
|
||||
options.customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
const npcProfile = resolveEncounterAttributeProfile(encounter, options);
|
||||
const attributeResonance = item.attributeResonance ?? buildItemAttributeResonance(item);
|
||||
const resonanceScore = scoreAttributeFit(
|
||||
npcProfile,
|
||||
attributeResonance.resonanceVector,
|
||||
schemaContext.schema,
|
||||
);
|
||||
const resonanceBonus = Math.max(0, Math.min(6, Math.round(resonanceScore * 18) - 1));
|
||||
const matchedAttributes = getTopContributionAttributeNames(
|
||||
npcProfile,
|
||||
attributeResonance.resonanceVector,
|
||||
schemaContext.schema,
|
||||
);
|
||||
|
||||
return {
|
||||
resonanceScore,
|
||||
resonanceBonus,
|
||||
matchedAttributes,
|
||||
reasonText: matchedAttributes.length > 0
|
||||
? `${item.name}更对上对方看重的${matchedAttributes.join('、')}。`
|
||||
: attributeResonance.explanation ?? `${item.name}带来的属性共振暂时不够明显。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChatAffinityOutcome(params: {
|
||||
playerCharacter: Character;
|
||||
encounter: Encounter;
|
||||
npcState: NpcPersistentState;
|
||||
actionText: string;
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}): ChatAffinityOutcome {
|
||||
const schemaContext = buildSchemaContext(
|
||||
params.worldType,
|
||||
params.customWorldProfile,
|
||||
params.encounter,
|
||||
);
|
||||
const playerProfile = resolveCharacterAttributeProfile(
|
||||
params.playerCharacter,
|
||||
schemaContext.worldType,
|
||||
schemaContext.customWorldProfile,
|
||||
);
|
||||
const npcProfile = resolveEncounterAttributeProfile(params.encounter, {
|
||||
worldType: schemaContext.worldType,
|
||||
customWorldProfile: schemaContext.customWorldProfile,
|
||||
});
|
||||
const intentVector = buildDialogueIntentVector(params.actionText, schemaContext.schema);
|
||||
const actorFit = scoreAttributeFit(playerProfile, intentVector, schemaContext.schema);
|
||||
const targetFit = scoreAttributeFit(npcProfile, intentVector, schemaContext.schema);
|
||||
const sharedFocusVector = buildFocusVector(npcProfile, schemaContext.schema, 2);
|
||||
const sharedResonance = scoreAttributeFit(playerProfile, sharedFocusVector, schemaContext.schema);
|
||||
const baseGain = Math.max(4, 8 - params.npcState.chattedCount);
|
||||
const bonusGain = Math.max(
|
||||
0,
|
||||
Math.min(4, Math.round(actorFit * 8 + targetFit * 6 + sharedResonance * 8) - 2),
|
||||
);
|
||||
const matchedAttributes = uniqueNames([
|
||||
...getTopContributionAttributeNames(playerProfile, intentVector, schemaContext.schema),
|
||||
...getTopContributionAttributeNames(npcProfile, intentVector, schemaContext.schema),
|
||||
]).slice(0, 2);
|
||||
|
||||
return {
|
||||
affinityGain: baseGain + bonusGain,
|
||||
matchedAttributes,
|
||||
summary: matchedAttributes.length > 0
|
||||
? `你这番说法更对上了对方在${matchedAttributes.join('、')}上的判断方式。`
|
||||
: '这轮交谈让对方更愿意继续观察你的来意。',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRecruitmentInsight(params: {
|
||||
encounter: Encounter;
|
||||
npcState: NpcPersistentState;
|
||||
playerCharacter: Character;
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}): RecruitReadinessInsight {
|
||||
const schemaContext = buildSchemaContext(
|
||||
params.worldType,
|
||||
params.customWorldProfile,
|
||||
params.encounter,
|
||||
);
|
||||
const playerProfile = resolveCharacterAttributeProfile(
|
||||
params.playerCharacter,
|
||||
schemaContext.worldType,
|
||||
schemaContext.customWorldProfile,
|
||||
);
|
||||
const npcProfile = resolveEncounterAttributeProfile(params.encounter, {
|
||||
worldType: schemaContext.worldType,
|
||||
customWorldProfile: schemaContext.customWorldProfile,
|
||||
});
|
||||
const preferenceVector = buildFocusVector(npcProfile, schemaContext.schema, 2);
|
||||
const tabooVector = buildFocusVector(npcProfile, schemaContext.schema, 1, 'bottom');
|
||||
const resonanceScore = scoreAttributeFit(playerProfile, preferenceVector, schemaContext.schema);
|
||||
const tabooScore = scoreAttributeFit(playerProfile, tabooVector, schemaContext.schema);
|
||||
const resonanceBonus = Math.max(0, Math.min(8, Math.round(resonanceScore * 24) - 2));
|
||||
const tabooPenalty = Math.max(0, Math.min(4, Math.round(tabooScore * 12) - 5));
|
||||
const matchedAttributes = getTopContributionAttributeNames(
|
||||
playerProfile,
|
||||
preferenceVector,
|
||||
schemaContext.schema,
|
||||
);
|
||||
const tabooAttributeName = getTopContributionAttributeNames(
|
||||
playerProfile,
|
||||
tabooVector,
|
||||
schemaContext.schema,
|
||||
1,
|
||||
)[0];
|
||||
|
||||
return {
|
||||
readinessScore: params.npcState.affinity + resonanceBonus - tabooPenalty,
|
||||
resonanceBonus,
|
||||
tabooPenalty,
|
||||
matchedAttributes,
|
||||
summary: matchedAttributes.length > 0
|
||||
? tabooPenalty > 0 && tabooAttributeName
|
||||
? `你在${matchedAttributes.join('、')}上的做派更容易被对方视为可同行,但${tabooAttributeName}这一路数仍让他保留几分。`
|
||||
: `你在${matchedAttributes.join('、')}上的做派,正是对方愿意并肩同行的那类信号。`
|
||||
: '对方主要还是在看你们当前积累下来的信任。',
|
||||
};
|
||||
}
|
||||
1811
src/data/npcInteractions.ts
Normal file
1811
src/data/npcInteractions.ts
Normal file
File diff suppressed because it is too large
Load Diff
34
src/data/npcLayoutConfig.json
Normal file
34
src/data/npcLayoutConfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"body": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"head": {
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"facialHair": {
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"hair": {
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"headgear": {
|
||||
"x": 0,
|
||||
"y": -3
|
||||
},
|
||||
"hand": {
|
||||
"x": -7,
|
||||
"y": 7
|
||||
},
|
||||
"mainHand": {
|
||||
"x": -8,
|
||||
"y": -10
|
||||
},
|
||||
"offHand": {
|
||||
"x": 5,
|
||||
"y": 8
|
||||
}
|
||||
}
|
||||
28
src/data/npcVisualOverrides.json
Normal file
28
src/data/npcVisualOverrides.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"wuxia-npc-gate-disciple": {
|
||||
"race": "human",
|
||||
"bodySrc": "/character/MedievalFantasyCharacters/sprites/Characters/body/body_blue.png",
|
||||
"headSrc": "/character/MedievalFantasyCharacters/sprites/Characters/human/head/human_head_skin_1.png",
|
||||
"hairSrc": "/character/MedievalFantasyCharacters/sprites/Characters/human/hair/hairstyle/human_hair_2.png",
|
||||
"handSrc": "/character/MedievalFantasyCharacters/sprites/Characters/human/hand/human_hand.png",
|
||||
"headgear": {
|
||||
"src": "/character/MedievalFantasyCharacters/sprites/wardrobe/metal/metal_blue.png",
|
||||
"frameIndex": 15,
|
||||
"columns": 10
|
||||
},
|
||||
"mainHand": {
|
||||
"src": "/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/polearm.png",
|
||||
"frameIndex": 8,
|
||||
"columns": 7
|
||||
},
|
||||
"offHand": {
|
||||
"src": "/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/shield.png",
|
||||
"frameIndex": 42,
|
||||
"columns": 7
|
||||
},
|
||||
"bodyFrames": [0, 1, 2, 3],
|
||||
"headFrame": 0,
|
||||
"hairFrame": 0,
|
||||
"handFrame": 0
|
||||
}
|
||||
}
|
||||
104
src/data/questFlow.test.ts
Normal file
104
src/data/questFlow.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import type {QuestLogEntry, QuestStep, ScenePresetInfo} from '../types';
|
||||
import {WorldType} from '../types';
|
||||
import {
|
||||
applyQuestProgressFromHostileNpcDefeat,
|
||||
applyQuestProgressFromNpcTalk,
|
||||
buildQuestForEncounter,
|
||||
isQuestReadyToClaim,
|
||||
normalizeQuestLogEntries,
|
||||
} from './questFlow';
|
||||
|
||||
const TEST_SCENE = {
|
||||
id: 'forest_path',
|
||||
name: 'Forest Path',
|
||||
description: 'A narrow trail with fresh claw marks.',
|
||||
monsterIds: ['wolf_alpha'],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'monsterIds' | 'npcs' | 'treasureHints'>;
|
||||
|
||||
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
|
||||
const step = quest.steps?.find(item => item.id === stepId);
|
||||
expect(step).toBeTruthy();
|
||||
return step!;
|
||||
}
|
||||
|
||||
describe('questFlow', () => {
|
||||
it('builds a staged quest contract for an encounter preview', () => {
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: 'npc_scout',
|
||||
issuerNpcName: 'Scout Lin',
|
||||
roleText: 'tracker',
|
||||
scene: TEST_SCENE,
|
||||
worldType: WorldType.WUXIA,
|
||||
currentQuests: [],
|
||||
});
|
||||
|
||||
expect(quest).toBeTruthy();
|
||||
expect(quest?.steps).toHaveLength(2);
|
||||
expect(quest?.objective.kind).toBe('defeat_hostile_npc');
|
||||
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
|
||||
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
|
||||
expect(quest?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('advances from primary objective to report-back step and then reward-ready', () => {
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: 'npc_scout',
|
||||
issuerNpcName: 'Scout Lin',
|
||||
roleText: 'tracker',
|
||||
scene: TEST_SCENE,
|
||||
worldType: WorldType.WUXIA,
|
||||
currentQuests: [],
|
||||
});
|
||||
expect(quest).toBeTruthy();
|
||||
|
||||
const afterBattle = applyQuestProgressFromHostileNpcDefeat(
|
||||
[quest!],
|
||||
TEST_SCENE.id,
|
||||
['wolf_alpha'],
|
||||
)[0];
|
||||
expect(afterBattle?.objective.kind).toBe('talk_to_npc');
|
||||
expect(afterBattle?.status).toBe('active');
|
||||
|
||||
const afterReport = applyQuestProgressFromNpcTalk([afterBattle!], 'npc_scout')[0];
|
||||
expect(afterReport?.status).toBe('ready_to_turn_in');
|
||||
expect(isQuestReadyToClaim(afterReport!)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes legacy single-objective quests into step-aware entries', () => {
|
||||
const normalized = normalizeQuestLogEntries([
|
||||
{
|
||||
id: 'legacy',
|
||||
issuerNpcId: 'npc_scout',
|
||||
issuerNpcName: 'Scout Lin',
|
||||
sceneId: TEST_SCENE.id,
|
||||
title: 'Legacy Quest',
|
||||
description: 'Legacy description',
|
||||
summary: 'Legacy summary',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
targetSceneId: TEST_SCENE.id,
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
completionNotified: false,
|
||||
reward: {
|
||||
affinityBonus: 10,
|
||||
currency: 20,
|
||||
items: [],
|
||||
},
|
||||
rewardText: 'Legacy reward text',
|
||||
},
|
||||
])[0];
|
||||
|
||||
expect(normalized?.steps).toHaveLength(1);
|
||||
expect(normalized?.steps?.[0]?.kind).toBe('inspect_treasure');
|
||||
expect(normalized?.status).toBe('completed');
|
||||
expect(normalized?.progress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
912
src/data/questFlow.ts
Normal file
912
src/data/questFlow.ts
Normal file
@@ -0,0 +1,912 @@
|
||||
import type {
|
||||
QuestCompilationRequest,
|
||||
QuestContract,
|
||||
QuestIntent,
|
||||
QuestOpportunity,
|
||||
QuestPreviewRequest,
|
||||
QuestProgressSignal,
|
||||
QuestSceneSnapshot,
|
||||
} from '../services/questTypes';
|
||||
import {
|
||||
type CustomWorldProfile,
|
||||
type InventoryItem,
|
||||
type QuestLogEntry,
|
||||
type QuestObjective,
|
||||
type QuestObjectiveKind,
|
||||
type QuestReward,
|
||||
type QuestStatus,
|
||||
type QuestStep,
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {formatCurrency} from './economy';
|
||||
import {getHostileNpcPresetById} from './hostileNpcPresets';
|
||||
import {getSceneHostileNpcs} from './scenePresets';
|
||||
|
||||
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
|
||||
const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired'];
|
||||
|
||||
type SceneQuestThreat =
|
||||
| {
|
||||
kind: 'defeat_hostile_npc';
|
||||
targetHostileNpcId: string;
|
||||
targetHostileNpcName: string;
|
||||
targetSceneId: string;
|
||||
suggestedThreatType: 'hostile_npc';
|
||||
}
|
||||
| {
|
||||
kind: 'inspect_treasure';
|
||||
targetSceneId: string;
|
||||
targetSceneName: string;
|
||||
suggestedThreatType: 'treasure';
|
||||
}
|
||||
| {
|
||||
kind: 'spar_with_npc';
|
||||
suggestedThreatType: 'relationship';
|
||||
};
|
||||
|
||||
function buildQuestItem(
|
||||
prefix: string,
|
||||
category: string,
|
||||
name: string,
|
||||
rarity: InventoryItem['rarity'],
|
||||
tags: string[],
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: `${prefix}:${encodeURIComponent(`${category}-${name}`)}`,
|
||||
category,
|
||||
name,
|
||||
quantity: 1,
|
||||
rarity,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestReward(params: {
|
||||
worldType: WorldType | null;
|
||||
roleText: string;
|
||||
rewardTheme: QuestIntent['rewardTheme'];
|
||||
narrativeType: QuestIntent['narrativeType'];
|
||||
scene: QuestSceneSnapshot | null;
|
||||
}): QuestReward {
|
||||
const {worldType, roleText, rewardTheme, narrativeType, scene} = params;
|
||||
const sharedRelic = worldType === 'XIANXIA'
|
||||
? buildQuestItem('quest', '稀有品', '灵纹符匣', 'rare', ['relic', 'mana'])
|
||||
: buildQuestItem('quest', '稀有品', '江湖悬赏令', 'rare', ['relic']);
|
||||
const roleItem = /猎|山|追踪/u.test(roleText)
|
||||
? buildQuestItem('quest', '消耗品', '追踪药包', 'uncommon', ['healing'])
|
||||
: /商|军需/u.test(roleText)
|
||||
? buildQuestItem('quest', '材料', '精制布包', 'uncommon', ['material'])
|
||||
: rewardTheme === 'resource'
|
||||
? buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])
|
||||
: buildQuestItem('quest', '消耗品', '回气散', 'uncommon', ['mana']);
|
||||
|
||||
const reward: QuestReward = {
|
||||
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
|
||||
currency: rewardTheme === 'intel'
|
||||
? (worldType === 'XIANXIA' ? 40 : 58)
|
||||
: (worldType === 'XIANXIA' ? 54 : 72),
|
||||
items: rewardTheme === 'resource'
|
||||
? [roleItem, buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])]
|
||||
: [sharedRelic, roleItem],
|
||||
};
|
||||
|
||||
if (rewardTheme === 'intel') {
|
||||
reward.intel = {
|
||||
rumorText: scene
|
||||
? `${scene.name} 附近还藏着一层没有完全揭开的线索。`
|
||||
: '对方愿意把一条尚未外传的消息托付给你。',
|
||||
unlockedSceneId: scene?.id,
|
||||
};
|
||||
}
|
||||
|
||||
return reward;
|
||||
}
|
||||
|
||||
function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
|
||||
const itemText = reward.items.map(item => item.name).join('、');
|
||||
const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}”` : '';
|
||||
return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`;
|
||||
}
|
||||
|
||||
function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey: string) {
|
||||
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
|
||||
}
|
||||
|
||||
function isRewardReadyStatus(status: QuestStatus) {
|
||||
return REWARD_READY_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: QuestStatus) {
|
||||
return TERMINAL_QUEST_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
function normalizeCount(rawCount: number | undefined) {
|
||||
return Math.max(1, Math.round(rawCount ?? 1));
|
||||
}
|
||||
|
||||
function clampProgress(progress: number | undefined, requiredCount: number) {
|
||||
return Math.max(0, Math.min(normalizeCount(requiredCount), Math.round(progress ?? 0)));
|
||||
}
|
||||
|
||||
function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: WorldType | null): SceneQuestThreat | null {
|
||||
if (!scene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNpc = getSceneHostileNpcs(scene)[0] ?? null;
|
||||
if (hostileNpc) {
|
||||
const targetHostileNpcId = hostileNpc.hostileNpcPresetId ?? hostileNpc.monsterPresetId ?? hostileNpc.id;
|
||||
const targetHostileNpcName = worldType
|
||||
? getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ?? hostileNpc.name ?? targetHostileNpcId
|
||||
: hostileNpc.name ?? targetHostileNpcId;
|
||||
|
||||
return {
|
||||
kind: 'defeat_hostile_npc',
|
||||
targetHostileNpcId,
|
||||
targetHostileNpcName,
|
||||
targetSceneId: scene.id,
|
||||
suggestedThreatType: 'hostile_npc',
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackHostileNpcId = scene.hostileNpcIds?.[0] ?? scene.monsterIds?.[0];
|
||||
if (fallbackHostileNpcId) {
|
||||
return {
|
||||
kind: 'defeat_hostile_npc',
|
||||
targetHostileNpcId: fallbackHostileNpcId,
|
||||
targetHostileNpcName: worldType
|
||||
? getHostileNpcPresetById(worldType, fallbackHostileNpcId)?.name ?? fallbackHostileNpcId
|
||||
: fallbackHostileNpcId,
|
||||
targetSceneId: scene.id,
|
||||
suggestedThreatType: 'hostile_npc',
|
||||
};
|
||||
}
|
||||
|
||||
if ((scene.treasureHints?.length ?? 0) > 0) {
|
||||
return {
|
||||
kind: 'inspect_treasure',
|
||||
targetSceneId: scene.id,
|
||||
targetSceneName: scene.name,
|
||||
suggestedThreatType: 'treasure',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'spar_with_npc',
|
||||
suggestedThreatType: 'relationship',
|
||||
};
|
||||
}
|
||||
|
||||
function buildStepRevealText(step: QuestStep, issuerNpcName: string, targetLabel: string) {
|
||||
switch (step.kind) {
|
||||
case 'defeat_hostile_npc':
|
||||
return `${issuerNpcName} 希望你先压制 ${targetLabel},再回来说明局势。`;
|
||||
case 'inspect_treasure':
|
||||
return `${issuerNpcName} 想确认 ${targetLabel} 背后的异常究竟是真是假。`;
|
||||
case 'spar_with_npc':
|
||||
return `${issuerNpcName} 想先亲自试试你的实力,再决定后续是否继续合作。`;
|
||||
case 'talk_to_npc':
|
||||
return `回去和 ${issuerNpcName} 对话,把这次委托的结果说明白。`;
|
||||
case 'reach_scene':
|
||||
return `先抵达 ${targetLabel},看清楚当前局势。`;
|
||||
case 'deliver_item':
|
||||
return `把目标物品交到 ${targetLabel} 手上。`;
|
||||
default:
|
||||
return `${issuerNpcName} 还在等待这份委托的新进展。`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildStepCompleteText(step: QuestStep, issuerNpcName: string, targetLabel: string) {
|
||||
switch (step.kind) {
|
||||
case 'defeat_hostile_npc':
|
||||
return `${targetLabel} 已被压制,回去向 ${issuerNpcName} 汇报吧。`;
|
||||
case 'inspect_treasure':
|
||||
return `${targetLabel} 的情况已经查明,可以回去和 ${issuerNpcName} 对情报了。`;
|
||||
case 'spar_with_npc':
|
||||
return `这场切磋已经结束,${issuerNpcName} 对你的判断也有了变化。`;
|
||||
case 'talk_to_npc':
|
||||
return `你已经和 ${issuerNpcName} 交代清楚,现在可以正式领取报酬。`;
|
||||
case 'reach_scene':
|
||||
return `你已经抵达 ${targetLabel},可以继续推进下一步。`;
|
||||
case 'deliver_item':
|
||||
return `${targetLabel} 已经收到了你送去的物品。`;
|
||||
default:
|
||||
return `${issuerNpcName} 已经确认这一步骤完成。`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrimaryQuestStep(params: {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
worldType: WorldType | null;
|
||||
intent: QuestIntent;
|
||||
}): QuestStep | null {
|
||||
const {issuerNpcId, issuerNpcName, scene, worldType, intent} = params;
|
||||
const threat = getScenePrimaryThreat(scene, worldType);
|
||||
if (!threat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredKinds = intent.recommendedObjectiveKinds.length > 0
|
||||
? intent.recommendedObjectiveKinds
|
||||
: [threat.kind];
|
||||
const chosenKind = preferredKinds.includes(threat.kind) ? threat.kind : preferredKinds[0] ?? threat.kind;
|
||||
|
||||
if (chosenKind === 'inspect_treasure' && threat.kind === 'inspect_treasure' && scene) {
|
||||
const title = `调查 ${scene.name} 的异常`;
|
||||
return {
|
||||
id: 'step_primary',
|
||||
kind: 'inspect_treasure',
|
||||
targetSceneId: scene.id,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: buildStepRevealText({
|
||||
id: 'step_primary',
|
||||
kind: 'inspect_treasure',
|
||||
targetSceneId: scene.id,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, scene.name),
|
||||
completeText: buildStepCompleteText({
|
||||
id: 'step_primary',
|
||||
kind: 'inspect_treasure',
|
||||
targetSceneId: scene.id,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, scene.name),
|
||||
};
|
||||
}
|
||||
|
||||
if (chosenKind === 'spar_with_npc') {
|
||||
const title = `与 ${issuerNpcName} 切磋`;
|
||||
return {
|
||||
id: 'step_primary',
|
||||
kind: 'spar_with_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: buildStepRevealText({
|
||||
id: 'step_primary',
|
||||
kind: 'spar_with_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, issuerNpcName),
|
||||
completeText: buildStepCompleteText({
|
||||
id: 'step_primary',
|
||||
kind: 'spar_with_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, issuerNpcName),
|
||||
};
|
||||
}
|
||||
|
||||
if (threat.kind === 'defeat_hostile_npc') {
|
||||
const hostileNpcName = threat.targetHostileNpcName;
|
||||
const title = `压制 ${hostileNpcName}`;
|
||||
return {
|
||||
id: 'step_primary',
|
||||
kind: 'defeat_hostile_npc',
|
||||
targetHostileNpcId: threat.targetHostileNpcId,
|
||||
targetSceneId: threat.targetSceneId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: buildStepRevealText({
|
||||
id: 'step_primary',
|
||||
kind: 'defeat_hostile_npc',
|
||||
targetHostileNpcId: threat.targetHostileNpcId,
|
||||
targetSceneId: threat.targetSceneId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, hostileNpcName),
|
||||
completeText: buildStepCompleteText({
|
||||
id: 'step_primary',
|
||||
kind: 'defeat_hostile_npc',
|
||||
targetHostileNpcId: threat.targetHostileNpcId,
|
||||
targetSceneId: threat.targetSceneId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, hostileNpcName),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTalkBackStep(issuerNpcId: string, issuerNpcName: string): QuestStep {
|
||||
const title = `返回与 ${issuerNpcName} 交谈`;
|
||||
return {
|
||||
id: 'step_report_back',
|
||||
kind: 'talk_to_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: buildStepRevealText({
|
||||
id: 'step_report_back',
|
||||
kind: 'talk_to_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, issuerNpcName),
|
||||
completeText: buildStepCompleteText({
|
||||
id: 'step_report_back',
|
||||
kind: 'talk_to_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
title,
|
||||
revealText: '',
|
||||
completeText: '',
|
||||
}, issuerNpcName, issuerNpcName),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveObjectiveFromStep(step: QuestStep | null, issuerNpcId: string): QuestObjective {
|
||||
if (!step) {
|
||||
return {
|
||||
kind: 'talk_to_npc',
|
||||
targetNpcId: issuerNpcId,
|
||||
requiredCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: step.kind,
|
||||
targetHostileNpcId: step.targetHostileNpcId,
|
||||
targetNpcId: step.targetNpcId,
|
||||
targetSceneId: step.targetSceneId,
|
||||
targetItemId: step.targetItemId,
|
||||
requiredCount: normalizeCount(step.requiredCount),
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyStepFromQuest(quest: QuestLogEntry): QuestStep {
|
||||
const requiredCount = normalizeCount(quest.objective.requiredCount);
|
||||
const progress = isRewardReadyStatus(quest.status) || quest.status === 'turned_in'
|
||||
? requiredCount
|
||||
: clampProgress(quest.progress, requiredCount);
|
||||
|
||||
return {
|
||||
id: 'step_legacy_primary',
|
||||
kind: quest.objective.kind,
|
||||
targetHostileNpcId: quest.objective.targetHostileNpcId,
|
||||
targetNpcId: quest.objective.targetNpcId,
|
||||
targetSceneId: quest.objective.targetSceneId,
|
||||
targetItemId: quest.objective.targetItemId,
|
||||
requiredCount,
|
||||
progress,
|
||||
title: quest.summary || quest.title,
|
||||
revealText: quest.description,
|
||||
completeText: quest.rewardText,
|
||||
};
|
||||
}
|
||||
|
||||
export function getQuestActiveStep(
|
||||
quest: Pick<QuestLogEntry, 'steps' | 'activeStepId' | 'status'>,
|
||||
) {
|
||||
if (!quest.steps?.length || isTerminalStatus(quest.status) || isRewardReadyStatus(quest.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitStep = quest.activeStepId
|
||||
? quest.steps.find(step => step.id === quest.activeStepId && step.progress < step.requiredCount) ?? null
|
||||
: null;
|
||||
if (explicitStep) {
|
||||
return explicitStep;
|
||||
}
|
||||
|
||||
return quest.steps.find(step => step.progress < step.requiredCount) ?? null;
|
||||
}
|
||||
|
||||
function buildQuestStageSummary(quest: Pick<QuestLogEntry, 'issuerNpcName' | 'steps' | 'activeStepId' | 'status' | 'title'>) {
|
||||
const activeStep = getQuestActiveStep(quest);
|
||||
if (activeStep) {
|
||||
return activeStep.title;
|
||||
}
|
||||
if (isRewardReadyStatus(quest.status)) {
|
||||
return `返回向 ${quest.issuerNpcName} 领取报酬`;
|
||||
}
|
||||
if (quest.status === 'turned_in') {
|
||||
return `${quest.title} 已完成交付`;
|
||||
}
|
||||
return quest.title;
|
||||
}
|
||||
|
||||
export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
|
||||
const steps = (quest.steps?.length ? quest.steps : [createLegacyStepFromQuest(quest)]).map(step => {
|
||||
const requiredCount = normalizeCount(step.requiredCount);
|
||||
return {
|
||||
...step,
|
||||
requiredCount,
|
||||
progress: clampProgress(step.progress, requiredCount),
|
||||
title: step.title?.trim() || quest.summary || quest.title,
|
||||
revealText: step.revealText?.trim() || quest.description,
|
||||
completeText: step.completeText?.trim() || quest.rewardText,
|
||||
} satisfies QuestStep;
|
||||
});
|
||||
|
||||
const incompleteStep = steps.find(step => step.progress < step.requiredCount) ?? null;
|
||||
const activeStepId = incompleteStep?.id ?? null;
|
||||
let status = quest.status ?? 'active';
|
||||
|
||||
if (!isTerminalStatus(status)) {
|
||||
if (!incompleteStep) {
|
||||
status = status === 'completed' ? 'completed' : 'ready_to_turn_in';
|
||||
} else if (status !== 'discovered') {
|
||||
status = 'active';
|
||||
}
|
||||
}
|
||||
|
||||
const objectiveSource = incompleteStep ?? steps[steps.length - 1] ?? null;
|
||||
const objective = deriveObjectiveFromStep(objectiveSource, quest.issuerNpcId);
|
||||
const progress = objectiveSource
|
||||
? clampProgress(objectiveSource.progress, objectiveSource.requiredCount)
|
||||
: normalizeCount(objective.requiredCount);
|
||||
|
||||
const normalizedQuest: QuestLogEntry = {
|
||||
...quest,
|
||||
objective,
|
||||
progress,
|
||||
status,
|
||||
steps,
|
||||
activeStepId,
|
||||
};
|
||||
|
||||
return {
|
||||
...normalizedQuest,
|
||||
summary: buildQuestStageSummary(normalizedQuest),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQuestLogEntries(quests: QuestLogEntry[]) {
|
||||
return quests.map(quest => normalizeQuestLogEntry(quest));
|
||||
}
|
||||
|
||||
function withNormalizedQuest(quest: QuestLogEntry) {
|
||||
return normalizeQuestLogEntry(quest);
|
||||
}
|
||||
|
||||
function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) {
|
||||
switch (signal.kind) {
|
||||
case 'hostile_npc_defeated':
|
||||
return step.kind === 'defeat_hostile_npc'
|
||||
&& (!step.targetSceneId || step.targetSceneId === signal.sceneId)
|
||||
&& step.targetHostileNpcId === signal.hostileNpcId;
|
||||
case 'treasure_inspected':
|
||||
return step.kind === 'inspect_treasure'
|
||||
&& (!step.targetSceneId || step.targetSceneId === signal.sceneId);
|
||||
case 'npc_spar_completed':
|
||||
return step.kind === 'spar_with_npc' && step.targetNpcId === signal.npcId;
|
||||
case 'npc_talk_completed':
|
||||
return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId;
|
||||
case 'scene_reached':
|
||||
return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId;
|
||||
case 'item_delivered':
|
||||
return step.kind === 'deliver_item'
|
||||
&& step.targetNpcId === signal.npcId
|
||||
&& step.targetItemId === signal.itemId;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSignalProgressIncrement(signal: QuestProgressSignal) {
|
||||
return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1;
|
||||
}
|
||||
|
||||
function applyQuestProgressSignalToQuest(quest: QuestLogEntry, signal: QuestProgressSignal) {
|
||||
const normalizedQuest = withNormalizedQuest(quest);
|
||||
if (isTerminalStatus(normalizedQuest.status) || isRewardReadyStatus(normalizedQuest.status)) {
|
||||
return normalizedQuest;
|
||||
}
|
||||
|
||||
const activeStep = getQuestActiveStep(normalizedQuest);
|
||||
if (!activeStep || !stepMatchesSignal(activeStep, signal)) {
|
||||
return normalizedQuest;
|
||||
}
|
||||
|
||||
const increment = getSignalProgressIncrement(signal);
|
||||
const nextSteps = normalizedQuest.steps!.map(step => {
|
||||
if (step.id !== activeStep.id) {
|
||||
return step;
|
||||
}
|
||||
return {
|
||||
...step,
|
||||
progress: Math.min(step.requiredCount, step.progress + increment),
|
||||
};
|
||||
});
|
||||
|
||||
return normalizeQuestLogEntry({
|
||||
...normalizedQuest,
|
||||
steps: nextSteps,
|
||||
completionNotified: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyQuestProgressSignal(quests: QuestLogEntry[], signal: QuestProgressSignal) {
|
||||
return quests.map(quest => applyQuestProgressSignalToQuest(quest, signal));
|
||||
}
|
||||
|
||||
function resolveQuestIdTargetKey(primaryStep: QuestStep, scene: QuestSceneSnapshot | null) {
|
||||
return primaryStep.targetHostileNpcId
|
||||
?? primaryStep.targetNpcId
|
||||
?? primaryStep.targetSceneId
|
||||
?? scene?.id
|
||||
?? primaryStep.id;
|
||||
}
|
||||
|
||||
export function findQuestById(quests: QuestLogEntry[], questId: string) {
|
||||
return quests.find(quest => quest.id === questId) ?? null;
|
||||
}
|
||||
|
||||
export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) {
|
||||
return quests.find(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in') ?? null;
|
||||
}
|
||||
|
||||
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity {
|
||||
const {issuerNpcId, scene, currentQuests = []} = params;
|
||||
if (!scene) {
|
||||
return {
|
||||
shouldOffer: false,
|
||||
reason: '当前缺少可落地的场景信息,暂时不适合生成委托。',
|
||||
};
|
||||
}
|
||||
|
||||
if (currentQuests.some(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in')) {
|
||||
return {
|
||||
shouldOffer: false,
|
||||
reason: '这名角色还有尚未结清的委托。',
|
||||
suggestedIssuerNpcId: issuerNpcId,
|
||||
};
|
||||
}
|
||||
|
||||
const liveQuestCount = currentQuests.filter(quest => !isTerminalStatus(quest.status)).length;
|
||||
if (liveQuestCount >= 4) {
|
||||
return {
|
||||
shouldOffer: false,
|
||||
reason: '当前未完成委托已经偏多,不再继续塞入新的任务机会。',
|
||||
suggestedIssuerNpcId: issuerNpcId,
|
||||
};
|
||||
}
|
||||
|
||||
const threat = getScenePrimaryThreat(scene, params.worldType);
|
||||
if (!threat) {
|
||||
return {
|
||||
shouldOffer: false,
|
||||
reason: '当前场景里缺少足够明确的任务抓手。',
|
||||
suggestedIssuerNpcId: issuerNpcId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldOffer: true,
|
||||
reason: threat.kind === 'inspect_treasure'
|
||||
? `${scene.name} 附近出现了值得调查的异常。`
|
||||
: threat.kind === 'spar_with_npc'
|
||||
? `${params.issuerNpcName} 更适合给出一份关系驱动的试炼型委托。`
|
||||
: `${scene.name} 附近存在可以被明确指向的敌对角色威胁。`,
|
||||
suggestedIssuerNpcId: issuerNpcId,
|
||||
suggestedThreatType: threat.suggestedThreatType,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFallbackQuestIntent(params: QuestCompilationRequest): QuestIntent {
|
||||
const {issuerNpcName, scene} = params;
|
||||
const threat = getScenePrimaryThreat(scene, params.worldType);
|
||||
|
||||
if (threat?.kind === 'defeat_hostile_npc') {
|
||||
const hostileNpcName = threat.targetHostileNpcName;
|
||||
return {
|
||||
title: `压制 ${hostileNpcName}`,
|
||||
description: `${issuerNpcName} 希望你先处理掉 ${scene?.name ?? '前方区域'} 徘徊的 ${hostileNpcName},再回来交换后续情报。`,
|
||||
summary: `击退 ${hostileNpcName},然后回去和 ${issuerNpcName} 交谈`,
|
||||
narrativeType: 'bounty',
|
||||
dramaticNeed: `${scene?.name ?? '前方区域'} 的危险已经影响到 ${issuerNpcName} 的下一步行动。`,
|
||||
issuerGoal: `先压下 ${hostileNpcName} 带来的威胁,再确认局势是否稳定。`,
|
||||
playerHook: '你正好位于现场,也最适合先去验证这一层风险。',
|
||||
worldReason: `${scene?.name ?? '这一区域'} 的局势还没有真正安定下来。`,
|
||||
recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'],
|
||||
urgency: 'medium',
|
||||
intimacy: 'cooperative',
|
||||
rewardTheme: 'resource',
|
||||
followupHooks: [
|
||||
`${issuerNpcName} 手里还握着没完全说开的后续线索。`,
|
||||
'这份委托背后还有更深一层的局势变化。',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (threat?.kind === 'inspect_treasure' && scene) {
|
||||
return {
|
||||
title: `探明 ${scene.name} 的异常`,
|
||||
description: `${issuerNpcName} 不确定 ${scene.name} 一带出现的异动是真是假,想让你先去看清楚,再回来对一遍情报。`,
|
||||
summary: `调查 ${scene.name} 的异常,然后回去向 ${issuerNpcName} 汇报`,
|
||||
narrativeType: 'investigation',
|
||||
dramaticNeed: `${issuerNpcName} 想知道这条线索值不值得继续深挖。`,
|
||||
issuerGoal: `确认 ${scene.name} 一带究竟藏着什么。`,
|
||||
playerHook: '你已经身在局中,最适合把这层异常先摸清。',
|
||||
worldReason: `${scene.name} 周围留下了还没有被说清的痕迹。`,
|
||||
recommendedObjectiveKinds: ['inspect_treasure', 'talk_to_npc'],
|
||||
urgency: 'medium',
|
||||
intimacy: 'cooperative',
|
||||
rewardTheme: 'intel',
|
||||
followupHooks: [
|
||||
`${scene.name} 的异常可能还连着另一处更深的地点。`,
|
||||
`${issuerNpcName} 对这里并不是完全陌生。`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `与 ${issuerNpcName} 过几招`,
|
||||
description: `${issuerNpcName} 想先亲自试一试你的成色,再决定要不要把更关键的事继续交给你。`,
|
||||
summary: `和 ${issuerNpcName} 切磋一场,然后回来把话说透`,
|
||||
narrativeType: 'trial',
|
||||
dramaticNeed: `${issuerNpcName} 还没完全确认你值不值得信任。`,
|
||||
issuerGoal: '通过切磋判断你的实力和态度。',
|
||||
playerHook: '你只需要接住这场试探,就能让关系往前推一步。',
|
||||
worldReason: '在这种局势里,口头承诺往往不如当面试一试来得直接。',
|
||||
recommendedObjectiveKinds: ['spar_with_npc', 'talk_to_npc'],
|
||||
urgency: 'low',
|
||||
intimacy: 'trust_based',
|
||||
rewardTheme: 'relationship',
|
||||
followupHooks: [
|
||||
`${issuerNpcName} 会根据这次试探重新判断和你的距离。`,
|
||||
'这次切磋很可能会牵出下一轮更正式的合作。',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function compileQuestIntentToQuest(
|
||||
params: QuestCompilationRequest,
|
||||
intent: QuestIntent,
|
||||
): QuestLogEntry | null {
|
||||
const primaryStep = buildPrimaryQuestStep({
|
||||
issuerNpcId: params.issuerNpcId,
|
||||
issuerNpcName: params.issuerNpcName,
|
||||
scene: params.scene,
|
||||
worldType: params.worldType,
|
||||
intent,
|
||||
});
|
||||
if (!primaryStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = [primaryStep, buildTalkBackStep(params.issuerNpcId, params.issuerNpcName)];
|
||||
const reward = buildQuestReward({
|
||||
worldType: params.worldType,
|
||||
roleText: params.roleText,
|
||||
rewardTheme: intent.rewardTheme,
|
||||
narrativeType: intent.narrativeType,
|
||||
scene: params.scene,
|
||||
});
|
||||
const rewardText = buildRewardText(reward, params.worldType);
|
||||
const contract: QuestContract = {
|
||||
id: buildQuestId(params.issuerNpcId, primaryStep.kind, resolveQuestIdTargetKey(primaryStep, params.scene)),
|
||||
issuerNpcId: params.issuerNpcId,
|
||||
issuerNpcName: params.issuerNpcName,
|
||||
sceneId: params.scene?.id ?? null,
|
||||
questArchetype: intent.narrativeType,
|
||||
title: intent.title.trim() || buildFallbackQuestIntent(params).title,
|
||||
description: intent.description.trim() || buildFallbackQuestIntent(params).description,
|
||||
summary: intent.summary.trim() || buildFallbackQuestIntent(params).summary,
|
||||
steps,
|
||||
reward,
|
||||
rewardText,
|
||||
narrativeBinding: {
|
||||
origin: params.origin ?? 'fallback_builder',
|
||||
narrativeType: intent.narrativeType,
|
||||
dramaticNeed: intent.dramaticNeed,
|
||||
issuerGoal: intent.issuerGoal,
|
||||
playerHook: intent.playerHook,
|
||||
worldReason: intent.worldReason,
|
||||
followupHooks: intent.followupHooks,
|
||||
},
|
||||
failPolicy: 'never',
|
||||
};
|
||||
|
||||
return normalizeQuestLogEntry({
|
||||
id: contract.id,
|
||||
issuerNpcId: contract.issuerNpcId,
|
||||
issuerNpcName: contract.issuerNpcName,
|
||||
sceneId: contract.sceneId,
|
||||
title: contract.title,
|
||||
description: contract.description,
|
||||
summary: contract.summary,
|
||||
objective: deriveObjectiveFromStep(contract.steps[0] ?? null, contract.issuerNpcId),
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
completionNotified: false,
|
||||
reward: contract.reward,
|
||||
rewardText: contract.rewardText,
|
||||
narrativeBinding: contract.narrativeBinding,
|
||||
steps: contract.steps,
|
||||
activeStepId: contract.steps[0]?.id ?? null,
|
||||
visibleStage: 0,
|
||||
hiddenFlags: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildQuestForEncounter(params: QuestPreviewRequest): QuestLogEntry | null {
|
||||
const opportunity = evaluateQuestOpportunity(params);
|
||||
if (!opportunity.shouldOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...params,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
buildFallbackQuestIntent(params),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildQuestAcceptDetail(quest: QuestLogEntry) {
|
||||
const normalizedQuest = withNormalizedQuest(quest);
|
||||
const activeStep = getQuestActiveStep(normalizedQuest);
|
||||
return activeStep
|
||||
? `${activeStep.revealText} ${normalizedQuest.rewardText}`
|
||||
: `${normalizedQuest.summary} ${normalizedQuest.rewardText}`;
|
||||
}
|
||||
|
||||
export function buildQuestTurnInDetail(quest: QuestLogEntry) {
|
||||
return `这份委托已经可以结算,去和 ${quest.issuerNpcName} 把结果说清楚吧。${quest.rewardText}`;
|
||||
}
|
||||
|
||||
export function buildQuestAcceptResultText(quest: QuestLogEntry) {
|
||||
const activeStep = getQuestActiveStep(quest);
|
||||
return `${quest.issuerNpcName} 正式把委托交到了你手上。${activeStep?.revealText ?? quest.summary}`;
|
||||
}
|
||||
|
||||
export function buildQuestTurnInResultText(quest: QuestLogEntry) {
|
||||
const itemText = quest.reward.items.map(item => item.name).join('、');
|
||||
const intelText = quest.reward.intel?.rumorText
|
||||
? `,并额外告诉了你一条消息:${quest.reward.intel.rumorText}`
|
||||
: '';
|
||||
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}。`;
|
||||
}
|
||||
|
||||
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
|
||||
if (findQuestById(quests, quest.id)) {
|
||||
return quests.map(item => withNormalizedQuest(item));
|
||||
}
|
||||
return [...quests.map(item => withNormalizedQuest(item)), withNormalizedQuest(quest)];
|
||||
}
|
||||
|
||||
export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) {
|
||||
return quests.map(quest =>
|
||||
quest.id === questId
|
||||
? withNormalizedQuest({
|
||||
...quest,
|
||||
status: 'turned_in',
|
||||
completionNotified: true,
|
||||
steps: quest.steps?.map(step => ({
|
||||
...step,
|
||||
progress: step.requiredCount,
|
||||
})),
|
||||
})
|
||||
: withNormalizedQuest(quest),
|
||||
);
|
||||
}
|
||||
|
||||
export function markQuestCompletionNotified(quests: QuestLogEntry[], questId: string) {
|
||||
return quests.map(quest =>
|
||||
quest.id === questId
|
||||
? withNormalizedQuest({
|
||||
...quest,
|
||||
completionNotified: true,
|
||||
})
|
||||
: withNormalizedQuest(quest),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyQuestProgressFromHostileNpcDefeat(
|
||||
quests: QuestLogEntry[],
|
||||
sceneId: string | null,
|
||||
defeatedHostileNpcIds: string[],
|
||||
) {
|
||||
return defeatedHostileNpcIds.reduce(
|
||||
(currentQuests, hostileNpcId) => applyQuestProgressSignal(currentQuests, {
|
||||
kind: 'hostile_npc_defeated',
|
||||
sceneId,
|
||||
hostileNpcId,
|
||||
}),
|
||||
quests.map(quest => withNormalizedQuest(quest)),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyQuestProgressFromTreasure(
|
||||
quests: QuestLogEntry[],
|
||||
sceneId: string | null,
|
||||
) {
|
||||
if (!sceneId) {
|
||||
return quests.map(quest => withNormalizedQuest(quest));
|
||||
}
|
||||
|
||||
return applyQuestProgressSignal(quests, {
|
||||
kind: 'treasure_inspected',
|
||||
sceneId,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyQuestProgressFromSpar(
|
||||
quests: QuestLogEntry[],
|
||||
npcId: string | null,
|
||||
) {
|
||||
if (!npcId) {
|
||||
return quests.map(quest => withNormalizedQuest(quest));
|
||||
}
|
||||
|
||||
return applyQuestProgressSignal(quests, {
|
||||
kind: 'npc_spar_completed',
|
||||
npcId,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyQuestProgressFromNpcTalk(
|
||||
quests: QuestLogEntry[],
|
||||
npcId: string | null,
|
||||
) {
|
||||
if (!npcId) {
|
||||
return quests.map(quest => withNormalizedQuest(quest));
|
||||
}
|
||||
|
||||
return applyQuestProgressSignal(quests, {
|
||||
kind: 'npc_talk_completed',
|
||||
npcId,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyQuestProgressFromSceneReached(
|
||||
quests: QuestLogEntry[],
|
||||
sceneId: string | null,
|
||||
) {
|
||||
if (!sceneId) {
|
||||
return quests.map(quest => withNormalizedQuest(quest));
|
||||
}
|
||||
|
||||
return applyQuestProgressSignal(quests, {
|
||||
kind: 'scene_reached',
|
||||
sceneId,
|
||||
});
|
||||
}
|
||||
|
||||
export function isQuestReadyToClaim(quest: QuestLogEntry) {
|
||||
return isRewardReadyStatus(withNormalizedQuest(quest).status);
|
||||
}
|
||||
|
||||
export function buildQuestGenerationSummary(customWorldProfile: CustomWorldProfile | null | undefined) {
|
||||
if (!customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${customWorldProfile.name}: ${customWorldProfile.summary}`;
|
||||
}
|
||||
277
src/data/runtimeItemCompiler.ts
Normal file
277
src/data/runtimeItemCompiler.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
InventoryItem,
|
||||
ItemBuildProfile,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemCompileBudget,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
TimedBuildBuff,
|
||||
} from '../types';
|
||||
import {normalizeBuildRole, normalizeBuildTags} from './buildTags';
|
||||
|
||||
const RARITY_ORDER: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
||||
|
||||
function clampIndex(index: number) {
|
||||
return Math.max(0, Math.min(RARITY_ORDER.length - 1, index));
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) - hash) + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function buildStructuralTags(plan: RuntimeItemPlan) {
|
||||
switch (plan.itemKind) {
|
||||
case 'equipment':
|
||||
return ['weapon'];
|
||||
case 'relic':
|
||||
return ['relic'];
|
||||
case 'material':
|
||||
return ['material'];
|
||||
case 'consumable':
|
||||
return ['consumable'];
|
||||
case 'quest':
|
||||
return ['relic'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEquipmentSlotId(plan: RuntimeItemPlan) {
|
||||
if (plan.itemKind === 'equipment') {
|
||||
if (plan.targetBuildDirection.includes('守御') || plan.targetBuildDirection.includes('护体')) {
|
||||
return 'armor' as const;
|
||||
}
|
||||
return 'weapon' as const;
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'relic' || plan.itemKind === 'quest') {
|
||||
return 'relic' as const;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCategory(plan: RuntimeItemPlan) {
|
||||
switch (plan.itemKind) {
|
||||
case 'equipment':
|
||||
return resolveEquipmentSlotId(plan) === 'armor' ? '护甲' : '武器';
|
||||
case 'consumable':
|
||||
return '消耗品';
|
||||
case 'material':
|
||||
return '材料';
|
||||
case 'quest':
|
||||
return '专属物';
|
||||
default:
|
||||
return '稀有品';
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuntimeBudget(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
seedKey: string,
|
||||
): RuntimeItemCompileBudget {
|
||||
const seed = hashText(`${context.generationChannel}:${seedKey}:${plan.slot}:${plan.itemKind}`);
|
||||
const channelBaseIndex = context.generationChannel === 'quest_reward'
|
||||
? 2
|
||||
: context.generationChannel === 'treasure'
|
||||
? 1
|
||||
: context.generationChannel === 'monster_drop'
|
||||
? 0
|
||||
: 1;
|
||||
const slotBonus = plan.slot === 'primary' ? 1 : 0;
|
||||
const permanenceBonus = plan.permanence === 'permanent' ? 1 : 0;
|
||||
const rarityIndex = clampIndex(channelBaseIndex + slotBonus + permanenceBonus + (seed % 2));
|
||||
const rarity = RARITY_ORDER[rarityIndex] ?? 'common';
|
||||
|
||||
return {
|
||||
rarity,
|
||||
buildTagLimit: rarity === 'legendary' ? 2 : rarity === 'epic' || rarity === 'rare' ? 2 : 1,
|
||||
timedBuffTagLimit: rarity === 'legendary' ? 3 : rarity === 'epic' || rarity === 'rare' ? 2 : 1,
|
||||
timedBuffDuration: rarity === 'legendary' ? 3 : rarity === 'epic' ? 3 : 2,
|
||||
statBudgetTier: rarityIndex + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimedBuffs(
|
||||
itemId: string,
|
||||
intent: RuntimeItemAiIntent,
|
||||
budget: RuntimeItemCompileBudget,
|
||||
): TimedBuildBuff[] {
|
||||
const tags = normalizeBuildTags(intent.desiredBuildTags, budget.timedBuffTagLimit);
|
||||
if (tags.length <= 0) return [];
|
||||
|
||||
return [{
|
||||
id: `${itemId}:buff`,
|
||||
sourceType: 'item',
|
||||
sourceId: itemId,
|
||||
name: `${tags[0]}增益`,
|
||||
tags,
|
||||
durationTurns: budget.timedBuffDuration,
|
||||
}];
|
||||
}
|
||||
|
||||
function buildUseProfile(
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
budget: RuntimeItemCompileBudget,
|
||||
itemId: string,
|
||||
): ItemUseProfile | null {
|
||||
if (plan.itemKind !== 'consumable' && plan.permanence === 'permanent') return null;
|
||||
|
||||
const useProfile: ItemUseProfile = {};
|
||||
|
||||
if (intent.desiredFunctionalBias.includes('heal')) {
|
||||
useProfile.hpRestore = 16 + budget.statBudgetTier * 10;
|
||||
}
|
||||
if (intent.desiredFunctionalBias.includes('mana')) {
|
||||
useProfile.manaRestore = 14 + budget.statBudgetTier * 8;
|
||||
}
|
||||
if (intent.desiredFunctionalBias.includes('cooldown')) {
|
||||
useProfile.cooldownReduction = budget.rarity === 'legendary' || budget.rarity === 'epic' ? 1 : 0;
|
||||
}
|
||||
if (plan.permanence === 'timed' || plan.itemKind === 'consumable') {
|
||||
useProfile.buildBuffs = buildTimedBuffs(itemId, intent, budget);
|
||||
}
|
||||
|
||||
if (
|
||||
(useProfile.hpRestore ?? 0) <= 0
|
||||
&& (useProfile.manaRestore ?? 0) <= 0
|
||||
&& (useProfile.cooldownReduction ?? 0) <= 0
|
||||
&& (useProfile.buildBuffs?.length ?? 0) <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return useProfile;
|
||||
}
|
||||
|
||||
function buildStatProfile(
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
budget: RuntimeItemCompileBudget,
|
||||
): ItemStatProfile | null {
|
||||
if (plan.itemKind === 'material') return null;
|
||||
|
||||
const statProfile: ItemStatProfile = {};
|
||||
const tier = budget.statBudgetTier;
|
||||
|
||||
if (plan.itemKind === 'equipment' || plan.itemKind === 'relic' || plan.itemKind === 'quest') {
|
||||
if (intent.desiredFunctionalBias.includes('guard') || plan.targetBuildDirection.includes('守御')) {
|
||||
statProfile.maxHpBonus = 6 * tier + (plan.itemKind === 'equipment' ? 10 : 4);
|
||||
statProfile.incomingDamageMultiplier = Number(Math.max(0.84, 1 - tier * 0.03).toFixed(2));
|
||||
}
|
||||
if (intent.desiredFunctionalBias.includes('mana') || plan.targetBuildDirection.includes('法力')) {
|
||||
statProfile.maxManaBonus = 8 * tier + (plan.itemKind === 'relic' ? 10 : 0);
|
||||
}
|
||||
if (intent.desiredFunctionalBias.includes('damage') || plan.targetBuildDirection.length > 0) {
|
||||
statProfile.outgoingDamageBonus = Number((0.03 * tier).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(statProfile).length <= 0) return null;
|
||||
return statProfile;
|
||||
}
|
||||
|
||||
function buildProfile(
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
budget: RuntimeItemCompileBudget,
|
||||
): ItemBuildProfile | null {
|
||||
if (plan.permanence !== 'permanent' || (plan.itemKind !== 'equipment' && plan.itemKind !== 'relic' && plan.itemKind !== 'quest')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = normalizeBuildTags(intent.desiredBuildTags, budget.buildTagLimit);
|
||||
if (tags.length <= 0) return null;
|
||||
|
||||
return {
|
||||
role: normalizeBuildRole(tags[0]),
|
||||
tags,
|
||||
synergy: normalizeBuildTags([
|
||||
...plan.targetBuildDirection,
|
||||
...intent.desiredBuildTags,
|
||||
], 3),
|
||||
craftTags: tags,
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
budget: RuntimeItemCompileBudget,
|
||||
plan: RuntimeItemPlan,
|
||||
) {
|
||||
const baseValue = {
|
||||
common: 18,
|
||||
uncommon: 32,
|
||||
rare: 52,
|
||||
epic: 80,
|
||||
legendary: 118,
|
||||
}[budget.rarity];
|
||||
|
||||
if (plan.itemKind === 'material') return baseValue - 8;
|
||||
if (plan.itemKind === 'consumable') return baseValue - 4;
|
||||
if (plan.itemKind === 'quest') return baseValue + 12;
|
||||
return baseValue;
|
||||
}
|
||||
|
||||
function buildItemTags(
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
budget: RuntimeItemCompileBudget,
|
||||
) {
|
||||
const tags = [
|
||||
...buildStructuralTags(plan),
|
||||
...normalizeBuildTags(intent.desiredBuildTags, budget.buildTagLimit + 1),
|
||||
];
|
||||
|
||||
if (intent.desiredFunctionalBias.includes('heal')) tags.push('healing');
|
||||
if (intent.desiredFunctionalBias.includes('mana')) tags.push('mana');
|
||||
if (resolveEquipmentSlotId(plan) === 'armor') tags.push('armor');
|
||||
if (resolveEquipmentSlotId(plan) === 'weapon') tags.push('weapon');
|
||||
if (resolveEquipmentSlotId(plan) === 'relic') tags.push('relic');
|
||||
|
||||
return [...new Set(tags.filter(Boolean))];
|
||||
}
|
||||
|
||||
export function compileRuntimeItem(params: {
|
||||
seedKey: string;
|
||||
context: RuntimeItemGenerationContext;
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
}) {
|
||||
const {seedKey, context, plan, intent} = params;
|
||||
const budget = buildRuntimeBudget(context, plan, seedKey);
|
||||
const itemId = `runtime:${context.generationChannel}:${hashText(seedKey).toString(36)}`;
|
||||
const runtimeMetadata = {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: context.generationChannel,
|
||||
seedKey,
|
||||
relationAnchor: plan.relationAnchor,
|
||||
sourceReason: intent.reasonToAppear,
|
||||
recentEventHook: context.recentActions[0],
|
||||
};
|
||||
|
||||
return {
|
||||
id: itemId,
|
||||
category: resolveCategory(plan),
|
||||
name: intent.shortNameSeed || '未命名秘物',
|
||||
quantity: 1,
|
||||
rarity: budget.rarity,
|
||||
tags: buildItemTags(plan, intent, budget),
|
||||
equipmentSlotId: resolveEquipmentSlotId(plan),
|
||||
statProfile: buildStatProfile(plan, intent, budget),
|
||||
useProfile: buildUseProfile(plan, intent, budget, itemId),
|
||||
buildProfile: buildProfile(plan, intent, budget),
|
||||
value: buildValue(budget, plan),
|
||||
runtimeMetadata,
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
252
src/data/runtimeItemContext.ts
Normal file
252
src/data/runtimeItemContext.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import type {
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
RuntimeItemGenerationChannel,
|
||||
RuntimeItemGenerationContext,
|
||||
ScenePresetInfo,
|
||||
} from '../types';
|
||||
import {getCharacterCombatTags, getTimedBuildBuffTags, normalizeBuildTags} from './buildTags';
|
||||
|
||||
type GapDefinition = {
|
||||
id: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const BUILD_GAP_DEFINITIONS: GapDefinition[] = [
|
||||
{id: 'survival_gap', tags: ['守御', '护体', '回复', '续战']},
|
||||
{id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载']},
|
||||
{id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制']},
|
||||
{id: 'mobility_gap', tags: ['突进', '快袭', '风行', '游击']},
|
||||
{id: 'control_gap', tags: ['控场', '符阵', '镇邪', '反击']},
|
||||
];
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined | false>) {
|
||||
return [...new Set(
|
||||
values
|
||||
.map(value => typeof value === 'string' ? value.trim() : '')
|
||||
.filter(Boolean),
|
||||
)];
|
||||
}
|
||||
|
||||
function collectLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
if (!loadout) return [] as string[];
|
||||
|
||||
return normalizeBuildTags([
|
||||
...(loadout.weapon?.buildProfile?.tags ?? []),
|
||||
loadout.weapon?.buildProfile?.role ?? '',
|
||||
...(loadout.armor?.buildProfile?.tags ?? []),
|
||||
loadout.armor?.buildProfile?.role ?? '',
|
||||
...(loadout.relic?.buildProfile?.tags ?? []),
|
||||
loadout.relic?.buildProfile?.role ?? '',
|
||||
], 6);
|
||||
}
|
||||
|
||||
function buildSceneTags(scene: Pick<ScenePresetInfo, 'name' | 'description' | 'treasureHints'> | null) {
|
||||
if (!scene) return [] as string[];
|
||||
|
||||
const seedParts = dedupeStrings([
|
||||
scene.name,
|
||||
...(scene.treasureHints ?? []),
|
||||
]);
|
||||
|
||||
return seedParts
|
||||
.flatMap(part => part.split(/[、,。;:\s/]+/u))
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length >= 2)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function buildRecentStorySummary(lines: string[]) {
|
||||
if (lines.length <= 0) return '最近没有形成稳定的事件线索。';
|
||||
return lines.join(' / ');
|
||||
}
|
||||
|
||||
function buildRecentStoryLines(storyHistory: GameState['storyHistory']) {
|
||||
return storyHistory
|
||||
.slice(-4)
|
||||
.map(moment => moment.text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-3);
|
||||
}
|
||||
|
||||
function derivePlayerBuildGaps(playerBuildTags: string[]) {
|
||||
const tagSet = new Set(playerBuildTags);
|
||||
return BUILD_GAP_DEFINITIONS
|
||||
.filter(definition => definition.tags.filter(tag => tagSet.has(tag)).length <= 0)
|
||||
.map(definition => definition.id)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildBaseRuntimeContext(params: {
|
||||
worldType: GameState['worldType'];
|
||||
customWorldProfile: GameState['customWorldProfile'];
|
||||
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||||
encounter: GameState['currentEncounter'];
|
||||
relatedNpcState: GameState['npcStates'][string] | null;
|
||||
storyHistory: GameState['storyHistory'];
|
||||
playerCharacterId: string;
|
||||
playerBuildTags: string[];
|
||||
playerEquipmentTags: string[];
|
||||
generationChannel: RuntimeItemGenerationChannel;
|
||||
}) {
|
||||
const {
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
scene,
|
||||
encounter,
|
||||
relatedNpcState,
|
||||
storyHistory,
|
||||
playerCharacterId,
|
||||
playerBuildTags,
|
||||
playerEquipmentTags,
|
||||
generationChannel,
|
||||
} = params;
|
||||
const recentStoryLines = buildRecentStoryLines(storyHistory);
|
||||
|
||||
return {
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
sceneId: scene?.id ?? null,
|
||||
sceneName: scene?.name ?? null,
|
||||
sceneDescription: scene?.description ?? null,
|
||||
sceneTags: buildSceneTags(scene),
|
||||
treasureHints: [...(scene?.treasureHints ?? [])],
|
||||
encounter: encounter ?? null,
|
||||
encounterNpcId: encounter?.id ?? encounter?.characterId ?? encounter?.monsterPresetId ?? encounter?.npcName ?? null,
|
||||
encounterNpcName: encounter?.npcName ?? null,
|
||||
encounterContextText: encounter?.context ?? null,
|
||||
relatedNpcState,
|
||||
relatedScene: scene,
|
||||
recentStorySummary: buildRecentStorySummary(recentStoryLines),
|
||||
recentActions: recentStoryLines,
|
||||
playerCharacterId,
|
||||
playerBuildTags,
|
||||
playerBuildGaps: derivePlayerBuildGaps(playerBuildTags),
|
||||
playerEquipmentTags,
|
||||
generationChannel,
|
||||
} satisfies RuntimeItemGenerationContext;
|
||||
}
|
||||
|
||||
export function buildLooseRuntimeItemGenerationContext(params: {
|
||||
worldType: GameState['worldType'];
|
||||
customWorldProfile?: GameState['customWorldProfile'];
|
||||
scene?: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||||
encounter?: GameState['currentEncounter'];
|
||||
relatedNpcState?: GameState['npcStates'][string] | null;
|
||||
storyHistory?: GameState['storyHistory'];
|
||||
playerCharacterId?: string;
|
||||
playerBuildTags?: string[];
|
||||
playerEquipmentTags?: string[];
|
||||
generationChannel: RuntimeItemGenerationChannel;
|
||||
}) {
|
||||
return buildBaseRuntimeContext({
|
||||
worldType: params.worldType,
|
||||
customWorldProfile: params.customWorldProfile ?? null,
|
||||
scene: params.scene ?? null,
|
||||
encounter: params.encounter ?? null,
|
||||
relatedNpcState: params.relatedNpcState ?? null,
|
||||
storyHistory: params.storyHistory ?? [],
|
||||
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
|
||||
playerBuildTags: params.playerBuildTags ?? [],
|
||||
playerEquipmentTags: params.playerEquipmentTags ?? [],
|
||||
generationChannel: params.generationChannel,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRuntimeItemGenerationContext(params: {
|
||||
state: GameState;
|
||||
generationChannel: RuntimeItemGenerationChannel;
|
||||
encounter?: GameState['currentEncounter'];
|
||||
scene?: GameState['currentScenePreset'];
|
||||
}) {
|
||||
const {state, generationChannel} = params;
|
||||
const encounter = params.encounter ?? state.currentEncounter;
|
||||
const scene = params.scene ?? state.currentScenePreset;
|
||||
const relatedNpcState = encounter
|
||||
? state.npcStates[encounter.id ?? encounter.npcName] ?? null
|
||||
: null;
|
||||
const playerBuildTags = state.playerCharacter
|
||||
? normalizeBuildTags([
|
||||
...getCharacterCombatTags(state.playerCharacter),
|
||||
...collectLoadoutBuildTags(state.playerEquipment),
|
||||
...getTimedBuildBuffTags(state.activeBuildBuffs),
|
||||
], 6)
|
||||
: [];
|
||||
|
||||
return buildBaseRuntimeContext({
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
scene,
|
||||
encounter,
|
||||
relatedNpcState,
|
||||
storyHistory: state.storyHistory,
|
||||
playerCharacterId: state.playerCharacter?.id ?? 'unknown-player',
|
||||
playerBuildTags,
|
||||
playerEquipmentTags: collectLoadoutBuildTags(state.playerEquipment),
|
||||
generationChannel,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildQuestRuntimeItemGenerationContext(params: {
|
||||
context: QuestGenerationContext;
|
||||
generationChannel?: RuntimeItemGenerationChannel;
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
roleText: string;
|
||||
scene?: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||||
}) {
|
||||
const {
|
||||
context,
|
||||
issuerNpcId,
|
||||
issuerNpcName,
|
||||
roleText,
|
||||
scene,
|
||||
generationChannel = 'quest_reward',
|
||||
} = params;
|
||||
const playerBuildTags = context.playerCharacter
|
||||
? normalizeBuildTags([
|
||||
...getCharacterCombatTags(context.playerCharacter),
|
||||
...collectLoadoutBuildTags(context.playerEquipment),
|
||||
], 6)
|
||||
: [];
|
||||
|
||||
return buildBaseRuntimeContext({
|
||||
worldType: context.worldType,
|
||||
customWorldProfile: context.customWorldProfile ?? null,
|
||||
scene: scene ?? (
|
||||
context.currentSceneName
|
||||
? {
|
||||
id: context.currentSceneId ?? '',
|
||||
name: context.currentSceneName,
|
||||
description: context.currentSceneDescription ?? '',
|
||||
treasureHints: [],
|
||||
}
|
||||
: null
|
||||
),
|
||||
encounter: {
|
||||
id: issuerNpcId,
|
||||
kind: 'npc',
|
||||
npcName: issuerNpcName,
|
||||
npcDescription: roleText,
|
||||
npcAvatar: '',
|
||||
context: roleText,
|
||||
},
|
||||
relatedNpcState: context.issuerAffinity == null
|
||||
? null
|
||||
: {
|
||||
affinity: context.issuerAffinity,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
},
|
||||
storyHistory: context.recentStoryMoments ?? [],
|
||||
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
|
||||
playerBuildTags,
|
||||
playerEquipmentTags: collectLoadoutBuildTags(context.playerEquipment),
|
||||
generationChannel,
|
||||
});
|
||||
}
|
||||
120
src/data/runtimeItemDirector.test.ts
Normal file
120
src/data/runtimeItemDirector.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {WorldType} from '../types';
|
||||
import {addInventoryItems} from './npcInteractions';
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildQuestRuntimeItemGenerationContext,
|
||||
} from './runtimeItemContext';
|
||||
import {buildDirectedRuntimeReward, buildRuntimeInventoryStock} from './runtimeItemDirector';
|
||||
|
||||
describe('runtime item director', () => {
|
||||
it('builds treasure rewards with runtime metadata and relation anchors', () => {
|
||||
const context = buildLooseRuntimeItemGenerationContext({
|
||||
worldType: WorldType.WUXIA,
|
||||
scene: {
|
||||
id: 'scene-ruins',
|
||||
name: '断碑古道',
|
||||
description: '碎碑与旧誓散落在路旁。',
|
||||
treasureHints: ['残匣', '旧祭火'],
|
||||
},
|
||||
encounter: {
|
||||
id: 'treasure-altar',
|
||||
kind: 'treasure',
|
||||
npcName: '断誓秘匣',
|
||||
npcDescription: '匣盖上留着未熄的旧印。',
|
||||
npcAvatar: '',
|
||||
context: '古道祭坛',
|
||||
},
|
||||
playerCharacterId: 'hero',
|
||||
playerBuildTags: ['快剑', '追击'],
|
||||
generationChannel: 'treasure',
|
||||
});
|
||||
|
||||
const reward = buildDirectedRuntimeReward(context, {
|
||||
seedKey: 'test:treasure',
|
||||
fixedKinds: ['relic', 'consumable'],
|
||||
fixedPermanence: ['permanent', 'timed'],
|
||||
itemCount: 2,
|
||||
});
|
||||
|
||||
expect(reward.primaryItem?.runtimeMetadata?.generationChannel).toBe('treasure');
|
||||
expect(reward.primaryItem?.runtimeMetadata?.relationAnchor?.type).toBe('npc');
|
||||
expect(reward.primaryItem?.name).not.toBe('未命名秘物');
|
||||
});
|
||||
|
||||
it('keeps identity-sensitive runtime items separate when adding inventory', () => {
|
||||
const baseItem = buildRuntimeInventoryStock(
|
||||
buildLooseRuntimeItemGenerationContext({
|
||||
worldType: WorldType.WUXIA,
|
||||
encounter: {
|
||||
id: 'npc-blackmarket',
|
||||
kind: 'npc',
|
||||
npcName: '黑市牙人',
|
||||
npcDescription: '在阴影里兜售消息与暗器。',
|
||||
npcAvatar: '',
|
||||
context: '黑市',
|
||||
},
|
||||
playerCharacterId: 'hero',
|
||||
playerBuildTags: ['快袭', '风行'],
|
||||
generationChannel: 'npc_trade',
|
||||
}),
|
||||
{
|
||||
seedKey: 'test:stock',
|
||||
itemCount: 1,
|
||||
fixedKinds: ['relic'],
|
||||
fixedPermanence: ['permanent'],
|
||||
},
|
||||
)[0];
|
||||
|
||||
const secondItem = {
|
||||
...baseItem,
|
||||
id: `${baseItem.id}:variant`,
|
||||
runtimeMetadata: baseItem.runtimeMetadata
|
||||
? {
|
||||
...baseItem.runtimeMetadata,
|
||||
seedKey: `${baseItem.runtimeMetadata.seedKey}:variant`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const merged = addInventoryItems([], [baseItem, secondItem]);
|
||||
expect(merged).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('can build quest-flavored runtime rewards from quest context', () => {
|
||||
const context = buildQuestRuntimeItemGenerationContext({
|
||||
context: {
|
||||
worldType: WorldType.XIANXIA,
|
||||
currentSceneId: 'scene-cloud',
|
||||
currentSceneName: '云阙旧渡',
|
||||
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
|
||||
issuerNpcId: 'npc-issuer',
|
||||
issuerNpcName: '巡守使',
|
||||
issuerNpcContext: '巡守',
|
||||
issuerAffinity: 24,
|
||||
recentStoryMoments: [],
|
||||
playerCharacter: null,
|
||||
},
|
||||
issuerNpcId: 'npc-issuer',
|
||||
issuerNpcName: '巡守使',
|
||||
roleText: '巡守',
|
||||
scene: {
|
||||
id: 'scene-cloud',
|
||||
name: '云阙旧渡',
|
||||
description: '旧渡口残留着灵潮和巡守痕迹。',
|
||||
treasureHints: ['旧印'],
|
||||
},
|
||||
});
|
||||
|
||||
const reward = buildDirectedRuntimeReward(context, {
|
||||
seedKey: 'test:quest',
|
||||
fixedKinds: ['equipment', 'consumable'],
|
||||
fixedPermanence: ['permanent', 'timed'],
|
||||
itemCount: 2,
|
||||
});
|
||||
|
||||
expect(reward.primaryItem?.runtimeMetadata?.generationChannel).toBe('quest_reward');
|
||||
expect(reward.supportItems.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
206
src/data/runtimeItemDirector.ts
Normal file
206
src/data/runtimeItemDirector.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
DirectedRuntimeReward,
|
||||
InventoryItem,
|
||||
RuntimeItemGenerationChannel,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemKind,
|
||||
RuntimeItemPermanence,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import {compileRuntimeItem} from './runtimeItemCompiler';
|
||||
import {
|
||||
applyRuntimeItemNarrative,
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeRewardStoryHint,
|
||||
flattenDirectedRuntimeRewardItems,
|
||||
} from './runtimeItemNarrative';
|
||||
|
||||
type RuntimeRewardOptions = {
|
||||
seedKey: string;
|
||||
variant?: string;
|
||||
itemCount?: number;
|
||||
fixedKinds?: RuntimeItemKind[];
|
||||
fixedPermanence?: RuntimeItemPermanence[];
|
||||
baseHp?: number;
|
||||
baseMana?: number;
|
||||
baseCurrency?: number;
|
||||
storyHint?: string;
|
||||
};
|
||||
|
||||
const GAP_TO_TAGS: Record<string, string[]> = {
|
||||
survival_gap: ['守御', '护体', '回复', '续战'],
|
||||
mana_gap: ['法力', '冷却', '过载'],
|
||||
finisher_gap: ['爆发', '重击', '追击'],
|
||||
mobility_gap: ['突进', '快袭', '风行', '游击'],
|
||||
control_gap: ['控场', '符阵', '镇邪'],
|
||||
};
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) - hash) + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function resolveGapDrivenTags(context: RuntimeItemGenerationContext) {
|
||||
const gapTags = context.playerBuildGaps.flatMap(gap => GAP_TO_TAGS[gap] ?? []);
|
||||
return [...new Set([
|
||||
...gapTags,
|
||||
...context.playerBuildTags,
|
||||
])].slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveRelationAnchor(context: RuntimeItemGenerationContext): RuntimeRelationAnchor {
|
||||
if (context.encounter?.monsterPresetId) {
|
||||
return {
|
||||
type: 'monster',
|
||||
monsterId: context.encounter.monsterPresetId,
|
||||
monsterName: context.encounter.npcName,
|
||||
};
|
||||
}
|
||||
|
||||
if (context.encounterNpcName) {
|
||||
return {
|
||||
type: 'npc',
|
||||
npcId: context.encounterNpcId ?? undefined,
|
||||
npcName: context.encounterNpcName,
|
||||
roleText: context.encounterContextText ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (context.sceneName) {
|
||||
return {
|
||||
type: 'scene',
|
||||
sceneId: context.sceneId ?? undefined,
|
||||
sceneName: context.sceneName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'landmark',
|
||||
landmarkName: context.customWorldProfile?.name ?? '无名之地',
|
||||
};
|
||||
}
|
||||
|
||||
function getChannelTemplate(channel: RuntimeItemGenerationChannel) {
|
||||
switch (channel) {
|
||||
case 'treasure':
|
||||
return {
|
||||
kinds: ['relic', 'consumable', 'material'] as RuntimeItemKind[],
|
||||
permanence: ['permanent', 'timed', 'resource'] as RuntimeItemPermanence[],
|
||||
defaultCount: 2,
|
||||
};
|
||||
case 'npc_trade':
|
||||
return {
|
||||
kinds: ['consumable', 'material', 'relic', 'equipment'] as RuntimeItemKind[],
|
||||
permanence: ['timed', 'resource', 'permanent', 'permanent'] as RuntimeItemPermanence[],
|
||||
defaultCount: 4,
|
||||
};
|
||||
case 'npc_reward':
|
||||
return {
|
||||
kinds: ['consumable', 'relic', 'equipment'] as RuntimeItemKind[],
|
||||
permanence: ['timed', 'permanent', 'permanent'] as RuntimeItemPermanence[],
|
||||
defaultCount: 1,
|
||||
};
|
||||
case 'monster_drop':
|
||||
return {
|
||||
kinds: ['material', 'consumable', 'relic'] as RuntimeItemKind[],
|
||||
permanence: ['resource', 'timed', 'permanent'] as RuntimeItemPermanence[],
|
||||
defaultCount: 2,
|
||||
};
|
||||
case 'quest_reward':
|
||||
return {
|
||||
kinds: ['equipment', 'relic', 'consumable'] as RuntimeItemKind[],
|
||||
permanence: ['permanent', 'permanent', 'timed'] as RuntimeItemPermanence[],
|
||||
defaultCount: 2,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kinds: ['consumable', 'material', 'relic'] as RuntimeItemKind[],
|
||||
permanence: ['timed', 'resource', 'permanent'] as RuntimeItemPermanence[],
|
||||
defaultCount: 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function planRuntimeItems(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): RuntimeItemPlan[] {
|
||||
const template = getChannelTemplate(context.generationChannel);
|
||||
const count = Math.max(1, options.itemCount ?? template.defaultCount);
|
||||
const relationAnchor = resolveRelationAnchor(context);
|
||||
const targetBuildDirection = resolveGapDrivenTags(context);
|
||||
const seed = hashText(`${options.seedKey}:${options.variant ?? 'default'}:${context.generationChannel}`);
|
||||
|
||||
return Array.from({length: count}, (_, index) => {
|
||||
const kinds = options.fixedKinds?.length ? options.fixedKinds : template.kinds;
|
||||
const permanences = options.fixedPermanence?.length ? options.fixedPermanence : template.permanence;
|
||||
const itemKind = kinds[(seed + index) % kinds.length] ?? 'consumable';
|
||||
const permanence = permanences[(seed + index) % permanences.length] ?? 'timed';
|
||||
|
||||
return {
|
||||
slot: index === 0 ? 'primary' : index === 1 ? 'secondary' : 'support',
|
||||
itemKind,
|
||||
permanence,
|
||||
narrativeWeight: index === 0 ? 'heavy' : 'medium',
|
||||
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
|
||||
relationAnchor,
|
||||
} satisfies RuntimeItemPlan;
|
||||
});
|
||||
}
|
||||
|
||||
function compilePlannedItem(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
seedKey: string,
|
||||
) {
|
||||
const intent = buildRuntimeItemAiIntent(context, plan);
|
||||
const compiled = compileRuntimeItem({
|
||||
seedKey,
|
||||
context,
|
||||
plan,
|
||||
intent,
|
||||
});
|
||||
|
||||
return applyRuntimeItemNarrative({
|
||||
item: compiled,
|
||||
context,
|
||||
plan,
|
||||
intent,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): DirectedRuntimeReward {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
|
||||
);
|
||||
|
||||
const reward: DirectedRuntimeReward = {
|
||||
primaryItem: compiledItems[0] ?? null,
|
||||
supportItems: compiledItems.slice(1),
|
||||
hp: options.baseHp,
|
||||
mana: options.baseMana,
|
||||
currency: options.baseCurrency,
|
||||
storyHint: options.storyHint,
|
||||
};
|
||||
|
||||
return {
|
||||
...reward,
|
||||
storyHint: buildRuntimeRewardStoryHint(reward),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRuntimeInventoryStock(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): InventoryItem[] {
|
||||
return flattenDirectedRuntimeRewardItems(buildDirectedRuntimeReward(context, options));
|
||||
}
|
||||
208
src/data/runtimeItemNarrative.ts
Normal file
208
src/data/runtimeItemNarrative.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
DirectedRuntimeReward,
|
||||
InventoryItem,
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemAiPromptInput,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
|
||||
function pickFirst<T>(values: T[], fallback: T): T {
|
||||
return values[0] ?? fallback;
|
||||
}
|
||||
|
||||
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
|
||||
return (value ?? '')
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return anchor.npcName;
|
||||
case 'scene':
|
||||
return anchor.sceneName;
|
||||
case 'landmark':
|
||||
return anchor.landmarkName;
|
||||
case 'monster':
|
||||
return anchor.monsterName;
|
||||
case 'faction':
|
||||
return anchor.factionName;
|
||||
case 'quest':
|
||||
return anchor.questName;
|
||||
default:
|
||||
return '无名之地';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFunctionWord(item: InventoryItem, plan: RuntimeItemPlan, intent: RuntimeItemAiIntent) {
|
||||
const topTag = intent.desiredBuildTags[0] ?? plan.targetBuildDirection[0] ?? '';
|
||||
|
||||
if (plan.itemKind === 'consumable') {
|
||||
if (intent.desiredFunctionalBias.includes('heal')) return '灵露';
|
||||
if (intent.desiredFunctionalBias.includes('mana')) return '回气散';
|
||||
if (intent.desiredFunctionalBias.includes('cooldown')) return '压纹符';
|
||||
return '药包';
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'material') {
|
||||
return topTag ? `${topTag}精粹` : '残材';
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'quest') {
|
||||
return '信物';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'weapon') {
|
||||
if (topTag === '快剑' || topTag === '追击') return '短刃';
|
||||
if (topTag === '远射') return '短弓';
|
||||
if (topTag === '重击') return '战锤';
|
||||
return '兵刃';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'armor') {
|
||||
return topTag === '守御' ? '护甲' : '护符';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'relic' || plan.itemKind === 'relic') {
|
||||
return topTag === '法力' ? '灵坠' : '护心佩';
|
||||
}
|
||||
|
||||
return pickFirst([
|
||||
sanitizeFragment(intent.shortNameSeed),
|
||||
topTag,
|
||||
item.category,
|
||||
].filter(Boolean), '秘物');
|
||||
}
|
||||
|
||||
function buildAnchorName(anchor: RuntimeRelationAnchor) {
|
||||
const label = resolveAnchorLabel(anchor);
|
||||
return sanitizeFragment(label, 4) || '旧誓';
|
||||
}
|
||||
|
||||
function buildRelationWord(anchor: RuntimeRelationAnchor, intent: RuntimeItemAiIntent) {
|
||||
const fromHook = sanitizeFragment(intent.relationHooks[0], 4);
|
||||
if (fromHook) return fromHook;
|
||||
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return sanitizeFragment(anchor.roleText, 4) || '旧识';
|
||||
case 'scene':
|
||||
return '遗痕';
|
||||
case 'monster':
|
||||
return '猎印';
|
||||
case 'quest':
|
||||
return '誓约';
|
||||
case 'faction':
|
||||
return '徽记';
|
||||
default:
|
||||
return '余烬';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeItemAiPromptInput(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
): RuntimeItemAiPromptInput {
|
||||
return {
|
||||
worldSummary: context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
|
||||
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
|
||||
encounterSummary: [context.encounterNpcName, context.encounterContextText].filter(Boolean).join(' / '),
|
||||
relatedNpcSummary: context.relatedNpcState
|
||||
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity}`
|
||||
: '暂无明确人物关系',
|
||||
recentStorySummary: context.recentStorySummary,
|
||||
generationChannel: context.generationChannel,
|
||||
playerBuildDirection: context.playerBuildTags,
|
||||
playerBuildGaps: context.playerBuildGaps,
|
||||
desiredItemKind: plan.itemKind,
|
||||
permanence: plan.permanence,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRuntimeItemAiIntent(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
): RuntimeItemAiIntent {
|
||||
const sourceSeed = sanitizeFragment(context.sceneName, 4)
|
||||
|| sanitizeFragment(context.customWorldProfile?.name, 4)
|
||||
|| sanitizeFragment(resolveAnchorLabel(plan.relationAnchor), 4)
|
||||
|| '旧誓';
|
||||
const functionalBias: RuntimeItemAiIntent['desiredFunctionalBias'] = [];
|
||||
|
||||
if (plan.permanence === 'timed') {
|
||||
functionalBias.push(context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown');
|
||||
}
|
||||
if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana');
|
||||
if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard');
|
||||
if (
|
||||
functionalBias.length <= 0
|
||||
|| context.playerBuildGaps.includes('finisher_gap')
|
||||
|| plan.itemKind === 'equipment'
|
||||
) {
|
||||
functionalBias.push('damage');
|
||||
}
|
||||
|
||||
return {
|
||||
shortNameSeed: sourceSeed,
|
||||
sourcePhrase: resolveAnchorLabel(plan.relationAnchor),
|
||||
reasonToAppear: `${resolveAnchorLabel(plan.relationAnchor)}与最近局势把它推到了你面前。`,
|
||||
relationHooks: [
|
||||
context.encounterContextText ?? context.sceneName ?? resolveAnchorLabel(plan.relationAnchor),
|
||||
...context.recentActions,
|
||||
].filter(Boolean).slice(0, 2) as string[],
|
||||
desiredBuildTags: [...new Set([
|
||||
...plan.targetBuildDirection,
|
||||
...context.playerBuildTags.slice(0, 2),
|
||||
])].slice(0, 3),
|
||||
desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2),
|
||||
tone: context.generationChannel === 'monster_drop'
|
||||
? 'grim'
|
||||
: context.generationChannel === 'quest_reward'
|
||||
? 'ritual'
|
||||
: context.playerBuildGaps.includes('survival_gap')
|
||||
? 'survival'
|
||||
: 'martial',
|
||||
};
|
||||
}
|
||||
|
||||
export function applyRuntimeItemNarrative(params: {
|
||||
item: InventoryItem;
|
||||
context: RuntimeItemGenerationContext;
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
}) {
|
||||
const {item, context, plan, intent} = params;
|
||||
const sourceWord = buildAnchorName(plan.relationAnchor);
|
||||
const relationWord = buildRelationWord(plan.relationAnchor, intent);
|
||||
const functionWord = resolveFunctionWord(item, plan, intent);
|
||||
const buildDirectionText = intent.desiredBuildTags.join('、') || context.playerBuildTags.join('、') || '均衡';
|
||||
const relationText = resolveAnchorLabel(plan.relationAnchor);
|
||||
const sourceReason = item.runtimeMetadata?.sourceReason ?? intent.reasonToAppear;
|
||||
|
||||
return {
|
||||
...item,
|
||||
name: `${sourceWord}${relationWord}${functionWord}`,
|
||||
description: `${relationText}留下的${item.category}。${sourceReason} 它偏向 ${buildDirectionText} 方向,适合当前局势中的临场构筑调整。`,
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
export function describeRuntimeRelationAnchor(anchor: RuntimeRelationAnchor | undefined) {
|
||||
if (!anchor) return '无明确锚点';
|
||||
return `${anchor.type}:${resolveAnchorLabel(anchor)}`;
|
||||
}
|
||||
|
||||
export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) {
|
||||
return [
|
||||
...(reward.primaryItem ? [reward.primaryItem] : []),
|
||||
...reward.supportItems,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildRuntimeRewardStoryHint(reward: DirectedRuntimeReward) {
|
||||
const primaryName = reward.primaryItem?.name;
|
||||
if (!primaryName) return reward.storyHint ?? '你得到了一件与当前局势相关的物品。';
|
||||
return reward.storyHint ?? `这次得到的核心物件是 ${primaryName}。`;
|
||||
}
|
||||
102
src/data/runtimeStats.ts
Normal file
102
src/data/runtimeStats.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { GameRuntimeStats, GameState } from '../types';
|
||||
|
||||
function clampNonNegativeInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function getIsoTimestamp(now: number) {
|
||||
return new Date(now).toISOString();
|
||||
}
|
||||
|
||||
export function createInitialGameRuntimeStats(
|
||||
options: {
|
||||
isActiveRun?: boolean;
|
||||
now?: number;
|
||||
} = {},
|
||||
): GameRuntimeStats {
|
||||
const now = options.now ?? Date.now();
|
||||
|
||||
return {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: options.isActiveRun ? getIsoTimestamp(now) : null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGameRuntimeStats(
|
||||
stats: Partial<GameRuntimeStats> | null | undefined,
|
||||
options: {
|
||||
isActiveRun?: boolean;
|
||||
now?: number;
|
||||
} = {},
|
||||
): GameRuntimeStats {
|
||||
const now = options.now ?? Date.now();
|
||||
|
||||
return {
|
||||
playTimeMs: typeof stats?.playTimeMs === 'number' && Number.isFinite(stats.playTimeMs)
|
||||
? Math.max(0, stats.playTimeMs)
|
||||
: 0,
|
||||
lastPlayTickAt: options.isActiveRun ? getIsoTimestamp(now) : null,
|
||||
hostileNpcsDefeated: clampNonNegativeInteger(stats?.hostileNpcsDefeated),
|
||||
questsAccepted: clampNonNegativeInteger(stats?.questsAccepted),
|
||||
itemsUsed: clampNonNegativeInteger(stats?.itemsUsed),
|
||||
scenesTraveled: clampNonNegativeInteger(stats?.scenesTraveled),
|
||||
};
|
||||
}
|
||||
|
||||
export function incrementGameRuntimeStats(
|
||||
stats: GameRuntimeStats,
|
||||
increments: Partial<Pick<GameRuntimeStats, 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>,
|
||||
): GameRuntimeStats {
|
||||
return {
|
||||
...stats,
|
||||
hostileNpcsDefeated: stats.hostileNpcsDefeated + clampNonNegativeInteger(increments.hostileNpcsDefeated),
|
||||
questsAccepted: stats.questsAccepted + clampNonNegativeInteger(increments.questsAccepted),
|
||||
itemsUsed: stats.itemsUsed + clampNonNegativeInteger(increments.itemsUsed),
|
||||
scenesTraveled: stats.scenesTraveled + clampNonNegativeInteger(increments.scenesTraveled),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncGameRuntimePlayTime(stats: GameRuntimeStats, now = Date.now()): GameRuntimeStats {
|
||||
if (!stats.lastPlayTickAt) {
|
||||
return {
|
||||
...stats,
|
||||
lastPlayTickAt: getIsoTimestamp(now),
|
||||
};
|
||||
}
|
||||
|
||||
const lastTickMs = Date.parse(stats.lastPlayTickAt);
|
||||
if (Number.isNaN(lastTickMs)) {
|
||||
return {
|
||||
...stats,
|
||||
lastPlayTickAt: getIsoTimestamp(now),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...stats,
|
||||
playTimeMs: stats.playTimeMs + Math.max(0, now - lastTickMs),
|
||||
lastPlayTickAt: getIsoTimestamp(now),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLiveGamePlayTimeMs(stats: GameRuntimeStats, now = Date.now()) {
|
||||
if (!stats.lastPlayTickAt) return stats.playTimeMs;
|
||||
|
||||
const lastTickMs = Date.parse(stats.lastPlayTickAt);
|
||||
if (Number.isNaN(lastTickMs)) return stats.playTimeMs;
|
||||
|
||||
return stats.playTimeMs + Math.max(0, now - lastTickMs);
|
||||
}
|
||||
|
||||
export function syncGameStatePlayTime(state: GameState, now = Date.now()): GameState {
|
||||
return {
|
||||
...state,
|
||||
runtimeStats: syncGameRuntimePlayTime(state.runtimeStats, now),
|
||||
};
|
||||
}
|
||||
|
||||
33
src/data/sceneBackgrounds.test.ts
Normal file
33
src/data/sceneBackgrounds.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { WorldType } from '../types';
|
||||
import { getDefaultCustomWorldSceneImage } from './customWorldVisuals';
|
||||
import { getScenePresetsByWorld } from './scenePresets';
|
||||
|
||||
function resolvePublicAssetPath(assetPath: string) {
|
||||
return path.resolve(process.cwd(), 'public', assetPath.replace(/^\/+/, ''));
|
||||
}
|
||||
|
||||
describe('scene background assets', () => {
|
||||
it('ships background files for every wuxia and xianxia scene preset', () => {
|
||||
const scenes = [
|
||||
...getScenePresetsByWorld(WorldType.WUXIA),
|
||||
...getScenePresetsByWorld(WorldType.XIANXIA),
|
||||
];
|
||||
|
||||
expect(scenes.length).toBeGreaterThan(0);
|
||||
|
||||
for (const scene of scenes) {
|
||||
expect(fs.existsSync(resolvePublicAssetPath(scene.imageSrc))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns existing default custom world backgrounds for both anchor worlds', () => {
|
||||
const wuxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.WUXIA);
|
||||
const xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
|
||||
|
||||
expect(fs.existsSync(resolvePublicAssetPath(wuxiaImage))).toBe(true);
|
||||
expect(fs.existsSync(resolvePublicAssetPath(xianxiaImage))).toBe(true);
|
||||
});
|
||||
});
|
||||
155
src/data/sceneEncounterPreviews.test.ts
Normal file
155
src/data/sceneEncounterPreviews.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
|
||||
import { createSceneMonster } from './hostileNpcs';
|
||||
import { buildInitialNpcState } from './npcInteractions';
|
||||
import {
|
||||
hasAutoBattleSceneEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
} from './sceneEncounterPreviews';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
initialAffinity: 12,
|
||||
hostile: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const encounter = createEncounter();
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: 'Trail',
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
connectedSceneIds: [],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
[encounter.id!]: {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
affinity: -5,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sceneEncounterPreviews', () => {
|
||||
it('treats negative-affinity npc encounters as immediate battles', () => {
|
||||
const state = createBaseState();
|
||||
|
||||
expect(hasAutoBattleSceneEncounter(state)).toBe(true);
|
||||
|
||||
const resolved = resolveSceneEncounterPreview(state);
|
||||
|
||||
expect(resolved.inBattle).toBe(true);
|
||||
expect(resolved.currentEncounter).toBeNull();
|
||||
expect(resolved.currentBattleNpcId).toBe('npc-trader');
|
||||
expect(resolved.currentNpcBattleMode).toBe('fight');
|
||||
expect(resolved.sceneMonsters).toHaveLength(1);
|
||||
expect(resolved.sceneMonsters[0]?.encounter?.npcName).toBe('Trader Lin');
|
||||
});
|
||||
|
||||
it('attaches npc encounter metadata to regular monsters', () => {
|
||||
const monsterId = getMonsterPresetsByWorld(WorldType.WUXIA)[0]?.id;
|
||||
if (!monsterId) {
|
||||
throw new Error('Expected at least one monster preset');
|
||||
}
|
||||
|
||||
const monster = createSceneMonster(WorldType.WUXIA, monsterId);
|
||||
|
||||
expect(monster).not.toBeNull();
|
||||
expect(monster?.encounter?.kind).toBe('npc');
|
||||
expect(monster?.encounter?.monsterPresetId).toBe(monsterId);
|
||||
expect(monster?.encounter?.hostile).toBe(true);
|
||||
expect(monster?.encounter?.initialAffinity).toBe(-40);
|
||||
});
|
||||
});
|
||||
|
||||
421
src/data/sceneEncounterPreviews.ts
Normal file
421
src/data/sceneEncounterPreviews.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import { getRecruitedNpcIds } from './companionRoster';
|
||||
import {
|
||||
createSceneMonstersFromIds,
|
||||
createSceneNpcMonstersFromEncounters,
|
||||
getFacingTowardPlayer,
|
||||
getMonsterGroupAnchorX,
|
||||
pickEncounterMonsterIds,
|
||||
PLAYER_BASE_X_METERS,
|
||||
} from './hostileNpcs';
|
||||
import { buildInitialNpcState, createNpcBattleMonster } from './npcInteractions';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
getSceneFriendlyNpcs,
|
||||
getSceneHostileNpcs,
|
||||
getWorldCampScenePreset,
|
||||
} from './scenePresets';
|
||||
|
||||
export const EXPLORE_APPROACH_DURATION_MS = 4000;
|
||||
export const PREVIEW_ENTITY_X_METERS = 12;
|
||||
export const RESOLVED_ENTITY_X_METERS = 3.2;
|
||||
export const CALL_OUT_ENTRY_X_METERS = 18;
|
||||
export const TREASURE_ENCOUNTERS_ENABLED = false;
|
||||
|
||||
function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType);
|
||||
}
|
||||
|
||||
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
|
||||
if (encounter.kind !== 'npc') return false;
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
|
||||
}
|
||||
|
||||
function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
const battleNpcId = getNpcEncounterKey(encounter);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneMonsters: [createNpcBattleMonster(encounter, npcState, 'fight')],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: battleNpcId,
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pickRandomItem<T>(items: T[]) {
|
||||
if (items.length === 0) return null;
|
||||
return items[Math.floor(Math.random() * items.length)] ?? null;
|
||||
}
|
||||
|
||||
function createTreasureEncounter(state: GameState, treasureHint: string): Encounter {
|
||||
return {
|
||||
id: `treasure-${state.currentScenePreset?.id ?? 'unknown'}`,
|
||||
kind: 'treasure',
|
||||
npcName: '宝藏',
|
||||
npcDescription: `你发现了与${treasureHint}相关的线索,看起来像是有人故意藏起的宝物。`,
|
||||
npcAvatar: '/Icons/47_treasure.png',
|
||||
context: 'treasure',
|
||||
xMeters: PREVIEW_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
function getAvailableFriendlySceneNpcs(state: GameState) {
|
||||
const recruitedNpcIds = getRecruitedNpcIds(state);
|
||||
const isCampScene = Boolean(
|
||||
state.worldType
|
||||
&& state.currentScenePreset?.id
|
||||
&& getWorldCampScenePreset(state.worldType)?.id === state.currentScenePreset.id,
|
||||
);
|
||||
|
||||
return getSceneFriendlyNpcs(state.currentScenePreset)
|
||||
.filter(candidate => !isCampScene || Boolean(candidate.characterId))
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id));
|
||||
}
|
||||
|
||||
function getAvailableHostileSceneNpcs(state: GameState) {
|
||||
const recruitedNpcIds = getRecruitedNpcIds(state);
|
||||
|
||||
return getSceneHostileNpcs(state.currentScenePreset)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id))
|
||||
.filter((candidate): candidate is SceneNpc & { monsterPresetId: string } => Boolean(candidate.monsterPresetId));
|
||||
}
|
||||
|
||||
function pickEncounterHostileNpcs(hostileNpcs: Array<SceneNpc & { monsterPresetId: string }>) {
|
||||
const selectedMonsterIds = new Set(
|
||||
pickEncounterMonsterIds(hostileNpcs.map(npc => npc.monsterPresetId)),
|
||||
);
|
||||
|
||||
return hostileNpcs.filter(npc => selectedMonsterIds.has(npc.monsterPresetId));
|
||||
}
|
||||
|
||||
function buildHostileEncounterGroup(
|
||||
state: GameState,
|
||||
entryX: number,
|
||||
animation: 'idle' | 'move',
|
||||
) {
|
||||
if (!state.worldType || !state.currentScenePreset) return [];
|
||||
|
||||
const selectedHostiles = pickEncounterHostileNpcs(getAvailableHostileSceneNpcs(state));
|
||||
const hostileEncounters = selectedHostiles.map(npc => buildEncounterFromSceneNpc(npc));
|
||||
const hostileMonsters = createSceneNpcMonstersFromEncounters(
|
||||
state.worldType,
|
||||
hostileEncounters,
|
||||
PLAYER_BASE_X_METERS,
|
||||
);
|
||||
const anchorX = getMonsterGroupAnchorX(hostileMonsters);
|
||||
|
||||
return hostileMonsters.map(monster => {
|
||||
const xMeters = Number((entryX + (monster.xMeters - anchorX)).toFixed(2));
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
animation,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
xMeters,
|
||||
}
|
||||
: monster.encounter,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildFriendlyEncounter(npc: SceneNpc, xMeters: number) {
|
||||
return {
|
||||
...buildEncounterFromSceneNpc(npc, xMeters),
|
||||
xMeters,
|
||||
} satisfies Encounter;
|
||||
}
|
||||
|
||||
function buildResolvedHostileBattleState(state: GameState, hostileEncounters: Encounter[]) {
|
||||
if (!state.worldType) return state;
|
||||
|
||||
const resolvedMonsters = createSceneNpcMonstersFromEncounters(
|
||||
state.worldType,
|
||||
hostileEncounters,
|
||||
PLAYER_BASE_X_METERS,
|
||||
).map(monster => ({
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, PLAYER_BASE_X_METERS),
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
xMeters: monster.xMeters,
|
||||
}
|
||||
: monster.encounter,
|
||||
}));
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneMonsters: resolvedMonsters,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSceneEncounterPreview(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
const availableHostiles = getAvailableHostileSceneNpcs(state);
|
||||
const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = [];
|
||||
if (availableHostiles.length > 0) availableKinds.push('hostile');
|
||||
if (availableNpcs.length > 0) availableKinds.push('npc');
|
||||
if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) {
|
||||
availableKinds.push('treasure');
|
||||
}
|
||||
|
||||
const kind = pickRandomItem(availableKinds);
|
||||
if (!kind) {
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'hostile') {
|
||||
return {
|
||||
sceneMonsters: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'),
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, PREVIEW_ENTITY_X_METERS) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []);
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: treasureHint ? createTreasureEncounter(state, treasureHint) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSceneCallOutEncounter(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = [];
|
||||
const availableHostiles = getAvailableHostileSceneNpcs(state);
|
||||
if (availableHostiles.length > 0) availableKinds.push('hostile');
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
if (availableNpcs.length > 0) availableKinds.push('npc');
|
||||
if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) {
|
||||
availableKinds.push('treasure');
|
||||
}
|
||||
|
||||
const kind = pickRandomItem(availableKinds);
|
||||
if (kind === 'hostile') {
|
||||
return {
|
||||
sceneMonsters: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'),
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'treasure') {
|
||||
const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []);
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: treasureHint
|
||||
? {
|
||||
...createTreasureEncounter(state, treasureHint),
|
||||
xMeters: CALL_OUT_ENTRY_X_METERS,
|
||||
}
|
||||
: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureSceneEncounterPreview(state: GameState): GameState {
|
||||
if (
|
||||
state.inBattle ||
|
||||
state.sceneMonsters.length > 0 ||
|
||||
state.currentEncounter ||
|
||||
!state.currentScenePreset ||
|
||||
!state.worldType
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...createSceneEncounterPreview(state),
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAutoBattleSceneEncounter(state: GameState) {
|
||||
if (!state.currentScenePreset || !state.worldType || state.inBattle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.sceneMonsters.length > 0) {
|
||||
return state.sceneMonsters.some(monster => Boolean(monster.encounter?.monsterPresetId));
|
||||
}
|
||||
|
||||
return state.currentEncounter?.kind === 'npc'
|
||||
? shouldAutoStartBattleForEncounter(state, state.currentEncounter)
|
||||
: false;
|
||||
}
|
||||
|
||||
export function resolveSceneEncounterPreview(state: GameState): GameState {
|
||||
if (!state.currentScenePreset || !state.worldType) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const previewState =
|
||||
state.sceneMonsters.length > 0 || state.currentEncounter
|
||||
? state
|
||||
: ensureSceneEncounterPreview(state);
|
||||
|
||||
if (previewState.sceneMonsters.length > 0) {
|
||||
const hostileEncounters = previewState.sceneMonsters
|
||||
.map(monster => monster.encounter)
|
||||
.filter((encounter): encounter is Encounter => Boolean(encounter?.monsterPresetId));
|
||||
|
||||
if (hostileEncounters.length > 0) {
|
||||
return buildResolvedHostileBattleState(previewState, hostileEncounters);
|
||||
}
|
||||
|
||||
const resolvedMonsters = createSceneMonstersFromIds(
|
||||
previewState.worldType ?? WorldType.WUXIA,
|
||||
previewState.sceneMonsters.map(monster => monster.id),
|
||||
PLAYER_BASE_X_METERS,
|
||||
).map(monster => ({
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, PLAYER_BASE_X_METERS),
|
||||
}));
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
sceneMonsters: resolvedMonsters,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
previewState.currentEncounter?.kind === 'npc'
|
||||
&& shouldAutoStartBattleForEncounter(previewState, previewState.currentEncounter)
|
||||
) {
|
||||
return buildResolvedNpcBattleState(previewState, previewState.currentEncounter);
|
||||
}
|
||||
|
||||
if (previewState.currentEncounter) {
|
||||
return {
|
||||
...previewState,
|
||||
currentEncounter: {
|
||||
...previewState.currentEncounter,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
return previewState;
|
||||
}
|
||||
|
||||
export function getPreviewEntityX(state: GameState) {
|
||||
return state.sceneMonsters.length > 0
|
||||
? getMonsterGroupAnchorX(state.sceneMonsters)
|
||||
: state.currentEncounter?.xMeters ?? PREVIEW_ENTITY_X_METERS;
|
||||
}
|
||||
1
src/data/sceneNpcOverrides.json
Normal file
1
src/data/sceneNpcOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
37
src/data/sceneObservation.ts
Normal file
37
src/data/sceneObservation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { WorldType } from '../types';
|
||||
import { getSceneFriendlyNpcs, getSceneHostileNpcs,getScenePresetById } from './scenePresets';
|
||||
|
||||
export function buildSceneObserveSignsStoryText(
|
||||
worldType: WorldType | null,
|
||||
sceneId: string | null | undefined,
|
||||
) {
|
||||
if (!worldType) {
|
||||
return '你停下来倾听,但目前场景上下文不足,无法判断附近有什么。';
|
||||
}
|
||||
|
||||
const scene = getScenePresetById(worldType, sceneId);
|
||||
if (!scene) {
|
||||
return '你停下来倾听,但这个区域还没有露出任何可靠的痕迹。';
|
||||
}
|
||||
|
||||
const friendlyNpcs = getSceneFriendlyNpcs(scene);
|
||||
const hostileNpcs = getSceneHostileNpcs(scene);
|
||||
const npcSummary = friendlyNpcs.length > 0
|
||||
? `可能的角色:${friendlyNpcs.map(npc => `${npc.name}(${npc.role})`).join(',')}`
|
||||
: '可能的角色:暂无明确识别';
|
||||
const hostileSummary = hostileNpcs.length > 0
|
||||
? `可能的敌对角色:${hostileNpcs.map(npc => npc.name).join(',')}`
|
||||
: '可能的敌对角色:无明确威胁特征';
|
||||
const treasureSummary = scene.treasureHints.length > 0
|
||||
? `可能的宝藏线索:${scene.treasureHints.slice(0, 2).join(',')}`
|
||||
: '可能的宝藏线索:暂无发现';
|
||||
|
||||
const bossCandidate = hostileNpcs[0] ?? null;
|
||||
const bossSummary = bossCandidate
|
||||
? hostileNpcs.length >= 3
|
||||
? `Boss线索:${bossCandidate.name} 感觉是这里最强的敌对存在。${bossCandidate.description}`
|
||||
: `Boss线索:暂无明显首领,但${bossCandidate.name} 仍然是最需要警惕的危险威胁。`
|
||||
: 'Boss线索:暂无迹象指向该区域有明确首领。';
|
||||
|
||||
return `你稳住队伍,梳理${scene.name}周围隐藏的迹象。${npcSummary}。${hostileSummary}。${treasureSummary}。${bossSummary}`;
|
||||
}
|
||||
1
src/data/sceneOverrides.json
Normal file
1
src/data/sceneOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
835
src/data/scenePresets.ts
Normal file
835
src/data/scenePresets.ts
Normal file
@@ -0,0 +1,835 @@
|
||||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||||
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
|
||||
import { CustomWorldProfile, Encounter, SceneNpc, WorldType } from '../types';
|
||||
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
|
||||
import { resolveAttributeSchema } from './attributeResolver';
|
||||
import {
|
||||
buildCharacterNpc,
|
||||
buildCustomWorldPlayableCharacters,
|
||||
getCharacterHomeSceneId,
|
||||
getCharacterNpcSceneIds,
|
||||
PRESET_CHARACTERS,
|
||||
} from './characterPresets';
|
||||
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
|
||||
import { getMonsterPresetById } from './hostileNpcPresets';
|
||||
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
|
||||
import sceneOverridesJson from './sceneOverrides.json';
|
||||
|
||||
export interface ScenePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
worldType: WorldType;
|
||||
forwardSceneId?: string;
|
||||
connectedSceneIds: string[];
|
||||
monsterIds: string[];
|
||||
npcs: SceneNpc[];
|
||||
treasureHints: string[];
|
||||
}
|
||||
|
||||
export type ScenePresetOverride = Partial<Omit<ScenePreset, 'id' | 'worldType' | 'npcs'>>;
|
||||
|
||||
type KnownSceneNpcGender = Exclude<NonNullable<SceneNpc['gender']>, 'unknown'>;
|
||||
|
||||
export type SceneNpcPresetOverride = Partial<Omit<SceneNpc, 'gender'>> & {
|
||||
gender?: KnownSceneNpcGender;
|
||||
};
|
||||
|
||||
const SCENE_OVERRIDES = sceneOverridesJson as Record<string, ScenePresetOverride>;
|
||||
const SCENE_NPC_OVERRIDES = sceneNpcOverridesJson as Record<string, SceneNpcPresetOverride>;
|
||||
// Keep scene-only NPC genders explicit so encounter prompts never receive "unknown".
|
||||
const SCENE_NPC_GENDERS: Record<string, KnownSceneNpcGender> = {
|
||||
'wuxia-npc-bamboo-woodcutter': 'male',
|
||||
'wuxia-npc-gate-disciple': 'male',
|
||||
'wuxia-npc-night-vendor': 'male',
|
||||
'wuxia-npc-village-remnant': 'female',
|
||||
'wuxia-npc-ferryman': 'male',
|
||||
'wuxia-npc-hunter': 'male',
|
||||
'wuxia-npc-quartermaster': 'male',
|
||||
'wuxia-npc-tomb-scholar': 'male',
|
||||
'wuxia-npc-temple-host': 'male',
|
||||
'wuxia-npc-miner': 'male',
|
||||
'wuxia-npc-blacksmith': 'male',
|
||||
'wuxia-npc-maid': 'female',
|
||||
'xianxia-npc-gate-attendant': 'male',
|
||||
'xianxia-npc-cloud-hermit': 'male',
|
||||
'xianxia-npc-palace-page': 'female',
|
||||
'xianxia-npc-herbal-keeper': 'female',
|
||||
'xianxia-npc-cold-scholar': 'male',
|
||||
'xianxia-npc-fire-forger': 'male',
|
||||
'xianxia-npc-thunder-keeper': 'male',
|
||||
'xianxia-npc-helmsman': 'male',
|
||||
'xianxia-npc-lake-watcher': 'female',
|
||||
'xianxia-npc-ruin-scholar': 'female',
|
||||
'xianxia-npc-tree-ward': 'female',
|
||||
'xianxia-npc-cliff-scout': 'female',
|
||||
};
|
||||
|
||||
type SceneTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
worldType: WorldType;
|
||||
monsterIds: string[];
|
||||
connectedSceneIds: string[];
|
||||
forwardSceneId?: string;
|
||||
treasureHints: string[];
|
||||
extraNpcs: SceneNpc[];
|
||||
};
|
||||
|
||||
const PACKS = [
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 1', count: 121 },
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 2', count: 119 },
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
|
||||
];
|
||||
|
||||
function buildImagePath(packName: string, imageNumber: number) {
|
||||
const filename = `${imageNumber.toString().padStart(3, '0')}.png`;
|
||||
return `/scene_bg/Pixel Battle Backgrounds Mega Pack/${packName}/${filename}`;
|
||||
}
|
||||
|
||||
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
|
||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const refs: string[] = [];
|
||||
let globalIndex = 0;
|
||||
|
||||
for (const pack of PACKS) {
|
||||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||||
const assignedWorld = globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
|
||||
if (assignedWorld === resolvedWorldType) {
|
||||
refs.push(buildImagePath(pack.packName, imageNumber));
|
||||
if (refs.length >= requiredCount) return refs;
|
||||
}
|
||||
globalIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectAllImagePool() {
|
||||
const refs: string[] = [];
|
||||
|
||||
for (const pack of PACKS) {
|
||||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||||
refs.push(buildImagePath(pack.packName, imageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function inferCustomNpcGender(id: string, name: string) {
|
||||
const seed = hashText(`${id}:${name}`);
|
||||
return seed % 2 === 0 ? 'female' as const : 'male' as const;
|
||||
}
|
||||
|
||||
function buildHostileSceneNpc(sceneId: string, worldType: WorldType, monsterId: string): SceneNpc | null {
|
||||
const preset = getMonsterPresetById(worldType, monsterId);
|
||||
if (!preset) return null;
|
||||
|
||||
return {
|
||||
id: `hostile-npc:${sceneId}:${preset.id}`,
|
||||
name: preset.name,
|
||||
role: '敌对角色',
|
||||
avatar: preset.name.slice(0, 1) || '敌',
|
||||
description: preset.description,
|
||||
gender: inferCustomNpcGender(`${sceneId}:${preset.id}`, preset.name),
|
||||
hostileNpcPresetId: preset.id,
|
||||
monsterPresetId: preset.id,
|
||||
initialAffinity: -40,
|
||||
hostile: true,
|
||||
recruitable: false,
|
||||
functions: ['fight'],
|
||||
attributeProfile: preset.attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function isHostileSceneNpc(npc: SceneNpc) {
|
||||
return Boolean(npc.hostile || npc.monsterPresetId || (npc.initialAffinity ?? 0) < 0);
|
||||
}
|
||||
|
||||
export function getSceneHostileNpcs(scene: { npcs?: SceneNpc[] } | null | undefined) {
|
||||
return (scene?.npcs ?? []).filter(isHostileSceneNpc);
|
||||
}
|
||||
|
||||
export function getSceneFriendlyNpcs(scene: { npcs?: SceneNpc[] } | null | undefined) {
|
||||
return (scene?.npcs ?? []).filter(npc => !isHostileSceneNpc(npc));
|
||||
}
|
||||
|
||||
export function buildEncounterFromSceneNpc(
|
||||
npc: SceneNpc,
|
||||
xMeters?: number,
|
||||
): Encounter {
|
||||
const hostileNpcPresetId = npc.hostileNpcPresetId ?? npc.monsterPresetId;
|
||||
const monsterPresetId = npc.monsterPresetId ?? npc.hostileNpcPresetId;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
hostileNpcPresetId,
|
||||
monsterPresetId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
xMeters,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
hostile: isHostileSceneNpc(npc),
|
||||
attributeProfile: npc.attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profile: CustomWorldProfile): SceneNpc {
|
||||
const attributeProfile = npc.attributeProfile
|
||||
?? buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema: resolveAttributeSchema(WorldType.CUSTOM, profile),
|
||||
textBlocks: [npc.role, npc.description, npc.motivation],
|
||||
}).profile;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
avatar: npc.name.slice(0, 1) || '?',
|
||||
description: `${npc.description} 动机:${npc.motivation}`,
|
||||
gender: inferCustomNpcGender(npc.id, npc.name),
|
||||
recruitable: true,
|
||||
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
|
||||
return kind === 'camp' ? 'custom-scene-camp' : `custom-scene-landmark-${index + 1}`;
|
||||
}
|
||||
|
||||
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const allImages = collectAllImagePool();
|
||||
const imageOffset = hashText(profile.id || profile.name) % Math.max(1, allImages.length);
|
||||
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
|
||||
const baseMonsterPool: string[] = getScenePresetsByWorld(anchorWorldType)
|
||||
.flatMap((scene: ScenePreset) => scene.monsterIds)
|
||||
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
|
||||
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];
|
||||
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
|
||||
const campSceneId = buildCustomSceneId('camp');
|
||||
const landmarkSceneIds = profile.landmarks.map((_, index) => buildCustomSceneId('landmark', index));
|
||||
const campNpcs = playableCharacters.slice(1).map(character => {
|
||||
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
|
||||
return npc
|
||||
? {
|
||||
...npc,
|
||||
role: `${character.title} / 可扮演角色`,
|
||||
description: `${character.description} 这名角色属于自定义世界“${profile.name}”的可扮演阵容。`,
|
||||
}
|
||||
: null;
|
||||
}).filter(Boolean) as SceneNpc[];
|
||||
|
||||
const customStoryNpcs = profile.storyNpcs.map(npc => buildCustomSceneNpc(npc, profile));
|
||||
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
|
||||
const customScenes: ScenePreset[] = [
|
||||
{
|
||||
id: campSceneId,
|
||||
name: buildCustomCampSceneName(profile),
|
||||
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
|
||||
worldType: WorldType.CUSTOM,
|
||||
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
|
||||
connectedSceneIds: landmarkSceneIds.slice(0, 3),
|
||||
forwardSceneId: landmarkSceneIds[0],
|
||||
monsterIds: [],
|
||||
treasureHints: [
|
||||
`${profile.name}地图残页`,
|
||||
...profile.landmarks.slice(0, 3).map(landmark => `${landmark.name}的旧线索`),
|
||||
].slice(0, 4),
|
||||
npcs: campNpcs,
|
||||
},
|
||||
...profile.landmarks.map((landmark, index): ScenePreset => {
|
||||
const sceneNpcs = customStoryNpcs.slice(index * chunkSize, (index + 1) * chunkSize);
|
||||
const connectedSceneIds: string[] = [
|
||||
campSceneId,
|
||||
landmarkSceneIds[(index - 1 + landmarkSceneIds.length) % landmarkSceneIds.length],
|
||||
landmarkSceneIds[(index + 1) % landmarkSceneIds.length],
|
||||
]
|
||||
.filter((sceneId): sceneId is string => Boolean(sceneId))
|
||||
.filter((sceneId, sceneIndex, array) => array.indexOf(sceneId) === sceneIndex);
|
||||
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
|
||||
const monsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
|
||||
const hostileNpcs = monsterIds
|
||||
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), anchorWorldType, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
|
||||
return {
|
||||
id: buildCustomSceneId('landmark', index),
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
worldType: WorldType.CUSTOM,
|
||||
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
|
||||
connectedSceneIds,
|
||||
forwardSceneId: connectedSceneIds.find(sceneId => sceneId !== campSceneId) ?? campSceneId,
|
||||
monsterIds,
|
||||
treasureHints: [
|
||||
`${landmark.name}的旧线索`,
|
||||
`${profile.name}相关遗物`,
|
||||
profile.storyNpcs[index]?.name ? `${profile.storyNpcs[index]!.name}留下的痕迹` : `${profile.playerGoal.slice(0, 10)}相关痕迹`,
|
||||
],
|
||||
npcs: [...sceneNpcs, ...hostileNpcs],
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return customScenes;
|
||||
}
|
||||
|
||||
function makeNpc(
|
||||
id: string,
|
||||
name: string,
|
||||
role: string,
|
||||
avatar: string,
|
||||
description: string,
|
||||
gender?: KnownSceneNpcGender,
|
||||
): SceneNpc {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
avatar,
|
||||
description,
|
||||
gender,
|
||||
recruitable: true,
|
||||
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
};
|
||||
}
|
||||
|
||||
function pickKnownSceneNpcGender(...candidates: Array<SceneNpc['gender'] | undefined>): KnownSceneNpcGender | null {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === 'male' || candidate === 'female') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSceneNpcGender(
|
||||
npcId: string,
|
||||
...candidates: Array<SceneNpc['gender'] | undefined>
|
||||
): KnownSceneNpcGender {
|
||||
const gender = pickKnownSceneNpcGender(...candidates, SCENE_NPC_GENDERS[npcId]);
|
||||
if (gender) {
|
||||
return gender;
|
||||
}
|
||||
throw new Error(`场景角色 "${npcId}" 缺少明确性别。`);
|
||||
}
|
||||
|
||||
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
|
||||
const npcs: SceneNpc[] = [];
|
||||
|
||||
for (const character of PRESET_CHARACTERS) {
|
||||
const characterId = character.id;
|
||||
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
|
||||
if (sceneIds.includes(sceneId)) {
|
||||
const npc = buildCharacterNpc(characterId, worldType, getRuntimeCustomWorldProfile());
|
||||
if (npc) {
|
||||
npcs.push(npc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return npcs;
|
||||
}
|
||||
|
||||
function buildSceneNpcAttributeProfile(
|
||||
npc: SceneNpc,
|
||||
worldType: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
if (npc.attributeProfile) return npc.attributeProfile;
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema: resolveAttributeSchema(worldType, customWorldProfile),
|
||||
textBlocks: [npc.role, npc.name, npc.description],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
function mergeNpcs(
|
||||
characterNpcs: SceneNpc[],
|
||||
extraNpcs: SceneNpc[],
|
||||
worldType: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
const map = new Map<string, SceneNpc>();
|
||||
[...characterNpcs, ...extraNpcs].forEach(npc => {
|
||||
const override = SCENE_NPC_OVERRIDES[npc.id] ?? {};
|
||||
const mergedNpc = {
|
||||
...npc,
|
||||
...override,
|
||||
gender: resolveSceneNpcGender(npc.id, override.gender, npc.gender),
|
||||
} satisfies SceneNpc;
|
||||
map.set(npc.id, {
|
||||
...mergedNpc,
|
||||
attributeProfile: buildSceneNpcAttributeProfile(mergedNpc, worldType, customWorldProfile),
|
||||
});
|
||||
});
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
const WUXIA_SCENES: SceneTemplate[] = [
|
||||
{
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-13', 'monster-08'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: ['???????', '??????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-bamboo-woodcutter', '樵夫老周', '樵夫', '樵', '常在竹海边缘砍柴,对附近路数和兽踪了如指掌。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-mountain-gate',
|
||||
name: '山门石阶',
|
||||
description: '青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-04', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
|
||||
forwardSceneId: 'wuxia-temple-forecourt',
|
||||
treasureHints: ['瑁傜紳涓殑閾滃專', '鐭崇嫯搴曞骇鏃侀仐钀界殑浠ょ墝'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-gate-disciple', '守山弟子', '门派弟子', '守', '一直盯着石阶尽头的动静,像在等某位重要来客。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-rain-street',
|
||||
name: '雨夜长街',
|
||||
description: '长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-11', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-ferry-bridge',
|
||||
treasureHints: ['???????', '????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-night-vendor', '夜灯摊主', '摊主', '灯', '深夜仍在街口守着灯摊,见过太多不该见的人。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-ruined-village',
|
||||
name: '荒村断垣',
|
||||
description: '残墙和空屋挤成一团,风里总像夹着旧哭声与游荡脚步。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-03', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['????????', '????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-village-remnant', '守村妇人', '遗民', '民', '不肯离开这片断垣,似乎还在等某个人归来。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-ferry-bridge',
|
||||
name: '古桥渡口',
|
||||
description: '桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-04', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['???????', '????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-ferryman', '老渡工', '渡工', '渡', '常年摆渡,看人看路都很准,有些话只肯对识货的人说。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-mist-woods',
|
||||
name: '雾林小径',
|
||||
description: '晨雾久久不散,树影像一层层压下来,适合毒蛇与潜伏兽狩猎。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-08', 'monster-13', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
|
||||
forwardSceneId: 'wuxia-ruined-village',
|
||||
treasureHints: ['缂犲湪鏍戞牴涓婄殑閿﹀泭', '琚浘姘存场婀跨殑鍦板浘娈嬮〉'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-hunter', '追迹猎户', '猎户', '猎', '脚边总带着兽夹和草药,对林中异动非常敏感。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-border-camp',
|
||||
name: '边关营地',
|
||||
description: '营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-18', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-quartermaster', '军需官', '营地官', '营', '管着兵器和粮草,对各路来客始终保持戒心。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-crypt-passage',
|
||||
name: '地宫通道',
|
||||
description: '地砖尽头传来回声,石壁上的裂隙像无数只正在张望的眼。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-03', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
|
||||
forwardSceneId: 'wuxia-mine-depths',
|
||||
treasureHints: ['???????', '????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-tomb-scholar', '探碑书生', '学者', '碑', '抱着拓本在地宫里转来转去,似乎在找某段缺失铭文。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-temple-forecourt',
|
||||
name: '寺庙前庭',
|
||||
description: '香灰、古钟和石灯挤在一处,清净里始终藏着不安的回响。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-04', 'monster-03'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
|
||||
forwardSceneId: 'wuxia-crypt-passage',
|
||||
treasureHints: ['????????', '???????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-temple-host', '守庙僧', '僧人', '僧', '白日扫院夜里守灯,似乎知道地宫里曾封过什么。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-mine-depths',
|
||||
name: '矿道深处',
|
||||
description: '碎石与矿灯照出曲折坑道,深处总有重物挪动与甲壳摩擦声。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-06', 'monster-18'],
|
||||
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-forge-works',
|
||||
treasureHints: ['鐭胯溅澶瑰眰閲岀殑閾剁洅', '鍩嬪湪鐭挎福涓殑绮鹃搧'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-miner', '老矿头', '矿工', '矿', '靠耳朵分辨坑道深处的回响,比谁都先知道危险会从哪边来。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-forge-works',
|
||||
name: '铸坊工场',
|
||||
description: '火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-18', 'monster-04'],
|
||||
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-palace-court',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-blacksmith', '老铸匠', '铸匠', '铸', '看一眼兵器缺口就知道你刚从什么地方杀出来。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wuxia-palace-court',
|
||||
name: '宫苑内庭',
|
||||
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-11', 'monster-13'],
|
||||
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-maid', '旧宫侍女', '宫人', '侍', '嘴上说得少,却总知道哪条回廊最近不该过去。'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
{
|
||||
id: 'xianxia-cloud-gate',
|
||||
name: '云海仙门',
|
||||
description: '云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-celestial-corridor',
|
||||
treasureHints: ['?????????', '????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-gate-attendant', '守门灵官', '门官', '门', '站在门阙侧旁观来者,像在等一份迟迟未到的回报。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-floating-isle',
|
||||
name: '悬空仙岛',
|
||||
description: '浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-12', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['??????????', '???????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cloud-hermit', '云栖散修', '散修', '云', '常坐在浮岛边缘打坐,对天风和禁制的变化很敏感。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-celestial-corridor',
|
||||
name: '天宫长廊',
|
||||
description: '廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-14'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-thunder-altar',
|
||||
treasureHints: ['??????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-palace-page', '抄经侍者', '侍者', '卷', '抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-herb-garden',
|
||||
name: '灵药花圃',
|
||||
description: '灵草灵花层层叠开,香气诱人,却也最容易养出食灵的怪物。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-herbal-keeper', '药圃执事', '药师', '药', '守着花圃记录灵植开谢,也清楚哪些地方最近长出了怪东西。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-jade-cavern',
|
||||
name: '寒玉洞天',
|
||||
description: '洞壁结着寒玉光泽,地面湿滑,水灵和阴性异物都爱停在这里。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-10', 'monster-12', 'monster-20'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['瀵掔帀瑁傞殭閲岀殑鐏甸珦', '鍐伴潰涓嬮棯鐫€鍏夌殑璐濆專'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cold-scholar', '寒洞客', '访客', '玉', '在洞天里采样寒玉碎屑,像在研究更深处的封禁。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-molten-realm',
|
||||
name: '熔岩秘境',
|
||||
description: '热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-14', 'monster-10'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-fire-forger', '熔炉匠修', '炼匠', '炉', '在热浪里锻器不歇,见惯灵火失控的后果。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-thunder-altar',
|
||||
name: '雷殿祭坛',
|
||||
description: '祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-star-vessel',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-thunder-keeper', '祭雷守使', '守使', '雷', '总站在祭坛边缘看天,像在确认下一道雷会落到哪里。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-star-vessel',
|
||||
name: '星舟甲板',
|
||||
description: '甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-12', 'monster-16', 'monster-02'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-floating-isle',
|
||||
treasureHints: ['????????', '??????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-helmsman', '星舟舵手', '舵手', '舟', '守着老旧星舟的航线图,对高空中的异动异常敏感。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-moon-lake',
|
||||
name: '月湖仙洲',
|
||||
description: '湖光像铺开的镜面,水灵、章灵与花影都可能从月色里浮出来。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-20', 'monster-14', 'monster-15'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
|
||||
forwardSceneId: 'xianxia-herb-garden',
|
||||
treasureHints: ['婀栧哺杈规紓鏉ョ殑鐜夌洅', '鏈堣壊涓嬭嫢闅愯嫢鐜扮殑閾堕搩'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-lake-watcher', '湖畔琴师', '琴师', '琴', '常在月湖边抚琴,像在等某段旋律把什么引出来。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-ancient-ruins',
|
||||
name: '古仙遗迹',
|
||||
description: '残碑、断墙与旧阵纹密密叠在一起,最容易招来书妖和骨灵残念。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-05', 'monster-12'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['娈嬮樀涓績鍩嬬潃鐨勭帀绠€', '鍊掑纰戞煴閲岀殑灏忓專'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-ruin-scholar', '寻迹司录', '司录', '录', '拿着一卷旧图在断墙间比对,像快要找到重要坐标。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-sacred-tree',
|
||||
name: '神木秘境',
|
||||
description: '古树根系盘踞成殿,枝叶遮天,最易孕出噬灵花与窥视灵眼。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['?????????', '??????????'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-tree-ward', '守木灵侍', '灵侍', '木', '一直绕着古树巡看,像是担心有人惊动树心。'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'xianxia-waterfall-cliff',
|
||||
name: '飞瀑仙崖',
|
||||
description: '瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-12', 'monster-20', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-cloud-gate',
|
||||
treasureHints: ['鐎戝箷鍚庨棯鐫€鍏夌殑鐭冲專', '宕栬竟钘や笂鎸傜潃鐨勬姢韬搩'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cliff-scout', '崖巡女修', '巡修', '崖', '长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[] {
|
||||
const imagePool = collectWorldImagePool(templates[0]?.worldType ?? WorldType.WUXIA, templates.length);
|
||||
|
||||
return templates.map((template, index) => {
|
||||
const characterNpcs = buildCharacterNpcPool(template.id, template.worldType);
|
||||
const hostileNpcs = template.monsterIds
|
||||
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
const sceneOverride = SCENE_OVERRIDES[template.id] ?? {};
|
||||
return {
|
||||
...template,
|
||||
...sceneOverride,
|
||||
imageSrc: sceneOverride.imageSrc ?? imagePool[index] ?? imagePool[0] ?? '',
|
||||
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
|
||||
} satisfies ScenePreset;
|
||||
});
|
||||
}
|
||||
|
||||
const ALL_SCENE_PRESETS: ScenePreset[] = [
|
||||
...buildScenePoolFromTemplates(WUXIA_SCENES),
|
||||
...buildScenePoolFromTemplates(XIANXIA_SCENES),
|
||||
];
|
||||
|
||||
export function getScenePresetsByWorld(worldType: WorldType): ScenePreset[] {
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
const profile = getRuntimeCustomWorldProfile();
|
||||
return profile ? buildCustomScenePresets(profile) : [];
|
||||
}
|
||||
return ALL_SCENE_PRESETS.filter(scene => scene.worldType === worldType);
|
||||
}
|
||||
|
||||
export function getScenePreset(worldType: WorldType, index: number): ScenePreset | null {
|
||||
const scenes = getScenePresetsByWorld(worldType);
|
||||
if (scenes.length === 0) return null;
|
||||
return scenes[((index % scenes.length) + scenes.length) % scenes.length] ?? null;
|
||||
}
|
||||
|
||||
export function getScenePresetById(worldType: WorldType, sceneId: string | null | undefined): ScenePreset | null {
|
||||
if (!sceneId) return null;
|
||||
return getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null;
|
||||
}
|
||||
|
||||
export function getScenePresetOverrideById(sceneId: string) {
|
||||
return SCENE_OVERRIDES[sceneId] ?? null;
|
||||
}
|
||||
|
||||
export function getSceneNpcPresetOverrideById(npcId: string) {
|
||||
return SCENE_NPC_OVERRIDES[npcId] ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterHomeScenePreset(worldType: WorldType, characterId: string) {
|
||||
const sceneId = getCharacterHomeSceneId(worldType, characterId);
|
||||
return sceneId ? getScenePresetById(worldType, sceneId) : null;
|
||||
}
|
||||
|
||||
const WORLD_CAMP_SCENE_IDS: Record<Exclude<WorldType, WorldType.CUSTOM>, string> = {
|
||||
[WorldType.WUXIA]: 'wuxia-border-camp',
|
||||
[WorldType.XIANXIA]: 'xianxia-star-vessel',
|
||||
};
|
||||
|
||||
export function getWorldCampScenePreset(worldType: WorldType) {
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
|
||||
}
|
||||
return getScenePresetById(worldType, WORLD_CAMP_SCENE_IDS[worldType]);
|
||||
}
|
||||
|
||||
export function getConnectedScenePresets(worldType: WorldType, sceneId: string | null | undefined) {
|
||||
const currentScene = getScenePresetById(worldType, sceneId);
|
||||
if (!currentScene) return [];
|
||||
|
||||
return currentScene.connectedSceneIds
|
||||
.map(id => getScenePresetById(worldType, id))
|
||||
.filter(Boolean) as ScenePreset[];
|
||||
}
|
||||
|
||||
export function getForwardScenePreset(worldType: WorldType, sceneId: string | null | undefined) {
|
||||
const currentScene = getScenePresetById(worldType, sceneId);
|
||||
if (!currentScene?.forwardSceneId) return null;
|
||||
return getScenePresetById(worldType, currentScene.forwardSceneId);
|
||||
}
|
||||
|
||||
export function getTravelScenePreset(worldType: WorldType, sceneId: string | null | undefined) {
|
||||
const currentScene = getScenePresetById(worldType, sceneId);
|
||||
if (!currentScene) return null;
|
||||
|
||||
const connectedScenes = getConnectedScenePresets(worldType, sceneId);
|
||||
return connectedScenes.find(scene => scene.id !== currentScene.forwardSceneId) ?? connectedScenes[0] ?? null;
|
||||
}
|
||||
|
||||
export function getSceneNpcById(worldType: WorldType, sceneId: string | null | undefined, npcId: string | undefined) {
|
||||
if (!npcId) return null;
|
||||
const scene = getScenePresetById(worldType, sceneId);
|
||||
return scene?.npcs.find(npc => npc.id === npcId) ?? null;
|
||||
}
|
||||
|
||||
export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: string | null | undefined) {
|
||||
const scene = getScenePresetById(worldType, sceneId);
|
||||
if (!scene) {
|
||||
return '当前区域暂无可用实体目录。';
|
||||
}
|
||||
|
||||
const monsterText = scene.monsterIds.length > 0
|
||||
? scene.monsterIds
|
||||
.map(monsterId => getMonsterPresetById(worldType, monsterId)?.name ?? monsterId)
|
||||
.join('、')
|
||||
: '暂无明确怪物';
|
||||
|
||||
const hostileNpcs = getSceneHostileNpcs(scene);
|
||||
const friendlyNpcs = getSceneFriendlyNpcs(scene);
|
||||
const hostileNpcText = hostileNpcs.length > 0
|
||||
? hostileNpcs.map(npc => npc.name).join('、')
|
||||
: '暂无明确敌对角色';
|
||||
const friendlyNpcText = friendlyNpcs.length > 0
|
||||
? friendlyNpcs.map(npc => `${npc.name}(${npc.role})`).join('、')
|
||||
: '暂无明确场景角色';
|
||||
const treasureText = scene.treasureHints.length > 0
|
||||
? scene.treasureHints.join('、')
|
||||
: '暂无明确宝藏线索';
|
||||
|
||||
return [
|
||||
`当前怪物:${monsterText}`,
|
||||
`当前敌对角色:${hostileNpcText}`,
|
||||
`当前场景角色:${friendlyNpcText}`,
|
||||
`当前宝藏线索:${treasureText}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
|
||||
1
src/data/stateFunctionOverrides.json
Normal file
1
src/data/stateFunctionOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
579
src/data/stateFunctions.ts
Normal file
579
src/data/stateFunctions.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import {
|
||||
Character,
|
||||
FunctionCategory,
|
||||
PlayerStateMode,
|
||||
SceneDirective,
|
||||
SceneMonster,
|
||||
SkillStyle,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CAMP_TRAVEL_HOME_FUNCTION,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
STATE_FUNCTION_DEFINITIONS as SPLIT_STATE_FUNCTION_DEFINITIONS,
|
||||
STATE_FUNCTION_PROMPT_DESCRIPTIONS as SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS,
|
||||
} from './functionCatalog';
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getTravelScenePreset,
|
||||
getWorldCampScenePreset,
|
||||
} from './scenePresets';
|
||||
import stateFunctionOverridesJson from './stateFunctionOverrides.json';
|
||||
|
||||
export interface FunctionEffectConfig {
|
||||
damageMultiplier?: number;
|
||||
incomingDamageMultiplier?: number;
|
||||
healAmount?: number;
|
||||
manaRestore?: number;
|
||||
cooldownTickBonus?: number;
|
||||
turnTimeMultiplier?: number;
|
||||
skillWeights?: Partial<Record<SkillStyle, number>>;
|
||||
escapeDurationMs?: number;
|
||||
escapeDistance?: number;
|
||||
monsterLagStart?: number;
|
||||
monsterLagEnd?: number;
|
||||
sceneShift?: number;
|
||||
enterBattle?: boolean;
|
||||
}
|
||||
|
||||
export type FunctionVisualConfig = Omit<SceneDirective, 'monsterChanges'> & {
|
||||
monsterActionTemplate?: string;
|
||||
monsterAnimation?: 'idle' | 'move' | 'attack';
|
||||
monsterMoveMeters?: number;
|
||||
};
|
||||
|
||||
export interface StateFunctionDefinition {
|
||||
id: string;
|
||||
state: PlayerStateMode;
|
||||
category: FunctionCategory;
|
||||
text: string;
|
||||
description: string;
|
||||
visual: FunctionVisualConfig;
|
||||
effect: FunctionEffectConfig;
|
||||
}
|
||||
|
||||
export type StateFunctionDefinitionOverride = Partial<
|
||||
Pick<StateFunctionDefinition, 'state' | 'category' | 'text' | 'description'>
|
||||
> & {
|
||||
visual?: Partial<FunctionVisualConfig>;
|
||||
effect?: Partial<FunctionEffectConfig>;
|
||||
};
|
||||
|
||||
export type StateFunctionOverrideMap = Record<
|
||||
string,
|
||||
StateFunctionDefinitionOverride
|
||||
>;
|
||||
|
||||
export interface FunctionAvailabilityContext {
|
||||
worldType: WorldType;
|
||||
playerCharacter: Character | null;
|
||||
inBattle: boolean;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
monsters: SceneMonster[];
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
}
|
||||
|
||||
const FUNCTION_OPTION_PRIORITIES: Record<string, number> = {
|
||||
[CAMP_TRAVEL_HOME_FUNCTION.id]: 4,
|
||||
[NPC_PREVIEW_TALK_FUNCTION.id]: 3,
|
||||
[NPC_RECRUIT_FUNCTION.id]: 3,
|
||||
[NPC_CHAT_FUNCTION.id]: 1,
|
||||
};
|
||||
|
||||
const MODEL_PRIORITY_LOCKED_OPTION_COUNT = 2;
|
||||
|
||||
export function getFunctionPromptDescription(
|
||||
functionId: string,
|
||||
fallback?: string,
|
||||
) {
|
||||
return (
|
||||
SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS[functionId] ??
|
||||
fallback ??
|
||||
functionId
|
||||
);
|
||||
}
|
||||
|
||||
const STATE_FUNCTION_OVERRIDES =
|
||||
stateFunctionOverridesJson as StateFunctionOverrideMap;
|
||||
const BASE_FUNCTIONS = [...SPLIT_STATE_FUNCTION_DEFINITIONS];
|
||||
|
||||
function mergeStateFunctionDefinition(
|
||||
definition: StateFunctionDefinition,
|
||||
override?: StateFunctionDefinitionOverride,
|
||||
): StateFunctionDefinition {
|
||||
if (!override) {
|
||||
return {
|
||||
...definition,
|
||||
visual: { ...definition.visual },
|
||||
effect: {
|
||||
...definition.effect,
|
||||
skillWeights: definition.effect.skillWeights
|
||||
? { ...definition.effect.skillWeights }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mergedSkillWeights = override.effect?.skillWeights
|
||||
? {
|
||||
...(definition.effect.skillWeights ?? {}),
|
||||
...override.effect.skillWeights,
|
||||
}
|
||||
: definition.effect.skillWeights
|
||||
? { ...definition.effect.skillWeights }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...definition,
|
||||
...override,
|
||||
visual: {
|
||||
...definition.visual,
|
||||
...(override.visual ?? {}),
|
||||
},
|
||||
effect: {
|
||||
...definition.effect,
|
||||
...(override.effect ?? {}),
|
||||
skillWeights: mergedSkillWeights,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyRuntimeFunctionAdjustments(
|
||||
definitions: StateFunctionDefinition[],
|
||||
) {
|
||||
return definitions
|
||||
.filter((definition) => definition.id !== 'idle_follow_clue')
|
||||
.map((definition) => {
|
||||
if (definition.id === 'idle_explore_forward') {
|
||||
return {
|
||||
...definition,
|
||||
text: '继续向前探索',
|
||||
description:
|
||||
'沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。',
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return {
|
||||
...definition,
|
||||
text: '主动出声试探',
|
||||
description:
|
||||
'主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。',
|
||||
};
|
||||
}
|
||||
|
||||
return definition;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildStateFunctionDefinitions(
|
||||
overrides: StateFunctionOverrideMap = STATE_FUNCTION_OVERRIDES,
|
||||
) {
|
||||
return applyRuntimeFunctionAdjustments(
|
||||
BASE_FUNCTIONS.map((definition) =>
|
||||
mergeStateFunctionDefinition(definition, overrides[definition.id]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const ALL_FUNCTIONS = buildStateFunctionDefinitions();
|
||||
|
||||
function hasAliveMonsters(monsters: SceneMonster[]) {
|
||||
return monsters.some((monster) => monster.hp > 0);
|
||||
}
|
||||
|
||||
function getPrimaryMonster(context: FunctionAvailabilityContext) {
|
||||
return (
|
||||
context.monsters.find((monster) => monster.hp > 0) ??
|
||||
context.monsters[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getPlayerHpRatio(context: FunctionAvailabilityContext) {
|
||||
return context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
}
|
||||
|
||||
function getPlayerManaRatio(context: FunctionAvailabilityContext) {
|
||||
return context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
}
|
||||
|
||||
function getMonsterHpRatio(context: FunctionAvailabilityContext) {
|
||||
const monster = getPrimaryMonster(context);
|
||||
if (!monster) return 0;
|
||||
return monster.hp / Math.max(monster.maxHp, 1);
|
||||
}
|
||||
|
||||
function buildSuggestedActionText(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
const monster = getPrimaryMonster(context);
|
||||
const monsterName = monster?.name ?? '前方怪物';
|
||||
const playerHpRatio = getPlayerHpRatio(context);
|
||||
const playerManaRatio = getPlayerManaRatio(context);
|
||||
const monsterHpRatio = getMonsterHpRatio(context);
|
||||
const forwardScene = getForwardScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
);
|
||||
const travelScene = getTravelScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
);
|
||||
|
||||
const sceneName = context.currentSceneName ?? '前路';
|
||||
|
||||
if (definition.id === 'idle_explore_forward') {
|
||||
if (playerHpRatio <= 0.35)
|
||||
return `按着伤口,沿着${sceneName}继续往深处摸去`;
|
||||
if (forwardScene) return `顺着${sceneName}的路势,继续朝前方深处探去`;
|
||||
return `拨开${sceneName}前的遮挡,继续朝更深处探去`;
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return `冲着${sceneName}前方扬声试探,看是谁先被逼出来`;
|
||||
}
|
||||
|
||||
switch (definition.id) {
|
||||
case 'battle_finisher_window':
|
||||
if (monsterHpRatio <= 0.25) return `完成对${monsterName}的残血收割`;
|
||||
if (monsterHpRatio <= 0.45) return `抓住${monsterName}露出的破绽补上重击`;
|
||||
return `盯住${monsterName}的空当准备终结一击`;
|
||||
case 'battle_all_in_crush':
|
||||
if (monsterHpRatio <= 0.25) return `压上去收掉${monsterName}最后一口气`;
|
||||
if (playerHpRatio <= 0.35) return `顶着伤势强压${monsterName}赌一波强杀`;
|
||||
return `正面强压${monsterName}不给喘息`;
|
||||
case 'battle_guard_break':
|
||||
if (monsterHpRatio <= 0.35) return `砸开${monsterName}的架势直接斩落`;
|
||||
return `重击破开${monsterName}的招架`;
|
||||
case 'battle_probe_pressure':
|
||||
if (playerManaRatio <= 0.3)
|
||||
return `稳住节奏试探${monsterName},先省下灵力`;
|
||||
if (monsterHpRatio <= 0.3) return `稳步逼近,补掉${monsterName}残余血量`;
|
||||
return `稳扎稳打继续试探${monsterName}`;
|
||||
case 'battle_feint_step':
|
||||
if (monsterHpRatio <= 0.35) return `虚晃切进去收掉${monsterName}`;
|
||||
return `借假动作切进${monsterName}身前`;
|
||||
case 'battle_recover_breath':
|
||||
if (playerHpRatio <= 0.35) return '原地打坐恢复血量';
|
||||
if (playerManaRatio <= 0.3) return '收势调息回一口灵力';
|
||||
return '边守边调息稳住节奏';
|
||||
case 'battle_escape_breakout':
|
||||
if (playerHpRatio <= 0.35) return `撑着伤势先脱离${monsterName}的追杀`;
|
||||
return `转身拉开距离,甩开${monsterName}`;
|
||||
case 'idle_explore_forward':
|
||||
if (forwardScene) return `继续向前探路`;
|
||||
if (playerHpRatio <= 0.35) return '拖着伤势继续向前摸索';
|
||||
return '继续向前探索前路';
|
||||
case 'idle_travel_next_scene':
|
||||
return travelScene ? `前往${travelScene.name}` : '前往其他场景';
|
||||
case 'idle_rest_focus':
|
||||
if (playerHpRatio <= 0.35) return '原地打坐恢复气血';
|
||||
if (playerManaRatio <= 0.35) return '盘坐调息恢复灵力';
|
||||
return '原地调息整理状态';
|
||||
case 'idle_observe_signs':
|
||||
return '停步观察附近的风吹草动';
|
||||
case 'idle_follow_clue':
|
||||
return '顺着可疑痕迹继续靠近';
|
||||
case 'idle_call_out':
|
||||
return '朝前方主动出声试探';
|
||||
default:
|
||||
return definition.text;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOptionDetailText(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
const forwardScene = getForwardScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
);
|
||||
const travelScene = getTravelScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
);
|
||||
const sceneName = context.currentSceneName ?? '当前区域';
|
||||
|
||||
if (definition.id === 'idle_explore_forward') {
|
||||
return forwardScene
|
||||
? `沿着${sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。`
|
||||
: `继续深入${sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`;
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。';
|
||||
}
|
||||
|
||||
switch (definition.id) {
|
||||
case 'idle_explore_forward':
|
||||
return forwardScene
|
||||
? `沿当前路径继续深入,可能会遇到角色、怪物、宝藏……`
|
||||
: '继续向前试探这片区域,可能会遇到角色、怪物、宝藏……';
|
||||
case 'idle_travel_next_scene':
|
||||
return travelScene?.description ?? '离开当前区域,前往相邻场景继续冒险。';
|
||||
case 'idle_observe_signs':
|
||||
return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。';
|
||||
case 'idle_follow_clue':
|
||||
return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。';
|
||||
case 'idle_call_out':
|
||||
return '主动打破寂静,看看附近是谁或什么东西先有反应。';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getFunctionPriority(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
const playerHpRatio = getPlayerHpRatio(context);
|
||||
const playerManaRatio = getPlayerManaRatio(context);
|
||||
const monsterHpRatio = getMonsterHpRatio(context);
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return 5;
|
||||
}
|
||||
|
||||
switch (definition.id) {
|
||||
case 'battle_recover_breath':
|
||||
return (
|
||||
(playerHpRatio <= 0.35 ? 10 : 0) + (playerManaRatio <= 0.3 ? 6 : 0)
|
||||
);
|
||||
case 'battle_finisher_window':
|
||||
return monsterHpRatio <= 0.25 ? 10 : monsterHpRatio <= 0.45 ? 6 : 1;
|
||||
case 'battle_all_in_crush':
|
||||
return monsterHpRatio <= 0.25 ? 8 : playerHpRatio <= 0.35 ? 2 : 4;
|
||||
case 'battle_guard_break':
|
||||
return monsterHpRatio <= 0.4 ? 6 : 3;
|
||||
case 'battle_probe_pressure':
|
||||
return playerManaRatio <= 0.3 ? 8 : 4;
|
||||
case 'battle_feint_step':
|
||||
return monsterHpRatio <= 0.5 ? 5 : 3;
|
||||
case 'battle_escape_breakout':
|
||||
return playerHpRatio <= 0.2 ? 9 : playerHpRatio <= 0.35 ? 5 : 1;
|
||||
case 'idle_rest_focus':
|
||||
return playerHpRatio <= 0.35 || playerManaRatio <= 0.35 ? 8 : 2;
|
||||
case 'idle_explore_forward':
|
||||
return playerHpRatio > 0.45 ? 6 : 2;
|
||||
case 'idle_travel_next_scene':
|
||||
return playerHpRatio > 0.45 ? 5 : 3;
|
||||
case 'idle_observe_signs':
|
||||
return 4;
|
||||
case 'idle_follow_clue':
|
||||
return 5;
|
||||
case 'idle_call_out':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesCategory(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
switch (definition.category) {
|
||||
case 'battle':
|
||||
case 'escape':
|
||||
return context.inBattle && hasAliveMonsters(context.monsters);
|
||||
case 'idle':
|
||||
return !context.inBattle;
|
||||
case 'recovery':
|
||||
return definition.state === 'battle'
|
||||
? context.inBattle
|
||||
: !context.inBattle;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCampSceneContext(context: FunctionAvailabilityContext) {
|
||||
return (
|
||||
getWorldCampScenePreset(context.worldType)?.id === context.currentSceneId
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlayerStateMode(inBattle: boolean): PlayerStateMode {
|
||||
return inBattle ? 'battle' : 'idle';
|
||||
}
|
||||
|
||||
export function getAllStateFunctionDefinitions(
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
export function getFunctionsForState(
|
||||
state: PlayerStateMode,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return definitions.filter((item) => item.state === state);
|
||||
}
|
||||
|
||||
export function getFunctionById(
|
||||
functionId: string,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return definitions.find((item) => item.id === functionId) ?? null;
|
||||
}
|
||||
|
||||
export function getExecutableFunctions(
|
||||
context: FunctionAvailabilityContext,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
const state = getPlayerStateMode(context.inBattle);
|
||||
return getFunctionsForState(state, definitions)
|
||||
.filter((definition) => matchesCategory(definition, context))
|
||||
.filter(
|
||||
(definition) =>
|
||||
!(
|
||||
definition.id === 'idle_explore_forward' &&
|
||||
isCampSceneContext(context)
|
||||
),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const scoreDiff =
|
||||
getFunctionPriority(b, context) - getFunctionPriority(a, context);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function isFunctionExecutable(
|
||||
functionId: string,
|
||||
context: FunctionAvailabilityContext,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return getExecutableFunctions(context, definitions).some(
|
||||
(item) => item.id === functionId,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildFunctionCatalogText(
|
||||
context: FunctionAvailabilityContext,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return getExecutableFunctions(context, definitions)
|
||||
.map(
|
||||
(item) =>
|
||||
`- ${item.id}:${getFunctionPromptDescription(item.id, item.description)}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function resolveFunctionOption(
|
||||
functionId: string,
|
||||
context: FunctionAvailabilityContext,
|
||||
actionText?: string,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
): StoryOption | null {
|
||||
const definition = getFunctionById(functionId, definitions);
|
||||
if (!definition || !isFunctionExecutable(functionId, context, definitions))
|
||||
return null;
|
||||
|
||||
const primaryMonster =
|
||||
context.monsters.find((monster) => monster.hp > 0) ?? context.monsters[0];
|
||||
const monsterAction =
|
||||
definition.visual.monsterActionTemplate && primaryMonster
|
||||
? definition.visual.monsterActionTemplate.replaceAll(
|
||||
'{monster}',
|
||||
primaryMonster.name,
|
||||
)
|
||||
: '前方的气氛依旧紧绷';
|
||||
const suggestedActionText = buildSuggestedActionText(definition, context);
|
||||
const shouldForceSuggestedActionText =
|
||||
definition.id === 'idle_explore_forward' ||
|
||||
definition.id === 'idle_travel_next_scene';
|
||||
const resolvedActionText = shouldForceSuggestedActionText
|
||||
? suggestedActionText
|
||||
: actionText?.trim() || suggestedActionText;
|
||||
const detailText = buildOptionDetailText(definition, context);
|
||||
|
||||
return {
|
||||
functionId: definition.id,
|
||||
actionText: resolvedActionText,
|
||||
text: resolvedActionText,
|
||||
detailText,
|
||||
priority: getStoryOptionPriority(definition.id),
|
||||
visuals: {
|
||||
playerAnimation: definition.visual.playerAnimation,
|
||||
playerMoveMeters: definition.visual.playerMoveMeters,
|
||||
playerOffsetY: definition.visual.playerOffsetY,
|
||||
playerFacing: definition.visual.playerFacing,
|
||||
scrollWorld: definition.visual.scrollWorld,
|
||||
monsterChanges:
|
||||
primaryMonster && definition.visual.monsterAnimation
|
||||
? [
|
||||
{
|
||||
id: primaryMonster.id,
|
||||
action: monsterAction,
|
||||
animation: definition.visual.monsterAnimation,
|
||||
moveMeters: definition.visual.monsterMoveMeters ?? 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultFunctionIdsForContext(
|
||||
context: FunctionAvailabilityContext,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return getExecutableFunctions(context, definitions)
|
||||
.slice(0, 6)
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
export function getFunctionEffect(
|
||||
functionId: string,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return getFunctionById(functionId, definitions)?.effect ?? {};
|
||||
}
|
||||
|
||||
export function getFunctionSkillWeights(
|
||||
functionId: string,
|
||||
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
|
||||
) {
|
||||
return getFunctionById(functionId, definitions)?.effect.skillWeights ?? null;
|
||||
}
|
||||
|
||||
export function getStoryOptionPriority(functionId: string) {
|
||||
return FUNCTION_OPTION_PRIORITIES[functionId] ?? 1;
|
||||
}
|
||||
|
||||
export function sortStoryOptionsByPriority(options: StoryOption[]) {
|
||||
const normalizedOptions = options.map((option, index) => ({
|
||||
option: {
|
||||
...option,
|
||||
priority: getStoryOptionPriority(option.functionId),
|
||||
},
|
||||
index,
|
||||
}));
|
||||
|
||||
const lockedOptions = normalizedOptions
|
||||
.slice(0, MODEL_PRIORITY_LOCKED_OPTION_COUNT)
|
||||
.map((item) => item.option);
|
||||
const prioritizedOptions = normalizedOptions
|
||||
.slice(MODEL_PRIORITY_LOCKED_OPTION_COUNT)
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = (b.option.priority ?? 1) - (a.option.priority ?? 1);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((item) => item.option);
|
||||
|
||||
return [...lockedOptions, ...prioritizedOptions];
|
||||
}
|
||||
165
src/data/treasureInteractions.ts
Normal file
165
src/data/treasureInteractions.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
type TreasureInteractionAction,
|
||||
} from '../types';
|
||||
import { formatCurrency } from './economy';
|
||||
import {
|
||||
TREASURE_INSPECT_FUNCTION,
|
||||
TREASURE_LEAVE_FUNCTION,
|
||||
TREASURE_SECURE_FUNCTION,
|
||||
} from './functionCatalog';
|
||||
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
|
||||
import { buildDirectedRuntimeReward } from './runtimeItemDirector';
|
||||
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
|
||||
|
||||
export type TreasureReward = {
|
||||
items: ReturnType<typeof flattenDirectedRuntimeRewardItems>;
|
||||
hp: number;
|
||||
mana: number;
|
||||
currency: number;
|
||||
storyHint?: string;
|
||||
};
|
||||
|
||||
function buildTreasureOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
detailText: string,
|
||||
action: TreasureInteractionAction,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
detailText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'treasure',
|
||||
action,
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function buildTreasureReward(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
action: TreasureInteractionAction,
|
||||
): TreasureReward {
|
||||
const context = buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'treasure',
|
||||
encounter,
|
||||
});
|
||||
const directed = buildDirectedRuntimeReward(context, {
|
||||
seedKey: `treasure:${encounter.id ?? encounter.npcName}:${action}`,
|
||||
variant: action,
|
||||
itemCount: action === 'inspect' ? 2 : 2,
|
||||
fixedKinds:
|
||||
action === 'inspect' ? ['relic', 'consumable'] : ['relic', 'material'],
|
||||
fixedPermanence:
|
||||
action === 'inspect' ? ['permanent', 'timed'] : ['permanent', 'resource'],
|
||||
baseHp: action === 'inspect' ? 10 : 0,
|
||||
baseMana: action === 'inspect' ? 12 : 0,
|
||||
baseCurrency:
|
||||
action === 'inspect'
|
||||
? state.worldType === 'XIANXIA'
|
||||
? 34
|
||||
: 48
|
||||
: state.worldType === 'XIANXIA'
|
||||
? 22
|
||||
: 30,
|
||||
storyHint: `${encounter.npcName}里藏着与你当前构筑和现场线索贴合的战利品。`,
|
||||
});
|
||||
|
||||
return {
|
||||
items: flattenDirectedRuntimeRewardItems(directed),
|
||||
hp: directed.hp ?? 0,
|
||||
mana: directed.mana ?? 0,
|
||||
currency: directed.currency ?? 0,
|
||||
storyHint: directed.storyHint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
}: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
overrideText?: string;
|
||||
}): StoryMoment {
|
||||
const secureReward = buildTreasureReward(state, encounter, 'secure');
|
||||
const inspectReward = buildTreasureReward(state, encounter, 'inspect');
|
||||
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`你在 ${encounter.npcName} 前停下脚步。${encounter.npcDescription} 它看起来并非随意遗落,而像是被刻意留在这里。`,
|
||||
options: [
|
||||
buildTreasureOption(
|
||||
TREASURE_SECURE_FUNCTION.id,
|
||||
TREASURE_SECURE_FUNCTION.title,
|
||||
`直接带走 ${secureReward.items.map((item) => item.name).join('、')},并获得 ${formatCurrency(secureReward.currency, state.worldType)}。`,
|
||||
'secure',
|
||||
),
|
||||
buildTreasureOption(
|
||||
TREASURE_INSPECT_FUNCTION.id,
|
||||
TREASURE_INSPECT_FUNCTION.title,
|
||||
`多花些时间搜查,可额外拿到 ${inspectReward.items.map((item) => item.name).join('、')}、${formatCurrency(inspectReward.currency, state.worldType)} 与恢复收益。`,
|
||||
'inspect',
|
||||
),
|
||||
buildTreasureOption(
|
||||
TREASURE_LEAVE_FUNCTION.id,
|
||||
TREASURE_LEAVE_FUNCTION.title,
|
||||
`先把 ${encounter.npcName} 的位置和迹象记住,暂时不动它。`,
|
||||
'leave',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTreasureReward(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
action: TreasureInteractionAction,
|
||||
) {
|
||||
return buildTreasureReward(state, encounter, action);
|
||||
}
|
||||
|
||||
export function buildTreasureResultText(
|
||||
encounter: Encounter,
|
||||
action: TreasureInteractionAction,
|
||||
reward?: TreasureReward,
|
||||
) {
|
||||
if (action === 'leave') {
|
||||
return `你暂时没有触碰 ${encounter.npcName},只是把它的异常位置和痕迹牢牢记下。`;
|
||||
}
|
||||
|
||||
const itemText = reward?.items.length
|
||||
? reward.items.map((item) => item.name).join('、')
|
||||
: '零散战利品';
|
||||
const restoreParts = [
|
||||
(reward?.hp ?? 0) > 0 ? `气血 +${reward?.hp ?? 0}` : null,
|
||||
(reward?.mana ?? 0) > 0 ? `灵力 +${reward?.mana ?? 0}` : null,
|
||||
].filter(Boolean);
|
||||
const restoreText =
|
||||
restoreParts.length > 0 ? `,并恢复 ${restoreParts.join('、')}` : '';
|
||||
const currencyText = reward ? `,另得 ${reward.currency} 钱币` : '';
|
||||
const storyHint = reward?.storyHint ? ` ${reward.storyHint}` : '';
|
||||
|
||||
if (action === 'inspect') {
|
||||
return `你仔细检查了 ${encounter.npcName},顺着现场痕迹拆开机关与伪装,最终收回 ${itemText}${currencyText}${restoreText}。${storyHint}`;
|
||||
}
|
||||
|
||||
return `你迅速收下了 ${encounter.npcName} 中最关键的收获:${itemText}${currencyText}。${storyHint}`;
|
||||
}
|
||||
174
src/data/worldAttributeSchemas.ts
Normal file
174
src/data/worldAttributeSchemas.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
|
||||
import {WorldType} from '../types';
|
||||
|
||||
export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType.CUSTOM>, WorldAttributeSchema> = {
|
||||
[WorldType.WUXIA]: {
|
||||
id: 'schema:wuxia:v1',
|
||||
worldId: WorldType.WUXIA,
|
||||
schemaVersion: 1,
|
||||
schemaName: '江湖六脉',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.WUXIA,
|
||||
worldName: '武侠',
|
||||
settingSummary: '江湖、门派、旧案与人情纠葛并存。',
|
||||
tone: '克制、紧张、讲究局势与心气。',
|
||||
conflictCore: '在人情、威压与旧案之间立住自身。',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '骨势',
|
||||
definition: '扛压、顶冲、硬吃风险也不退的势头。',
|
||||
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
|
||||
negativeSignals: ['虚浮', '怯退', '一碰就散'],
|
||||
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
|
||||
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
|
||||
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '身法',
|
||||
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
|
||||
positiveSignals: ['快', '轻灵', '抢位'],
|
||||
negativeSignals: ['迟缓', '失位', '笨重'],
|
||||
combatUseText: '切线换位、闪转腾挪、争夺先手。',
|
||||
socialUseText: '应变快,擅长观察气口并顺势接话。',
|
||||
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '眼脉',
|
||||
definition: '看破破绽、拆招、识局、看穿人心的能力。',
|
||||
positiveSignals: ['识局', '洞察', '拆招'],
|
||||
negativeSignals: ['迟钝', '误判', '看不透'],
|
||||
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
|
||||
socialUseText: '判断弦外之音、试探真假、识别来意。',
|
||||
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '心焰',
|
||||
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
|
||||
positiveSignals: ['胆气', '决断', '压迫'],
|
||||
negativeSignals: ['犹疑', '软弱', '易被动摇'],
|
||||
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
|
||||
socialUseText: '立威、定调、在谈判里压住场子。',
|
||||
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '尘缘',
|
||||
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
|
||||
positiveSignals: ['通人情', '会安抚', '懂交换'],
|
||||
negativeSignals: ['生硬', '失礼', '不近人情'],
|
||||
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
|
||||
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
|
||||
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
|
||||
positiveSignals: ['稳', '续战', '调息'],
|
||||
negativeSignals: ['紊乱', '易崩', '续不上'],
|
||||
combatUseText: '续战、回气、稳住节奏与状态。',
|
||||
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
|
||||
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
|
||||
},
|
||||
],
|
||||
},
|
||||
[WorldType.XIANXIA]: {
|
||||
id: 'schema:xianxia:v1',
|
||||
worldId: WorldType.XIANXIA,
|
||||
schemaVersion: 1,
|
||||
schemaName: '灵界六轴',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.XIANXIA,
|
||||
worldName: '仙侠',
|
||||
settingSummary: '灵潮、宗门、禁制、秘境与道途交织。',
|
||||
tone: '空灵、危险、带着灾变与大道压迫。',
|
||||
conflictCore: '在裂变与因果之间稳住自我与道途。',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '道骨',
|
||||
definition: '承载道压与高强度冲击的底子。',
|
||||
positiveSignals: ['承压', '根基稳', '扛得住'],
|
||||
negativeSignals: ['根基浅', '易溃', '承载不足'],
|
||||
combatUseText: '扛住灵压、正面承受高强度对撞。',
|
||||
socialUseText: '让人感到根基扎实,值得托付重事。',
|
||||
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '灵行',
|
||||
definition: '位移、御空、转场、抢占天时地利的能力。',
|
||||
positiveSignals: ['位移', '御空', '机动'],
|
||||
negativeSignals: ['迟滞', '失位', '转场慢'],
|
||||
combatUseText: '抢位、御空、快速重整战场位置。',
|
||||
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
|
||||
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '识海',
|
||||
definition: '解析禁制、洞察因果、识破虚实的能力。',
|
||||
positiveSignals: ['洞察', '解构', '看破'],
|
||||
negativeSignals: ['迷失', '误判', '看不清'],
|
||||
combatUseText: '识破术理、找出因果节点与破绽。',
|
||||
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
|
||||
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '劫纹',
|
||||
definition: '在高危变化中强行推进、改写局势的能力。',
|
||||
positiveSignals: ['强推', '决断', '逆转'],
|
||||
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
|
||||
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
|
||||
socialUseText: '在关键谈判中拍板,推动他人表态。',
|
||||
explorationUseText: '面对异变与风险时敢于推进关键节点。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '心契',
|
||||
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
|
||||
positiveSignals: ['共鸣', '结契', '安抚'],
|
||||
negativeSignals: ['隔阂', '生硬', '难以共振'],
|
||||
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
|
||||
socialUseText: '建立信任、誓约与更深层的关系连结。',
|
||||
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
|
||||
positiveSignals: ['稳态', '回转', '续航'],
|
||||
negativeSignals: ['紊乱', '枯竭', '失衡'],
|
||||
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
|
||||
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
|
||||
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getPresetWorldAttributeSchema(worldType: Exclude<WorldType, WorldType.CUSTOM>) {
|
||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[worldType];
|
||||
}
|
||||
|
||||
export function getWorldAttributeSchema(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
if (worldType === WorldType.CUSTOM && customWorldProfile?.attributeSchema) {
|
||||
return customWorldProfile.attributeSchema;
|
||||
}
|
||||
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
|
||||
}
|
||||
|
||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
|
||||
}
|
||||
Reference in New Issue
Block a user