333
src/prompts/characterChatPrompts.ts
Normal file
333
src/prompts/characterChatPrompts.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCustomWorldReferenceText } from '../services/customWorld';
|
||||
import { buildStoryPromptHistory } from '../services/storyHistory';
|
||||
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatPromptContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeGender(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '几乎无伤';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '充盈';
|
||||
if (ratio >= 0.7) return '稳定';
|
||||
if (ratio >= 0.45) return '尚可';
|
||||
if (ratio >= 0.2) return '偏低';
|
||||
if (ratio > 0) return '接近枯竭';
|
||||
return '耗尽';
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||||
: '更早剧情摘要:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近 3 轮剧情:暂无。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describeCharacterInfo(
|
||||
label: string,
|
||||
character: Character,
|
||||
world: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
options: {
|
||||
affinity?: number | null;
|
||||
includeUnlockProgress?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||||
const skills = character.skills.length > 0
|
||||
? character.skills
|
||||
.map(
|
||||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||||
)
|
||||
.join(' | ')
|
||||
: '无';
|
||||
const backgroundLines = options.affinity == null
|
||||
? [getCharacterPublicBackstorySummary(character, world)]
|
||||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||||
: null;
|
||||
const schemaSummary = buildSchemaSummary(schema)
|
||||
.map(slot => `${slot.name}(${slot.definition})`)
|
||||
.join(' | ');
|
||||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join(' | ');
|
||||
|
||||
return [
|
||||
`${label}姓名:${character.name}`,
|
||||
`${label}称号:${character.title}`,
|
||||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
`${label}描述:${character.description}`,
|
||||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||||
nextLockedChapter
|
||||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||||
: null,
|
||||
`${label}性格:${character.personality}`,
|
||||
`${label}世界属性框架:${schemaSummary}`,
|
||||
`${label}主要属性:${topAttributes}`,
|
||||
`${label}属性详情:${attributeDetails}`,
|
||||
`${label}技能:${skills}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||||
`场景:${context.sceneName ?? '当前区域'}`,
|
||||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||||
|
||||
return [
|
||||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
if (history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'聊天记录:',
|
||||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
const latestCharacterReply = [...conversationHistory]
|
||||
.reverse()
|
||||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
latestCharacterReply
|
||||
? `角色刚刚的回复:${latestCharacterReply}`
|
||||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
32
src/prompts/customWorldEntityActionPrompts.ts
Normal file
32
src/prompts/customWorldEntityActionPrompts.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldRoleSkill,
|
||||
} from '../types';
|
||||
|
||||
export function buildSkillActionPrompt(params: {
|
||||
role: Pick<
|
||||
CustomWorldPlayableNpc | CustomWorldNpc,
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
>;
|
||||
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
|
||||
}) {
|
||||
const { role, skill } = params;
|
||||
return [
|
||||
`${role.name},${role.title || role.role}。`,
|
||||
`技能名称:${skill.name}。`,
|
||||
skill.summary ? `技能表现:${skill.summary}。` : '',
|
||||
role.description ? `角色气质:${role.description}。` : '',
|
||||
role.personality ? `性格补充:${role.personality}。` : '',
|
||||
role.motivation ? `动作目标:${role.motivation}。` : '',
|
||||
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
8
src/prompts/customWorldOrchestratorPrompts.ts
Normal file
8
src/prompts/customWorldOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
1076
src/prompts/customWorldPrompts.ts
Normal file
1076
src/prompts/customWorldPrompts.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
src/prompts/customWorldRolePromptDefaults.ts
Normal file
57
src/prompts/customWorldRolePromptDefaults.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
return {
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
175
src/prompts/questPrompts.ts
Normal file
175
src/prompts/questPrompts.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from '../services/questTypes';
|
||||
import { buildQuestVisibilitySlice } from '../services/storyEngine/visibilityEngine';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map(moment => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary?.map(quest =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
).join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
if (!context.activeThreadIds?.length) {
|
||||
return '暂无明确激活线程';
|
||||
}
|
||||
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const labels = context.activeThreadIds.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
|
||||
return labels.join('、');
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask}`,
|
||||
`表层线:${profile.visibleLine}`,
|
||||
`当前压力:${profile.immediatePressure}`,
|
||||
profile.reactionHooks.length > 0
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function summarizeQuestVisibility(context: QuestGenerationContext) {
|
||||
const slice = buildQuestVisibilitySlice({
|
||||
issuerNarrativeProfile: context.issuerNarrativeProfile,
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
});
|
||||
|
||||
return [
|
||||
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
|
||||
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
|
||||
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const {context, scene, opportunity} = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName ?? '未知'}(${context.issuerNpcId ?? '未知'})`,
|
||||
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
629
src/prompts/qwenSpriteSheetToolPrompts.ts
Normal file
629
src/prompts/qwenSpriteSheetToolPrompts.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
stagingDirection?: string;
|
||||
defaultDetailText?: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
|
||||
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。';
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||
|
||||
const SETTING_AND_ROLE_ALIGNMENT_TEXT =
|
||||
'先从角色设定中提炼世界设定、时代氛围、阵营身份、职业职责、战斗习惯、装备结构、材质配色和情绪气质,再把这些信息落实到发型、服装层次、护具、武器、饰品、轮廓和动作节奏里,不要混入与设定无关的现代服饰、写实摄影镜头、枪械体系或其他世界观元素。';
|
||||
const CHARACTER_DETAIL_COVERAGE_TEXT =
|
||||
'角色描述需要尽量落到可视化细节:发型与脸部识别点、年龄层与气质、服装层次、护具与配饰、武器/法器类型、主色与材质、职业姿态与世界观痕迹,避免只有抽象身份词。';
|
||||
|
||||
export const DEFAULT_CHARACTER_BRIEF =
|
||||
'魔潮复苏边境城邦中的少女遗迹冒险者,Q版大头身,约 2 到 3 头身,金棕色微卷短发,琥珀色眼睛,额前碎发与侧边发束清晰,表情明亮但带警觉;穿分层轻甲、短披风和旅行短裙,皮革护腕、金属护膝、系带短靴完整可见;腰间挂药剂包、地图筒与小型护符,右手持单手短剑,主色为蜂蜜金、墨绿和旧银,轮廓利落,像长期在遗迹与荒野间行动的年轻探索者。';
|
||||
|
||||
export const PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES = [
|
||||
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
];
|
||||
|
||||
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
label: '待机循环',
|
||||
loop: true,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '原地',
|
||||
weaponRule: '武器始终在主手,位置稳定',
|
||||
sequenceLines: [
|
||||
'1-4 帧:稳定站姿,轻微呼吸起伏',
|
||||
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
|
||||
'9-12 帧:呼气回落,重心恢复',
|
||||
'13-16 帧:逐渐回到与首帧接近的站姿',
|
||||
],
|
||||
ending: '第 16 帧自然衔接第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
label: '奔跑循环',
|
||||
loop: true,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '小幅前移但角色中心基本固定',
|
||||
weaponRule: '武器始终在主手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
|
||||
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
|
||||
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
|
||||
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
|
||||
],
|
||||
ending: '第 16 帧能无缝接回第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'attack_slash',
|
||||
label: '横斩攻击',
|
||||
loop: false,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '中幅前探',
|
||||
weaponRule: '右手持武器,始终右手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:轻微收身蓄力,武器向后收',
|
||||
'5-8 帧:重心前压,挥击开始',
|
||||
'9-12 帧:斩击达到最大幅度,动作力量最强',
|
||||
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
|
||||
],
|
||||
ending: '第 16 帧停在收招后稳定姿态',
|
||||
},
|
||||
{
|
||||
id: 'hurt',
|
||||
label: '受击后仰',
|
||||
loop: false,
|
||||
defaultFps: 10,
|
||||
bodyTravel: '原地或极小后仰',
|
||||
weaponRule: '武器不要脱手,不要换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:突然受击,头肩后仰',
|
||||
'5-8 帧:身体失衡最明显',
|
||||
'9-12 帧:手臂和武器随惯性摆动',
|
||||
'13-16 帧:逐渐恢复到勉强站稳的姿态',
|
||||
],
|
||||
ending: '第 16 帧能接回 idle 或下一个动作',
|
||||
},
|
||||
{
|
||||
id: 'die',
|
||||
label: '倒地死亡',
|
||||
loop: false,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '明显倒地位移',
|
||||
weaponRule: '武器不可瞬间消失',
|
||||
sequenceLines: [
|
||||
'1-4 帧:受创失衡,重心被打断',
|
||||
'5-8 帧:身体明显下坠或后仰',
|
||||
'9-12 帧:倒地过程完成,动作幅度最大',
|
||||
'13-16 帧:停在清晰的终止姿态',
|
||||
],
|
||||
ending: '第 16 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
const ACTION_TEMPLATE_DETAILS: Record<
|
||||
QwenSpriteActionTemplateId,
|
||||
{ stagingDirection: string; defaultDetailText: string }
|
||||
> = {
|
||||
idle: {
|
||||
stagingDirection:
|
||||
'演出重点是轻呼吸、微幅重心起伏、戒备感和可循环衔接。',
|
||||
defaultDetailText:
|
||||
'保持与角色设定一致的戒备气质和职业站姿,重心稳在脚下,呼吸起伏轻微,披风、衣摆、发梢和小型饰品只有细小摆动,武器安静跟随身体微动,像角色在所属世界里随时准备探索、交涉或开战的待机瞬间。',
|
||||
},
|
||||
run: {
|
||||
stagingDirection:
|
||||
'演出重点是持续推进、步点清楚、上身稳定和装备惯性。',
|
||||
defaultDetailText:
|
||||
'跑动时要体现角色职业和世界设定中的装备重量感,身体略微前压,步幅清晰,手臂与武器顺势摆动,披风、衣摆和挂件跟着惯性后甩,像角色正穿过战场、街巷或遗迹通道迅速移动,节奏连续稳定。',
|
||||
},
|
||||
attack_slash: {
|
||||
stagingDirection:
|
||||
'演出重点是收身蓄力、爆发斩击、武器轨迹和收招稳定。',
|
||||
defaultDetailText:
|
||||
'攻击前先有短暂收身蓄力,再顺着主手武器做干净利落的前踏斩击,爆发点明确,武器轨迹贴合角色职业习惯与世界观武器设定,衣摆和挂件随发力甩动,收招后还能稳住重心,像久经战斗训练的真实角色动作。',
|
||||
},
|
||||
hurt: {
|
||||
stagingDirection:
|
||||
'演出重点是冲击反馈、短暂失衡、惯性摆动和重新稳住。',
|
||||
defaultDetailText:
|
||||
'受击瞬间要有明确冲击反馈,肩背后仰或身体侧偏,表情短促吃痛但不夸张,武器和手臂因惯性发生合理摆动,服装层次和挂件跟着抖动一下,随后努力稳住姿态,像这个角色在其世界观里真实挨到一击后的反应。',
|
||||
},
|
||||
die: {
|
||||
stagingDirection:
|
||||
'演出重点是失衡下坠、动作逐渐停尽和终止姿态清晰。',
|
||||
defaultDetailText:
|
||||
'死亡动作要先表现失衡与力量被抽离,再完成明显倒下或瘫落,武器和四肢随惯性下坠,披风、衣摆和饰品有最后一次明显摆动,最终停在清晰的终止姿态,符合角色身份、装备重量和世界氛围,不要轻飘或喜剧化。',
|
||||
},
|
||||
};
|
||||
|
||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
const template =
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((candidate) => candidate.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0];
|
||||
return {
|
||||
...template,
|
||||
...ACTION_TEMPLATE_DETAILS[template.id],
|
||||
};
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function sliceSpriteSheetFrames(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(spriteSource);
|
||||
const frameWidth = Math.floor(image.width / options.cols);
|
||||
const frameHeight = Math.floor(image.height / options.rows);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth;
|
||||
canvas.height = frameHeight;
|
||||
context.drawImage(
|
||||
image,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
0,
|
||||
0,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frames,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
outputSize?: number;
|
||||
},
|
||||
) {
|
||||
const sliced = await sliceSpriteSheetFrames(spriteSource, {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const frameSource = sliced.frames[options.frameIndex];
|
||||
|
||||
if (!frameSource) {
|
||||
throw new Error('帧索引超出范围。');
|
||||
}
|
||||
|
||||
if (!options.outputSize) {
|
||||
return frameSource;
|
||||
}
|
||||
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = options.outputSize;
|
||||
canvas.height = options.outputSize;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function replaceSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
replacementSource: string;
|
||||
},
|
||||
) {
|
||||
const spriteImage = await loadImageFromSource(spriteSource);
|
||||
const replacementImage = await loadImageFromSource(options.replacementSource);
|
||||
const frameWidth = Math.floor(spriteImage.width / options.cols);
|
||||
const frameHeight = Math.floor(spriteImage.height / options.rows);
|
||||
const rowIndex = Math.floor(options.frameIndex / options.cols);
|
||||
const colIndex = options.frameIndex % options.cols;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = spriteImage.width;
|
||||
canvas.height = spriteImage.height;
|
||||
context.drawImage(spriteImage, 0, 0);
|
||||
context.clearRect(
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
context.drawImage(
|
||||
replacementImage,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameIndices(
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameSources(
|
||||
frameDataUrls: string[],
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
|
||||
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function composeSpriteSheetFromFrames(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
cols: number;
|
||||
rows?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
padToGrid?: boolean;
|
||||
},
|
||||
) {
|
||||
if (frameSources.length === 0) {
|
||||
throw new Error('没有可用于拼接精灵表的帧。');
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
frameSources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const frameWidth =
|
||||
options.frameWidth ??
|
||||
Math.max(...images.map((image) => image.width), 1);
|
||||
const frameHeight =
|
||||
options.frameHeight ??
|
||||
Math.max(...images.map((image) => image.height), 1);
|
||||
const rows =
|
||||
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth * options.cols;
|
||||
canvas.height = frameHeight * rows;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const totalCells = options.padToGrid ? rows * options.cols : images.length;
|
||||
for (let index = 0; index < totalCells; index += 1) {
|
||||
const image = images[index];
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
const rowIndex = Math.floor(index / options.cols);
|
||||
const colIndex = index % options.cols;
|
||||
drawContainedImage(context, image, {
|
||||
x: colIndex * frameWidth,
|
||||
y: rowIndex * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
rows,
|
||||
cols: options.cols,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frameCount: frameSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
sources = PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'???2D ???????????????????????????????????????????? sprite sheet ???',
|
||||
`?????${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`?????${SUBJECT_ONLY_TEXT}`,
|
||||
`?????1:1 ??????????????????????????????????????????????????${CLEAN_BACKGROUND_TEXT}`,
|
||||
`?????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ?????????????????????????????????????/??/???????????????????????`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CHARACTER_DETAIL_COVERAGE_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSheetPrompt(options: {
|
||||
characterBrief: string;
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`???1??????????? 4x4 ? sprite sheet?? 16 ????????????????????????????????????????????????????????????????????????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`????${options.actionTemplate.label}`,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.loop ? '?' : '?'}`,
|
||||
`?????${options.actionTemplate.bodyTravel}`,
|
||||
`?????${options.actionTemplate.weaponRule}`,
|
||||
...options.actionTemplate.sequenceLines,
|
||||
`?????${options.actionTemplate.ending}`,
|
||||
'?????????????????????????????????????????????????????????????????????????????????? sprite frames?',
|
||||
options.characterBrief.trim(),
|
||||
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '???' | '???';
|
||||
}) {
|
||||
return [
|
||||
`???1??????????2??????????3???????2??${options.useNeighborLabel}?`,
|
||||
`?????????????????????????????????????????????????????????2???????1?????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${SETTING_AND_ROLE_ALIGNMENT_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} ???3???????????????? sprite sheet ??`,
|
||||
'?????????????????????????',
|
||||
`?????${options.issueText.trim() || '????????????????????'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`???????????????? ${options.actionTemplate.label}?`,
|
||||
`??????1??????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.sequenceLines.join('?')}??????${options.actionTemplate.ending}?`,
|
||||
options.useChromaKey
|
||||
? '??????????????????????????????'
|
||||
: '?????????????',
|
||||
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
`?????${options.characterBrief.trim()}`,
|
||||
'?????????????????????????????????????????',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export async function triggerDataUrlDownload(
|
||||
filename: string,
|
||||
dataUrl: string,
|
||||
) {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename: string, value: unknown) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function buildDefaultFrameOrder(frameCount: number) {
|
||||
return Array.from({ length: frameCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function restoreAllFrames(frameCount: number) {
|
||||
return buildDefaultFrameOrder(frameCount);
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_SHEET_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function buildRepairNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_REPAIR_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function moveFrameOrderItem(
|
||||
frameOrder: number[],
|
||||
frameIndex: number,
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
const currentOrderIndex = frameOrder.indexOf(frameIndex);
|
||||
if (currentOrderIndex < 0) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const targetIndex = currentOrderIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const nextOrder = [...frameOrder];
|
||||
const [item] = nextOrder.splice(currentOrderIndex, 1);
|
||||
nextOrder.splice(targetIndex, 0, item);
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
|
||||
if (activeFrames.includes(frameIndex)) {
|
||||
return activeFrames.filter((item) => item !== frameIndex);
|
||||
}
|
||||
|
||||
return [...activeFrames, frameIndex].sort((left, right) => left - right);
|
||||
}
|
||||
119
src/prompts/runtimeItemPrompts.ts
Normal file
119
src/prompts/runtimeItemPrompts.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemAiPromptInput,
|
||||
} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import { buildRuntimeItemStoryFingerprint } from '../services/storyEngine/carrierNarrativeCompiler';
|
||||
import { buildCarrierVisibilitySlice } from '../services/storyEngine/visibilityEngine';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCarrierFactId(factId: string) {
|
||||
if (factId === 'visibleClue') return '可见线索';
|
||||
if (factId === 'currentAppearanceReason') return '当前出现理由';
|
||||
if (factId === 'witnessMark') return '见证痕';
|
||||
if (factId === 'unresolvedQuestion') return '未完成问题';
|
||||
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
|
||||
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context,
|
||||
plan,
|
||||
intent: fallbackIntent,
|
||||
});
|
||||
const visibilitySlice = buildCarrierVisibilitySlice({
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
storyFingerprint: fallbackFingerprint,
|
||||
});
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
5
src/prompts/storyOrchestratorPrompts.ts
Normal file
5
src/prompts/storyOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
1881
src/prompts/storyPromptBuilders.ts
Normal file
1881
src/prompts/storyPromptBuilders.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user