This commit is contained in:
2026-04-26 14:27:48 +08:00
parent f68f4914ec
commit ea33413187
155 changed files with 8130 additions and 1740 deletions

View File

@@ -340,13 +340,18 @@ function resolveSceneCardImage(params: {
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
function collectSceneActImagePreviews(
sceneChapters: SceneChapterBlueprint[],
sharedSceneImageSrc?: string | null,
) {
const sharedImageSrc = sharedSceneImageSrc?.trim() || '';
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `${index + 1}`,
imageSrc: act.backgroundImageSrc?.trim() || '',
imageSrc: sharedImageSrc || act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
@@ -356,8 +361,11 @@ function buildFallbackSceneActImagePreviews(params: {
sceneChapters: SceneChapterBlueprint[];
sceneImageSrc?: string | null;
}) {
const actPreviews = collectSceneActImagePreviews(params.sceneChapters);
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
const actPreviews = collectSceneActImagePreviews(
params.sceneChapters,
sceneImageSrc,
);
if (actPreviews.length > 0 || !sceneImageSrc) {
return actPreviews;
@@ -778,8 +786,14 @@ export function CustomWorldEntityCatalog({
sceneId: landmark.id,
sceneName: landmark.name,
});
const firstActImageSrc =
sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
const sceneImageSrc = resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneImageSrc:
firstActImageSrc || landmarkImageById.get(landmark.id) || landmark.imageSrc,
sceneChapters,
});

View File

@@ -1,9 +1,9 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import type {
CustomWorldNpc,
@@ -18,6 +18,10 @@ import {
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
afterEach(() => {
cleanup();
});
vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
'../data/characterPresets',
@@ -63,14 +67,25 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('./rpg-runtime-shell', () => ({
RpgRuntimeShell: ({
session,
chrome,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
chrome?: { hidePlayerLevelBadge?: boolean };
}) => (
<div>
<div></div>
{chrome?.hidePlayerLevelBadge ? <div></div> : null}
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
</div>
),
@@ -208,6 +223,7 @@ function createProfileWithLandmark(): CustomWorldProfile {
createStoryRole('story-1', '顾潮音'),
createStoryRole('story-2', '闻雪汀'),
createStoryRole('story-3', '谢孤灯'),
createStoryRole('story-4', '陆听潮'),
],
landmarks: [
{
@@ -222,6 +238,29 @@ function createProfileWithLandmark(): CustomWorldProfile {
} as unknown as CustomWorldProfile;
}
function createProfileWithTwoLandmarks(): CustomWorldProfile {
return {
...createProfileWithLandmark(),
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
imageSrc: '/generated-custom-world-scenes/original-scene.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [],
},
{
id: 'landmark-2',
name: '雾灯塔',
description: '雾中仍在闪烁的旧灯塔。',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [],
},
],
} as unknown as CustomWorldProfile;
}
function LandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithLandmark());
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
@@ -255,6 +294,24 @@ function LandmarkEditorFlowHarness() {
);
}
function TwoLandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithTwoLandmarks());
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'landmark',
mode: 'edit',
id: 'landmark-1',
});
return (
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
);
}
function readLandmarkHarnessProfile() {
const content = screen.getByTestId('landmark-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
@@ -506,16 +563,13 @@ test('基本设定用分号拆分成标签展示', () => {
const profile = {
...createProfile(),
anchorContent: {
worldPromise: {
hook: '机械微生物吞并进化',
differentiator: '角色被迫寄生改造',
desiredExperience: '在失控系统里求生',
},
worldPromise:
'机械微生物吞并进化;角色被迫寄生改造;在失控系统里求生',
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
@@ -688,6 +742,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
expect(savedProfile.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(
savedSceneChapter?.acts.every(
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-scene.png',
),
).toBe(true);
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
@@ -758,6 +820,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
expect(savedProfile.camp?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(
savedSceneChapter?.acts.every(
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-camp.png',
),
).toBe(true);
});
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
@@ -795,10 +865,7 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3);
expect(savedProfile.camp?.sceneNpcIds).toEqual(
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
);
expect(savedProfile.camp?.sceneNpcIds).toContain('story-2');
expect(savedProfile.camp?.connections).toEqual([
{
targetLandmarkId: 'landmark-1',
@@ -811,6 +878,41 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
});
test('普通场景世界地图会包含开局场景并高亮当前场景', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(screen.getByRole('button', { name: '查看世界地图' }));
await waitFor(() => {
expect(screen.getByText('世界地图')).toBeTruthy();
});
expect(screen.getAllByText('潮灯居').length).toBeGreaterThan(0);
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
expect(screen.getByText('当前')).toBeTruthy();
});
test('世界地图会展示当前未保存的场景连接', async () => {
const user = userEvent.setup();
render(<TwoLandmarkEditorFlowHarness />);
await user.click(screen.getByText('北'));
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('北侧连接')).toBeNull();
});
await user.click(screen.getByRole('button', { name: '查看世界地图' }));
await waitFor(() => {
expect(screen.getByText('世界地图')).toBeTruthy();
});
expect(screen.getAllByText('雾灯塔').length).toBeGreaterThan(0);
expect(screen.getAllByText('北').length).toBeGreaterThan(0);
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
const user = userEvent.setup();
@@ -916,6 +1018,40 @@ test('场景多幕支持新增删除和调序', async () => {
expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3');
});
test('每幕角色槽位可以从当前世界所有 NPC 中选择', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
});
const savedProfile = readLandmarkHarnessProfile();
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-4');
expect(savedProfile.landmarks[0]?.sceneNpcIds).toContain('story-4');
});
test('场景幕预览会打开当前幕运行时面板', async () => {
const user = userEvent.setup();
@@ -929,7 +1065,9 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '关闭预览' }));
expect(screen.getByText('隐藏等级徽标')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '结束预览' }));
await waitFor(() => {
expect(screen.queryByText('幕预览运行时')).toBeNull();

View File

@@ -328,8 +328,8 @@ export function CustomWorldNpcPortrait({
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className={`platform-npc-portrait relative overflow-hidden rounded-2xl ${className}`}>
<div className="platform-npc-portrait__grid absolute inset-0" />
<div
className={`relative flex h-full items-center justify-center ${contentClassName}`}
>

View File

@@ -46,6 +46,14 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
),
}));
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({
RpgCreationEntityEditorModal: () => null,
default: () => null,
@@ -184,48 +192,21 @@ const baseProfile = {
description: '玩家最初落脚的旧灯塔内院。',
},
anchorContent: {
worldPromise: {
hook: '被海雾反复改写航路的群岛世界。',
differentiator: '旧灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是被迫返乡守灯人继承者。',
corePursuit: '查清沉钟异动与失控航路的真相。',
fearOfLoss: '失去家族留下的最后航路坐标。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['热血少年漫'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '首夜就撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守潮盟与沉钟会争夺航路解释权'],
hiddenCrisis: '有人借假航灯持续清洗整片群岛的旧证据',
firstTouchedConflict: '玩家回港当夜就被卷进禁航区封锁',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他掌握沉船夜的关键视角',
},
],
hiddenLines: {
hiddenTruths: ['沉钟异动和旧案灭口是同一条线'],
misdirectionHints: ['表面看像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
worldPromise:
'被海雾反复改写航路的群岛世界,旧灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉钟异动与失控航路的真相,风险是失去家族留下的最后航路坐标。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免热血少年漫。',
playerEntryPoint:
'玩家返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
hiddenLines:
'沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
landmarks: [
{
@@ -437,7 +418,7 @@ test('landmark tab previews every generated act image while keeping chapter deta
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png');
).toBe('/generated-custom-world-scenes/scene-act-1.png');
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {

View File

@@ -65,7 +65,6 @@ export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
promptText: string;
characterBriefText?: string;
referenceImageDataUrls: string[];
candidateCount: number;
imageModel: string;

View File

@@ -11,20 +11,14 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
currentTurn: 4,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有通路都要向未知代价借路。',
desiredExperience: '压迫、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的旧航路继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '一旦失败,就会再次失去唯一还活着的旧友。',
},
worldPromise:
'一个被潮雾改写航线秩序的群岛世界,所有通路都要向未知代价借路,体验压迫、潮湿、悬疑。',
playerFantasy:
'玩家是被迫返乡的旧航路继承人,目标是查清沉船夜背后的真相,失败会再次失去唯一还活着的旧友。',
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},

View File

@@ -10,16 +10,13 @@ test('custom world agent workspace renders minimum loop chat layout', () => {
sessionId: 'custom-world-agent-session-1',
currentTurn: 3,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要为每一次借路付出代价。',
desiredExperience: '压迫、悬疑、带一点海上传奇感',
},
worldPromise:
'一个被潮雾改写航线秩序的群岛世界,所有人都要为每一次借路付出代价,体验压迫、悬疑、带一点海上传奇感。',
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
@@ -76,7 +73,7 @@ test('custom world agent workspace renders minimum loop chat layout', () => {
expect(html).toContain('42%');
expect(html).toContain('输入消息');
expect(html).toContain('总结当前设定');
expect(html).toContain('补剩余设定');
expect(html).toContain('补剩余设定');
expect(html).not.toContain('世界共创');
expect(html).not.toContain(
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',

View File

@@ -8,6 +8,11 @@ import {
type SceneHostileNpc,
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
import {
ENTITY_CONTAINER_REM,
getHostileNpcSceneBottomOffsetPx,
getMirroredStageEntityLeft,
} from './GameCanvasShared';
function createCharacter(): Character {
return {
@@ -112,6 +117,18 @@ function renderEntityLayer(effectNpcId: string | null) {
}
describe('GameCanvasEntityLayer', () => {
it('uses mirrored stage anchors for player and opponent containers', () => {
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
});
it('lowers large monster sprites to the shared scene ground line', () => {
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 62})).toBe(-78);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 46})).toBe(-68);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 37})).toBe(-52);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28);
});
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');

View File

@@ -24,10 +24,10 @@ import {
getCharacterBottomOffsetPx,
getCharacterOpponentBottom,
getCompanionSlotOffset,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneEntityZIndex,
HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
@@ -262,9 +262,7 @@ export function GameCanvasEntityLayer({
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const hostileNpcBottomOffsetPx = npcMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const hostileNpcBottomOffsetPx = getHostileNpcSceneBottomOffsetPx(npcMonsterConfig);
const opponentBottom = npcCharacter
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
@@ -365,9 +363,7 @@ export function GameCanvasEntityLayer({
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulHostileBottomOffsetPx = peacefulMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const peacefulHostileBottomOffsetPx = getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig);
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
: stageLiftPx + peacefulHostileBottomOffsetPx;

View File

@@ -11,6 +11,7 @@ import {GameCanvasSceneLayer} from './GameCanvasSceneLayer';
import {
type GameCanvasProps,
getCharacterBottomOffsetPx,
getMirroredStageEntityLeft,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
@@ -77,6 +78,8 @@ export function GameCanvasRuntime({
const sideAnchor = '15%';
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
@@ -84,9 +87,15 @@ export function GameCanvasRuntime({
const playerBottomOffsetPx = getCharacterBottomOffsetPx(stageLiftPx, playerCharacter, playerOffsetY);
const playerLeft = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
: playerWorldLeft;
: scrollWorld
? playerWorldLeft
: playerStageLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
if (!scrollWorld && hostileNpc.animation !== 'attack') {
return opponentStageLeft;
}
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft

View File

@@ -2,11 +2,11 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
buildMedievalNpcVisual,
buildMedievalNpcVisualFromCustomWorldVisual,
} from '../../data/medievalNpcVisuals';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
AnimationState,
Character,
@@ -17,8 +17,8 @@ import {
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryNpcAffinityEffect,
StoryEngineMemoryState,
StoryNpcAffinityEffect,
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
@@ -70,6 +70,7 @@ export const MONSTER_RENDER_OFFSETS: Record<string, {x: number; y: number}> = {
export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
export const GENERIC_NPC_SCENE_SCALE = 1.72;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
@@ -80,7 +81,9 @@ export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
export type HostileNpcSceneAnchorConfig = {
frameHeight: number;
};
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
@@ -139,6 +142,15 @@ export function getPlayerWorldLeft(
return `calc(${sideAnchor} + ${(playerX - cameraAnchorX) * METERS_TO_PIXELS * 0.65}px)`;
}
export function getMirroredStageEntityLeft(
sideAnchor: string,
side: 'player' | 'opponent',
) {
return side === 'player'
? sideAnchor
: `calc(100% - ${sideAnchor} - ${ENTITY_CONTAINER_REM}rem)`;
}
export function getMonsterWorldLeft(
sideAnchor: string,
monsterX: number,
@@ -157,6 +169,18 @@ export function getCharacterOpponentBottom(
return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`;
}
export function getHostileNpcSceneBottomOffsetPx(
monster: HostileNpcSceneAnchorConfig | null | undefined,
) {
if (!monster) return 0;
// 怪物动画帧和角色立绘不是同一套脚底锚点,大帧需要更明显地下沉到场景地面线。
if (monster.frameHeight >= 58) return -78;
if (monster.frameHeight >= 42) return -68;
if (monster.frameHeight >= 34) return -52;
return -28;
}
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
if (monsterPresetId) return DEFAULT_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_NPC_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
@@ -292,14 +316,16 @@ export function SceneEncounterNpcSprite({
}
if (displayEncounterImageSrc) {
const transform = `${facing === 'left' ? 'scaleX(-1) ' : ''}scale(${ROLE_CHARACTER_SCENE_IMAGE_SCALE})`;
return (
<img
src={displayEncounterImageSrc}
alt={encounter.npcName}
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
className={`h-full w-full origin-bottom object-contain ${className ?? ''}`.trim()}
style={{
...DEFAULT_IMAGE_STYLE,
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
transform,
transformOrigin: 'bottom center',
}}
/>

View File

@@ -224,6 +224,7 @@ function isAgentResultStructuralBlockerResolved(
readProfileTextField(profile, [
'worldHook',
'creatorIntent.worldHook',
'anchorContent.worldPromise',
'anchorContent.worldPromise.hook',
'settingText',
]),
@@ -234,6 +235,7 @@ function isAgentResultStructuralBlockerResolved(
readProfileTextField(profile, [
'playerPremise',
'creatorIntent.playerPremise',
'anchorContent.playerEntryPoint',
'anchorContent.playerEntryPoint.openingIdentity',
'anchorContent.playerEntryPoint.openingProblem',
'anchorContent.playerEntryPoint.entryMotivation',

View File

@@ -932,7 +932,6 @@ export function RpgCreationRoleAssetStudioModal({
try {
const result = await generateVisualCandidatesForRole({
characterBriefText,
promptText: visualPromptText,
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
role: workingRole,

View File

@@ -6,14 +6,12 @@ import type { EditableCustomWorldRole } from './roleAssetStudioModel';
export function useRoleVisualCandidateWorkflow() {
const generateVisualCandidatesForRole = async (params: {
characterBriefText: string;
promptText: string;
referenceImageDataUrls: string[];
role: EditableCustomWorldRole;
sourceMode: 'text-to-image' | 'image-to-image';
}) => {
const {
characterBriefText,
promptText,
referenceImageDataUrls,
role,
@@ -24,7 +22,6 @@ export function useRoleVisualCandidateWorkflow() {
characterId: role.id,
sourceMode,
promptText,
characterBriefText,
referenceImageDataUrls,
candidateCount: 1,
imageModel: 'wan2.7-image-pro',

View File

@@ -207,48 +207,21 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
currentTurn: 0,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛。',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他知道沉船夜的另一半真相',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
worldPromise:
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。',
playerEntryPoint:
'玩家返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
hiddenLines:
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
progressPercent: 0,
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
@@ -2014,42 +1987,20 @@ test('agent result view does not keep legacy publish blockers when preview uses
...compiledAgentDraftSession.resultPreview!.preview,
settingText: '被海雾吞没的旧航路群岛',
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
worldPromise:
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。',
playerEntryPoint:
'玩家返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
keyRelationships: null,
hiddenLines:
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
creatorIntent: {
sourceMode: 'card',

View File

@@ -33,15 +33,19 @@ import type {
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileReferralInviteCenterResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileSaveArchiveSummary,
RedeemProfileReferralInviteCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
createRpgProfileRechargeOrder,
getRpgProfileReferralInviteCenter,
getRpgProfileRechargeCenter,
redeemRpgProfileReferralInviteCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -115,6 +119,7 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'saves',
'profile',
];
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
@@ -1020,6 +1025,154 @@ function AccountRechargeModal({
);
}
function ProfileReferralModal({
panel,
center,
inviteCodeInput,
isLoading,
isSubmitting,
error,
success,
onClose,
onInputChange,
onCopyInvite,
onSubmitRedeem,
}: {
panel: ProfilePopupPanel;
center: ProfileReferralInviteCenterResponse | null;
inviteCodeInput: string;
isLoading: boolean;
isSubmitting: boolean;
error: string | null;
success: string | null;
onClose: () => void;
onInputChange: (value: string) => void;
onCopyInvite: () => void;
onSubmitRedeem: () => void;
}) {
const title =
panel === 'invite'
? '邀请好友'
: panel === 'redeem'
? '填邀请码'
: '玩家社区';
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
<div className="relative w-full max-w-[24rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
aria-label={`关闭${title}`}
>
×
</button>
<div className="px-5 pb-5 pt-4">
<div className="text-center text-xl font-black">{title}</div>
{panel === 'community' ? (
<div className="mt-5 grid grid-cols-2 gap-3">
{['微信群', 'QQ群'].map((label) => (
<div
key={label}
className="rounded-xl border border-zinc-200 bg-zinc-50 p-3 text-center"
>
<div className="aspect-square rounded-lg border border-dashed border-zinc-300 bg-white" />
<div className="mt-2 text-sm font-bold text-zinc-700">
{label}
</div>
</div>
))}
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : panel === 'invite' ? (
<div className="mt-5 space-y-3">
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
<div className="text-[11px] font-bold text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</div>
<button
type="button"
onClick={onCopyInvite}
disabled={!center?.inviteCode}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
<Copy className="h-4 w-4" />
</button>
<div className="grid grid-cols-3 gap-2 text-center text-xs text-zinc-500">
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.invitedCount ?? 0}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.rewardedInviteCount ?? 0}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.todayInviterRewardRemaining ?? 0}
</div>
</div>
</div>
</div>
) : (
<div className="mt-5 space-y-3">
{center?.hasRedeemedCode ? (
<div className="rounded-xl bg-emerald-50 px-4 py-4 text-center text-sm font-bold text-emerald-700">
</div>
) : (
<>
<input
value={inviteCodeInput}
onChange={(event) => onInputChange(event.target.value)}
placeholder="输入邀请码"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black tracking-[0.14em] outline-none focus:border-[#ff4056]"
/>
<button
type="button"
onClick={onSubmitRedeem}
disabled={isSubmitting || !inviteCodeInput.trim()}
className="w-full rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
{isSubmitting ? '提交中' : '确认填写'}
</button>
</>
)}
</div>
)}
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{success ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{success}
</div>
) : null}
</div>
</div>
</div>
);
}
export function RpgEntryHomeView({
activeTab,
onTabChange,
@@ -1059,6 +1212,15 @@ export function RpgEntryHomeView({
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
useState<ProfileReferralInviteCenterResponse | null>(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
const [isSubmittingReferral, setIsSubmittingReferral] = useState(false);
const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
@@ -1162,6 +1324,61 @@ export function RpgEntryHomeView({
})
.finally(() => setSubmittingRechargeProductId(null));
};
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
setProfilePopupPanel(panel);
setReferralError(null);
setReferralSuccess(null);
if (panel === 'community') {
return;
}
setIsLoadingReferral(true);
void getRpgProfileReferralInviteCenter()
.then(setReferralCenter)
.catch((error: unknown) => {
setReferralCenter(null);
setReferralError(
error instanceof Error ? error.message : '读取邀请码失败',
);
})
.finally(() => setIsLoadingReferral(false));
};
const copyInviteInfo = () => {
if (!referralCenter?.inviteCode) {
return;
}
const inviteUrl =
typeof window === 'undefined'
? referralCenter.inviteLinkPath
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
copyText(`${referralCenter.inviteCode} ${inviteUrl}`);
setReferralSuccess('已复制');
};
const submitReferralInviteCode = () => {
if (isSubmittingReferral || !inviteCodeInput.trim()) {
return;
}
setIsSubmittingReferral(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCodeInput)
.then((response: RedeemProfileReferralInviteCodeResponse) => {
setReferralCenter(response.center);
setInviteCodeInput('');
setReferralSuccess(
response.inviteeRewardGranted ? '已获得30积分' : '填写成功',
);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setReferralError(
error instanceof Error ? error.message : '填写邀请码失败',
);
})
.finally(() => setIsSubmittingReferral(false));
};
const submitDesktopSearch = () => {
const keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
@@ -1579,9 +1796,21 @@ export function RpgEntryHomeView({
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="常用功能" detail="快捷入口" />
<div className="grid grid-cols-3 gap-3">
<ProfileShortcutButton label="邀请好友" icon={UserPlus} />
<ProfileShortcutButton label="邀请码" icon={Ticket} />
<ProfileShortcutButton label="玩家社区" icon={MessageCircle} />
<ProfileShortcutButton
label="邀请好友"
icon={UserPlus}
onClick={() => openProfilePopupPanel('invite')}
/>
<ProfileShortcutButton
label="填邀请码"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
<ProfileShortcutButton
label="玩家社区"
icon={MessageCircle}
onClick={() => openProfilePopupPanel('community')}
/>
</div>
</section>
@@ -1938,6 +2167,21 @@ export function RpgEntryHomeView({
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
</div>
);
}
@@ -2047,6 +2291,21 @@ export function RpgEntryHomeView({
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
</div>
);
}

View File

@@ -79,7 +79,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},

View File

@@ -74,7 +74,7 @@ function buildSession(
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},

View File

@@ -107,7 +107,7 @@ test('adventure panel renders system turns without special relationship labels',
expect(html).not.toContain('关系变化');
});
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {
test('adventure panel shows current act label without fixed hostile chat turns', () => {
const currentStory: StoryMoment = {
text: '断桥客仍在压着最后那半句真相。',
displayMode: 'dialogue',
@@ -122,10 +122,12 @@ test('adventure panel shows current act label and remaining turns for limited ho
turnCount: 3,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 2,
turnLimit: null,
remainingTurns: null,
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
terminationMode: 'hostile_model',
isHostileChat: true,
},
};
@@ -201,6 +203,5 @@ test('adventure panel shows current act label and remaining turns for limited ho
expect(html).toContain('当前幕');
expect(html).toContain('断桥口 · 对峙幕');
expect(html).toContain('1/3');
expect(html).toContain('剩余交谈');
expect(html).toContain('2 轮');
expect(html).not.toContain('剩余交谈');
});

View File

@@ -147,6 +147,25 @@ test('adventure panel does not show deferred hint for non-continue options with
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
});
test('adventure panel renders compact function tags before option text', () => {
const chatOption = createOption('npc_chat', '继续追问桥上的旧账');
const questOption = createOption('npc_quest_accept', '接下断桥客的委托');
const giftOption = createOption('npc_gift', '把玉牌递给柳无声');
const currentStory: StoryMoment = {
text: '你看向眼前的人。',
options: [chatOption, questOption, giftOption],
};
const html = renderPanel(currentStory, [chatOption, questOption, giftOption]);
expect(html).toContain('聊天');
expect(html).toContain('继续追问桥上的旧账');
expect(html).toContain('任务');
expect(html).toContain('接下断桥客的委托');
expect(html).toContain('送礼');
expect(html).toContain('把玉牌递给柳无声');
});
test('adventure panel shows npc chat custom input and exit button in chat mode', () => {
const optionA = createOption('npc_chat', '先听对方把话说完');
const optionB = createOption('npc_chat', '顺着这个问题继续追问');
@@ -181,7 +200,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
expect(html).toContain('退出聊天');
expect(html).toContain('输入你想对 TA 说的话');
expect(html).toContain('发送');
expect(html).not.toContain('换一换');
expect(html).toContain('换一换');
expect(html).not.toContain('关系升温');
});

View File

@@ -18,11 +18,7 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { formatCurrency } from '../../data/economy';
import { getEquipmentSlotFromItem } from '../../data/equipmentEffects';
import {
getFunctionDocumentationById,
isContinueAdventureOption,
NPC_CHAT_FUNCTION,
} from '../../data/functionCatalog';
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getHostileNpcPresetById } from '../../data/hostileNpcPresets';
import { resolveInventoryItemUseEffect } from '../../data/inventoryEffects';
import { isQuestReadyToClaim } from '../../data/questFlow';
@@ -136,22 +132,6 @@ function AdventurePanelOverlayLoadingFallback() {
);
}
function getCompactOptionDetailText(option: StoryOption) {
if (option.functionId === NPC_CHAT_FUNCTION.id) {
return (
option.detailText ||
getFunctionDocumentationById(option.functionId)?.runtime
?.compactDetailText ||
'聊聊并试探口风'
);
}
return (
getFunctionDocumentationById(option.functionId)?.runtime
?.compactDetailText || option.detailText
);
}
function getOptionActionTextClass(option: StoryOption) {
if ((option.priority ?? 1) >= 3)
return 'text-fuchsia-200 group-hover:text-fuchsia-100';
@@ -160,6 +140,67 @@ function getOptionActionTextClass(option: StoryOption) {
return 'text-zinc-300 group-hover:text-white';
}
function getOptionFunctionTagText(option: StoryOption) {
const tagByFunctionId: Record<string, string> = {
battle_all_in_crush: '战斗',
battle_attack_basic: '战斗',
battle_escape_breakout: '逃跑',
battle_feint_step: '战斗',
battle_finisher_window: '战斗',
battle_guard_break: '战斗',
battle_probe_pressure: '战斗',
battle_recover_breath: '调息',
battle_use_skill: '技能',
camp_travel_home_scene: '场景',
idle_call_out: '试探',
idle_explore_forward: '探索',
idle_follow_clue: '线索',
idle_observe_signs: '观察',
idle_rest_focus: '调息',
idle_travel_next_scene: '场景',
npc_chat: '聊天',
npc_fight: '战斗',
npc_gift: '送礼',
npc_help: '求助',
npc_leave: '离开',
npc_preview_talk: '聊天',
npc_quest_accept: '任务',
npc_quest_turn_in: '任务',
npc_recruit: '招募',
npc_spar: '切磋',
npc_trade: '交易',
story_continue_adventure: '继续',
treasure_inspect: '探查',
treasure_leave: '离开',
treasure_secure: '收取',
};
if (option.functionId.startsWith('npc_chat_quest_offer_')) {
return '任务';
}
return tagByFunctionId[option.functionId] ?? null;
}
function RpgOptionActionLabel({ option }: { option: StoryOption }) {
const tagText = getOptionFunctionTagText(option);
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{tagText ? (
<span className="shrink-0 rounded border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] leading-none text-zinc-300">
{tagText}
</span>
) : null}
<span
className={`min-w-0 break-words text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
</span>
);
}
function getDialogueTurnAlignmentClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
@@ -798,29 +839,45 @@ function RpgAdventureChoiceSection(props: {
</button>
</div>
{isNpcChatMode ? (
<button
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
) : canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : null}
<div className="flex shrink-0 items-center gap-2">
{isNpcChatMode && canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : !isNpcChatMode && canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : null}
{isNpcChatMode ? (
<button
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
) : null}
</div>
</div>
<div className="space-y-1.5">
@@ -867,12 +924,8 @@ function RpgAdventureChoiceSection(props: {
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<div className="flex items-center justify-between gap-3">
<RpgOptionActionLabel option={option} />
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
@@ -894,12 +947,8 @@ function RpgAdventureChoiceSection(props: {
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<div className="flex items-center justify-between gap-3">
<RpgOptionActionLabel option={option} />
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"

View File

@@ -1,6 +1,5 @@
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -9,6 +8,7 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/rpg-runtime-story';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
CompanionRenderState,
GameState,
@@ -58,6 +58,7 @@ export interface RpgRuntimePanelRouterProps {
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
refreshNpcChatOptions: () => boolean;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
@@ -93,6 +94,7 @@ export function RpgRuntimePanelRouter({
hideStoryOptions,
canRefreshOptions,
handleRefreshOptions,
refreshNpcChatOptions,
handleSceneTransitionChoice,
handleNpcChatInput,
exitNpcChat,
@@ -226,8 +228,18 @@ export function RpgRuntimePanelRouter({
isLoading={isLoading}
displayedOptions={displayedOptions}
hideOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
onRefreshOptions={handleRefreshOptions}
canRefreshOptions={
visibleCurrentStory.npcChatState
? visibleCurrentStory.options.length > 1
: canRefreshOptions
}
onRefreshOptions={() => {
if (visibleCurrentStory.npcChatState) {
refreshNpcChatOptions();
return;
}
handleRefreshOptions();
}}
onChoice={handleSceneTransitionChoice}
onSubmitNpcChatInput={handleNpcChatInput}
onExitNpcChat={exitNpcChat}

View File

@@ -30,6 +30,7 @@ export function RpgRuntimeShell({
entry,
companions,
audio,
chrome,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
@@ -51,6 +52,7 @@ export function RpgRuntimeShell({
canRefreshOptions,
handleRefreshOptions,
handleNpcChatInput,
refreshNpcChatOptions,
exitNpcChat,
handleMapTravelToScene,
npcUi,
@@ -167,7 +169,7 @@ export function RpgRuntimeShell({
/>
</Suspense>
{visibleGameState.playerCharacter && (
{visibleGameState.playerCharacter && !chrome?.hidePlayerLevelBadge && (
<div
className="pointer-events-none fixed z-[26] w-[4.5rem] drop-shadow-[0_2px_8px_rgba(0,0,0,0.75)]"
style={{
@@ -219,6 +221,7 @@ export function RpgRuntimeShell({
hideStoryOptions={shouldHideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
refreshNpcChatOptions={refreshNpcChatOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}

View File

@@ -1,7 +1,6 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -10,6 +9,7 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/rpg-runtime-story';
import type { BottomTab } from '../../hooks/rpg-session';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
CompanionRenderState,
@@ -76,6 +76,7 @@ export interface RpgRuntimeStageRouterProps {
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
refreshNpcChatOptions: () => boolean;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
@@ -123,6 +124,7 @@ export function RpgRuntimeStageRouter({
hideStoryOptions,
canRefreshOptions,
handleRefreshOptions,
refreshNpcChatOptions,
handleSceneTransitionChoice,
handleNpcChatInput,
exitNpcChat,
@@ -227,6 +229,7 @@ export function RpgRuntimeStageRouter({
hideStoryOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
refreshNpcChatOptions={refreshNpcChatOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}

View File

@@ -1,4 +1,3 @@
import type { BottomTab } from '../../hooks/rpg-session';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -8,6 +7,7 @@ import type {
QuestFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/rpg-runtime-story';
import type { BottomTab } from '../../hooks/rpg-session';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
Character,
@@ -35,6 +35,7 @@ export interface RpgRuntimeStoryProps {
handleRefreshOptions: () => void;
handleChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
refreshNpcChatOptions: () => boolean;
exitNpcChat: () => boolean;
handleMapTravelToScene: (sceneId: string) => boolean;
npcUi: StoryGenerationNpcUi;
@@ -69,6 +70,10 @@ export interface RpgRuntimeAudioProps {
onMusicVolumeChange: (value: number) => void;
}
export interface RpgRuntimeShellChromeOptions {
hidePlayerLevelBadge?: boolean;
}
export interface RpgRuntimeDialogueIndicator {
showPlayer: boolean;
showEncounter: boolean;
@@ -101,4 +106,5 @@ export interface RpgRuntimeShellProps {
entry: RpgEntrySessionProps;
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
chrome?: RpgRuntimeShellChromeOptions;
}