1
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -65,7 +65,6 @@ export type CharacterVisualGenerationPayload = {
|
||||
characterId: string;
|
||||
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
promptText: string;
|
||||
characterBriefText?: string;
|
||||
referenceImageDataUrls: string[];
|
||||
candidateCount: number;
|
||||
imageModel: string;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -932,7 +932,6 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
|
||||
try {
|
||||
const result = await generateVisualCandidatesForRole({
|
||||
characterBriefText,
|
||||
promptText: visualPromptText,
|
||||
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
|
||||
role: workingRole,
|
||||
|
||||
@@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
|
||||
@@ -74,7 +74,7 @@ function buildSession(
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
|
||||
@@ -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('剩余交谈');
|
||||
});
|
||||
|
||||
@@ -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('关系升温');
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user