feat: complete bark battle draft publish flow
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-19 汪汪声浪创作先进入草稿结果页
|
||||||
|
|
||||||
|
- 背景:汪汪声浪轻配置表单直接发布会缺少草稿编译、资源预览、手动上传、重新生成和发布前试玩环节,创作者无法确认角色形象、UI 背景和狗叫音效替换效果。
|
||||||
|
- 决策:`bark-battle` 入口继续保持创作 Tab 内嵌轻配置表单;提交后先调用 `/api/creation/bark-battle/drafts` 生成草稿并进入 `bark-battle-result`,草稿响应必须带回 SpacetimeDB 草稿行上的稳定 `workId`、`configVersion` 和 `rulesetVersion`。结果页负责资源预览、图片槽位重新生成、四类资源手动上传、发布前试玩和最终发布;发布必须复用草稿返回的同一个 `workId`,不得在 publish 阶段重新生成作品 ID。排行榜字段暂保留兼容,但创作 UI 不展示排行榜开关。
|
||||||
|
- 影响范围:`BarkBattleConfigEditor`、`BarkBattleResultView`、`BarkBattlePreviewCard`、`PlatformEntryFlowShellImpl`、Bark Battle creation client、玩法链路文档和相关交互测试。
|
||||||
|
- 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单;点击生成草稿进入结果页;结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime,发布成功后再进入正式 runtime。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-05-14 创作页图像输入统一封装为图像组件
|
## 2026-05-14 创作页图像输入统一封装为图像组件
|
||||||
|
|
||||||
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
|
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
|
||||||
|
|||||||
@@ -124,7 +124,26 @@
|
|||||||
- 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。
|
- 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。
|
||||||
- 排行榜分榜:按 `workId + difficultyPreset + rulesetVersion` 拆分,只收录后端裁决玩家胜利的成绩。
|
- 排行榜分榜:按 `workId + difficultyPreset + rulesetVersion` 拆分,只收录后端裁决玩家胜利的成绩。
|
||||||
|
|
||||||
当前入口状态为 `visible=true`、`open=false`,创作 Tab 展示为“敬请期待”,不进入轻配置表单或 runtime。后续重新开放时仍沿用创作 Tab 内嵌轻配置表单,不再切到独立 `bark-battle-config` 阶段;runtime 退出后回到创作页并恢复汪汪声浪模板选中态。
|
当前入口沿用创作 Tab 内嵌轻配置表单,不再切到独立 `bark-battle-config` 阶段;配置提交后先进入草稿结果页,再由结果页执行资源预览、手动上传替换、重新生成、试玩和发布。runtime 从草稿试玩返回草稿结果页,从入口回退时恢复汪汪声浪模板选中态。
|
||||||
|
|
||||||
|
创作流程为:
|
||||||
|
|
||||||
|
- 创作 Tab 表单:填写作品标题、简介、主题、玩家角色设定、对手角色设定、难度和资源源。
|
||||||
|
- 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON,返回包含 `draftId`、稳定 `workId`、`configVersion` 和 `rulesetVersion` 的草稿结果。
|
||||||
|
- 资源预览:草稿结果页展示玩家形象、对手形象、UI 背景和狗叫音效槽位。
|
||||||
|
- 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。
|
||||||
|
- 重新生成:玩家形象、对手形象和 UI 背景先复用现有图片生成链路;狗叫音效暂不假装自动生成,未接专用音频生成时走手动上传。
|
||||||
|
- 试玩:在发布前使用草稿配置启动本地 runtime 预览,不写正式发布记录。
|
||||||
|
- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 调用 `POST /api/creation/bark-battle/works/publish`,发布成功后进入 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。
|
||||||
|
|
||||||
|
支持的创作者可替换内容:
|
||||||
|
|
||||||
|
- 基础信息:作品标题、简介、主题背景、玩家角色设定、对手角色设定和难度。
|
||||||
|
- 角色形象:可分别替换玩家与对手角色图片;未配置图片时继续使用狗狗预设兜底。
|
||||||
|
- UI 视觉:可替换运行态主背景图;未配置图片时继续使用主题背景兜底。
|
||||||
|
- 狗叫音效:可替换局内触发叫声的音频资源;未配置音频时不强制播放自定义音效。
|
||||||
|
|
||||||
|
这些替换槽位写入 Bark Battle 配置 JSON,发布后由 runtime 读取;计分阈值、对局时长、反作弊校验和后端裁决仍由规则集与后端控制,不能通过前端替换项改变。排行榜相关后端字段暂保留兼容,但创作 UI 不再展示排行榜开关。
|
||||||
|
|
||||||
## 方洞挑战
|
## 方洞挑战
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ describe('Bark Battle shared contracts', () => {
|
|||||||
test('default draft config fixture uses normal difficulty and camelCase fields', () => {
|
test('default draft config fixture uses normal difficulty and camelCase fields', () => {
|
||||||
const draft: BarkBattleDraftConfig = {
|
const draft: BarkBattleDraftConfig = {
|
||||||
draftId: 'draft-bark-1',
|
draftId: 'draft-bark-1',
|
||||||
|
workId: 'work-bark-1',
|
||||||
|
configVersion: 2,
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
title: '汪汪声浪挑战',
|
title: '汪汪声浪挑战',
|
||||||
description: '轻配置草稿',
|
description: '轻配置草稿',
|
||||||
themePreset: 'city-park',
|
themePreset: 'city-park',
|
||||||
playerDogSkinPreset: 'corgi',
|
playerDogSkinPreset: 'corgi',
|
||||||
opponentDogSkinPreset: 'husky',
|
opponentDogSkinPreset: 'husky',
|
||||||
|
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
|
||||||
|
opponentCharacterImageSrc: 'https://example.test/opponent.png',
|
||||||
|
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
|
||||||
|
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
|
||||||
difficultyPreset: 'normal',
|
difficultyPreset: 'normal',
|
||||||
leaderboardEnabled: true,
|
leaderboardEnabled: true,
|
||||||
updatedAt: '2026-05-13T03:00:00.000Z',
|
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||||
@@ -26,15 +33,23 @@ describe('Bark Battle shared contracts', () => {
|
|||||||
expect(draft.difficultyPreset).toBe('normal');
|
expect(draft.difficultyPreset).toBe('normal');
|
||||||
expect(Object.keys(draft)).toEqual([
|
expect(Object.keys(draft)).toEqual([
|
||||||
'draftId',
|
'draftId',
|
||||||
|
'workId',
|
||||||
|
'configVersion',
|
||||||
|
'rulesetVersion',
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
'themePreset',
|
'themePreset',
|
||||||
'playerDogSkinPreset',
|
'playerDogSkinPreset',
|
||||||
'opponentDogSkinPreset',
|
'opponentDogSkinPreset',
|
||||||
|
'playerCharacterImageSrc',
|
||||||
|
'opponentCharacterImageSrc',
|
||||||
|
'uiBackgroundImageSrc',
|
||||||
|
'barkSoundSrc',
|
||||||
'difficultyPreset',
|
'difficultyPreset',
|
||||||
'leaderboardEnabled',
|
'leaderboardEnabled',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
]);
|
]);
|
||||||
|
expect(draft.playerCharacterImageSrc).toContain('/generated-bark-battle/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('finish accepted player_win fixture exposes backend adjudication result', () => {
|
test('finish accepted player_win fixture exposes backend adjudication result', () => {
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ export type BarkBattleFinishStatus =
|
|||||||
|
|
||||||
export type BarkBattlePlayTypeId = 'bark-battle';
|
export type BarkBattlePlayTypeId = 'bark-battle';
|
||||||
|
|
||||||
export interface BarkBattleConfigEditorPayload {
|
export interface BarkBattleReplacementConfig {
|
||||||
|
playerCharacterImageSrc?: string;
|
||||||
|
opponentCharacterImageSrc?: string;
|
||||||
|
uiBackgroundImageSrc?: string;
|
||||||
|
barkSoundSrc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleConfigEditorPayload extends BarkBattleReplacementConfig {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
themePreset: string;
|
themePreset: string;
|
||||||
@@ -30,7 +37,7 @@ export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayl
|
|||||||
|
|
||||||
export interface BarkBattleWorkPublishRequest {
|
export interface BarkBattleWorkPublishRequest {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
workId?: string;
|
workId: string;
|
||||||
publishedSnapshot?: BarkBattleConfigEditorPayload;
|
publishedSnapshot?: BarkBattleConfigEditorPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +60,10 @@ export interface BarkBattlePublishedConfig {
|
|||||||
themePreset: string;
|
themePreset: string;
|
||||||
playerDogSkinPreset: string;
|
playerDogSkinPreset: string;
|
||||||
opponentDogSkinPreset: string;
|
opponentDogSkinPreset: string;
|
||||||
|
playerCharacterImageSrc?: string;
|
||||||
|
opponentCharacterImageSrc?: string;
|
||||||
|
uiBackgroundImageSrc?: string;
|
||||||
|
barkSoundSrc?: string;
|
||||||
difficultyPreset: BarkBattleDifficultyPreset;
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
leaderboardEnabled: boolean;
|
leaderboardEnabled: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -73,6 +84,10 @@ export interface BarkBattleRuntimeConfig {
|
|||||||
themePreset: string;
|
themePreset: string;
|
||||||
playerDogSkinPreset: string;
|
playerDogSkinPreset: string;
|
||||||
opponentDogSkinPreset: string;
|
opponentDogSkinPreset: string;
|
||||||
|
playerCharacterImageSrc?: string;
|
||||||
|
opponentCharacterImageSrc?: string;
|
||||||
|
uiBackgroundImageSrc?: string;
|
||||||
|
barkSoundSrc?: string;
|
||||||
leaderboardEnabled: boolean;
|
leaderboardEnabled: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ use shared_kernel::{
|
|||||||
offset_datetime_to_unix_micros, parse_rfc3339,
|
offset_datetime_to_unix_micros, parse_rfc3339,
|
||||||
};
|
};
|
||||||
use spacetime_client::{
|
use spacetime_client::{
|
||||||
BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord,
|
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
|
||||||
BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
|
||||||
|
BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||||
|
|
||||||
@@ -73,11 +74,8 @@ struct BarkBattleRunSnapshotRecord {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct BarkBattleDraftConfigSnapshotRecord {
|
struct BarkBattleDraftConfigSnapshotRecord {
|
||||||
draft_id: String,
|
draft_id: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
work_id: String,
|
work_id: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
config_version: u64,
|
config_version: u64,
|
||||||
#[allow(dead_code)]
|
|
||||||
ruleset_version: String,
|
ruleset_version: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
config_json: String,
|
config_json: String,
|
||||||
@@ -105,6 +103,35 @@ pub async fn create_bark_battle_draft(
|
|||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||||
let now = current_utc_micros();
|
let now = current_utc_micros();
|
||||||
|
let editor_config = BarkBattleConfigEditorPayload {
|
||||||
|
title: payload.title.clone(),
|
||||||
|
description: payload.description.clone(),
|
||||||
|
theme_preset: payload.theme_preset.clone(),
|
||||||
|
player_dog_skin_preset: payload.player_dog_skin_preset.clone(),
|
||||||
|
opponent_dog_skin_preset: payload.opponent_dog_skin_preset.clone(),
|
||||||
|
player_character_image_src: normalize_optional_bark_battle_asset_source(
|
||||||
|
&request_context,
|
||||||
|
payload.player_character_image_src.as_deref(),
|
||||||
|
"playerCharacterImageSrc",
|
||||||
|
)?,
|
||||||
|
opponent_character_image_src: normalize_optional_bark_battle_asset_source(
|
||||||
|
&request_context,
|
||||||
|
payload.opponent_character_image_src.as_deref(),
|
||||||
|
"opponentCharacterImageSrc",
|
||||||
|
)?,
|
||||||
|
ui_background_image_src: normalize_optional_bark_battle_asset_source(
|
||||||
|
&request_context,
|
||||||
|
payload.ui_background_image_src.as_deref(),
|
||||||
|
"uiBackgroundImageSrc",
|
||||||
|
)?,
|
||||||
|
bark_sound_src: normalize_optional_bark_battle_asset_source(
|
||||||
|
&request_context,
|
||||||
|
payload.bark_sound_src.as_deref(),
|
||||||
|
"barkSoundSrc",
|
||||||
|
)?,
|
||||||
|
difficulty_preset: payload.difficulty_preset.clone(),
|
||||||
|
leaderboard_enabled: payload.leaderboard_enabled,
|
||||||
|
};
|
||||||
let draft = state
|
let draft = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
|
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
|
||||||
@@ -127,7 +154,35 @@ pub async fn create_bark_battle_draft(
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
})?;
|
})?;
|
||||||
let draft = map_draft_config_record(draft, &request_context)?;
|
let draft_snapshot = parse_draft_snapshot_record(draft, &request_context)?;
|
||||||
|
let config_json = serde_json::to_string(&editor_config).map_err(|error| {
|
||||||
|
bark_battle_error_response(
|
||||||
|
&request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": format!("Bark Battle config JSON 序列化失败: {error}"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let updated = state
|
||||||
|
.spacetime_client()
|
||||||
|
.update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput {
|
||||||
|
draft_id: draft_snapshot.draft_id,
|
||||||
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
|
work_id: draft_snapshot.work_id,
|
||||||
|
config_version: draft_snapshot.config_version.saturating_add(1),
|
||||||
|
ruleset_version: draft_snapshot.ruleset_version,
|
||||||
|
difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset)
|
||||||
|
.to_string(),
|
||||||
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
|
config_json,
|
||||||
|
updated_at_micros: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let draft = map_draft_config_record(updated, &request_context)?;
|
||||||
Ok(json_success_body(Some(&request_context), draft))
|
Ok(json_success_body(Some(&request_context), draft))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,13 +194,17 @@ pub async fn publish_bark_battle_work(
|
|||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||||
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
|
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
|
||||||
let work_id = payload
|
let Some(work_id) = payload
|
||||||
.work_id
|
.work_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string) else {
|
||||||
.unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX));
|
return Err(bark_battle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
"workId 缺失,请重新生成草稿后再发布。",
|
||||||
|
));
|
||||||
|
};
|
||||||
let published_snapshot_json = payload
|
let published_snapshot_json = payload
|
||||||
.published_snapshot
|
.published_snapshot
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -473,11 +532,18 @@ fn map_draft_config_record(
|
|||||||
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||||
Ok(BarkBattleDraftConfig {
|
Ok(BarkBattleDraftConfig {
|
||||||
draft_id: snapshot.draft_id,
|
draft_id: snapshot.draft_id,
|
||||||
|
work_id: Some(snapshot.work_id),
|
||||||
|
config_version: Some(snapshot.config_version.min(u64::from(u32::MAX)) as u32),
|
||||||
|
ruleset_version: Some(snapshot.ruleset_version),
|
||||||
title: editor_config.title,
|
title: editor_config.title,
|
||||||
description: editor_config.description,
|
description: editor_config.description,
|
||||||
theme_preset: editor_config.theme_preset,
|
theme_preset: editor_config.theme_preset,
|
||||||
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||||
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||||
|
player_character_image_src: editor_config.player_character_image_src,
|
||||||
|
opponent_character_image_src: editor_config.opponent_character_image_src,
|
||||||
|
ui_background_image_src: editor_config.ui_background_image_src,
|
||||||
|
bark_sound_src: editor_config.bark_sound_src,
|
||||||
difficulty_preset: editor_config.difficulty_preset,
|
difficulty_preset: editor_config.difficulty_preset,
|
||||||
leaderboard_enabled: editor_config.leaderboard_enabled,
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
@@ -505,6 +571,10 @@ fn map_runtime_config_record(
|
|||||||
theme_preset: editor_config.theme_preset,
|
theme_preset: editor_config.theme_preset,
|
||||||
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||||
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||||
|
player_character_image_src: editor_config.player_character_image_src,
|
||||||
|
opponent_character_image_src: editor_config.opponent_character_image_src,
|
||||||
|
ui_background_image_src: editor_config.ui_background_image_src,
|
||||||
|
bark_sound_src: editor_config.bark_sound_src,
|
||||||
leaderboard_enabled: editor_config.leaderboard_enabled,
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
})
|
})
|
||||||
@@ -527,6 +597,10 @@ fn map_published_config_record(
|
|||||||
theme_preset: editor_config.theme_preset,
|
theme_preset: editor_config.theme_preset,
|
||||||
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||||
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||||
|
player_character_image_src: editor_config.player_character_image_src,
|
||||||
|
opponent_character_image_src: editor_config.opponent_character_image_src,
|
||||||
|
ui_background_image_src: editor_config.ui_background_image_src,
|
||||||
|
bark_sound_src: editor_config.bark_sound_src,
|
||||||
difficulty_preset: editor_config.difficulty_preset,
|
difficulty_preset: editor_config.difficulty_preset,
|
||||||
leaderboard_enabled: editor_config.leaderboard_enabled,
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
@@ -592,6 +666,23 @@ fn ensure_non_empty(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_bark_battle_asset_source(
|
||||||
|
request_context: &RequestContext,
|
||||||
|
value: Option<&str>,
|
||||||
|
field_name: &str,
|
||||||
|
) -> Result<Option<String>, Response> {
|
||||||
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if value.chars().count() > 512 {
|
||||||
|
return Err(bark_battle_bad_request(
|
||||||
|
request_context,
|
||||||
|
&format!("{field_name} 不能超过 512 个字符"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
|
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
|
||||||
bark_battle_error_response(
|
bark_battle_error_response(
|
||||||
request_context,
|
request_context,
|
||||||
@@ -753,6 +844,7 @@ fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_and_energy_are_clamped_to_spacetime_millis() {
|
fn unit_and_energy_are_clamped_to_spacetime_millis() {
|
||||||
@@ -773,4 +865,43 @@ mod tests {
|
|||||||
1_713_672_001_234_567
|
1_713_672_001_234_567
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_config_mapping_includes_stable_work_identity() {
|
||||||
|
let request_context = RequestContext::new(
|
||||||
|
"test-request".to_string(),
|
||||||
|
"POST /api/creation/bark-battle/drafts".to_string(),
|
||||||
|
Duration::ZERO,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let config_json = json!({
|
||||||
|
"title": "汪汪测试杯",
|
||||||
|
"description": "",
|
||||||
|
"themePreset": "sunny-yard",
|
||||||
|
"playerDogSkinPreset": "主角",
|
||||||
|
"opponentDogSkinPreset": "对手",
|
||||||
|
"difficultyPreset": "normal",
|
||||||
|
"leaderboardEnabled": true
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let row = json!({
|
||||||
|
"draftId": "bark-battle-draft-1",
|
||||||
|
"workId": "bark-battle-work-1",
|
||||||
|
"configVersion": 2,
|
||||||
|
"rulesetVersion": "bark-battle-ruleset-v1",
|
||||||
|
"configJson": config_json,
|
||||||
|
"updatedAtMicros": 1_713_686_401_234_567i64,
|
||||||
|
});
|
||||||
|
|
||||||
|
let draft = map_draft_config_record(row, &request_context)
|
||||||
|
.expect("draft config should map from SpacetimeDB snapshot");
|
||||||
|
|
||||||
|
assert_eq!(draft.draft_id, "bark-battle-draft-1");
|
||||||
|
assert_eq!(draft.work_id.as_deref(), Some("bark-battle-work-1"));
|
||||||
|
assert_eq!(draft.config_version, Some(2));
|
||||||
|
assert_eq!(
|
||||||
|
draft.ruleset_version.as_deref(),
|
||||||
|
Some("bark-battle-ruleset-v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ pub enum BarkBattleFinishStatus {
|
|||||||
Rejected,
|
Rejected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BarkBattleReplacementConfig {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BarkBattleConfigEditorPayload {
|
pub struct BarkBattleConfigEditorPayload {
|
||||||
@@ -39,6 +52,14 @@ pub struct BarkBattleConfigEditorPayload {
|
|||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub player_dog_skin_preset: String,
|
pub player_dog_skin_preset: String,
|
||||||
pub opponent_dog_skin_preset: String,
|
pub opponent_dog_skin_preset: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||||
pub leaderboard_enabled: bool,
|
pub leaderboard_enabled: bool,
|
||||||
@@ -53,6 +74,14 @@ pub struct BarkBattleDraftCreateRequest {
|
|||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub player_dog_skin_preset: String,
|
pub player_dog_skin_preset: String,
|
||||||
pub opponent_dog_skin_preset: String,
|
pub opponent_dog_skin_preset: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||||
pub leaderboard_enabled: bool,
|
pub leaderboard_enabled: bool,
|
||||||
@@ -66,6 +95,10 @@ impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
|
|||||||
theme_preset: value.theme_preset,
|
theme_preset: value.theme_preset,
|
||||||
player_dog_skin_preset: value.player_dog_skin_preset,
|
player_dog_skin_preset: value.player_dog_skin_preset,
|
||||||
opponent_dog_skin_preset: value.opponent_dog_skin_preset,
|
opponent_dog_skin_preset: value.opponent_dog_skin_preset,
|
||||||
|
player_character_image_src: value.player_character_image_src,
|
||||||
|
opponent_character_image_src: value.opponent_character_image_src,
|
||||||
|
ui_background_image_src: value.ui_background_image_src,
|
||||||
|
bark_sound_src: value.bark_sound_src,
|
||||||
difficulty_preset: value.difficulty_preset,
|
difficulty_preset: value.difficulty_preset,
|
||||||
leaderboard_enabled: value.leaderboard_enabled,
|
leaderboard_enabled: value.leaderboard_enabled,
|
||||||
}
|
}
|
||||||
@@ -86,12 +119,26 @@ pub struct BarkBattleWorkPublishRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BarkBattleDraftConfig {
|
pub struct BarkBattleDraftConfig {
|
||||||
pub draft_id: String,
|
pub draft_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub work_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_version: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ruleset_version: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub player_dog_skin_preset: String,
|
pub player_dog_skin_preset: String,
|
||||||
pub opponent_dog_skin_preset: String,
|
pub opponent_dog_skin_preset: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||||
pub leaderboard_enabled: bool,
|
pub leaderboard_enabled: bool,
|
||||||
@@ -102,11 +149,18 @@ impl Default for BarkBattleDraftConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
draft_id: String::new(),
|
draft_id: String::new(),
|
||||||
|
work_id: None,
|
||||||
|
config_version: None,
|
||||||
|
ruleset_version: None,
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
description: None,
|
description: None,
|
||||||
theme_preset: String::new(),
|
theme_preset: String::new(),
|
||||||
player_dog_skin_preset: String::new(),
|
player_dog_skin_preset: String::new(),
|
||||||
opponent_dog_skin_preset: String::new(),
|
opponent_dog_skin_preset: String::new(),
|
||||||
|
player_character_image_src: None,
|
||||||
|
opponent_character_image_src: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
bark_sound_src: None,
|
||||||
difficulty_preset: BarkBattleDifficultyPreset::Normal,
|
difficulty_preset: BarkBattleDifficultyPreset::Normal,
|
||||||
leaderboard_enabled: true,
|
leaderboard_enabled: true,
|
||||||
updated_at: String::new(),
|
updated_at: String::new(),
|
||||||
@@ -129,6 +183,14 @@ pub struct BarkBattlePublishedConfig {
|
|||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub player_dog_skin_preset: String,
|
pub player_dog_skin_preset: String,
|
||||||
pub opponent_dog_skin_preset: String,
|
pub opponent_dog_skin_preset: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||||
pub leaderboard_enabled: bool,
|
pub leaderboard_enabled: bool,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -151,6 +213,14 @@ pub struct BarkBattleRuntimeConfig {
|
|||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub player_dog_skin_preset: String,
|
pub player_dog_skin_preset: String,
|
||||||
pub opponent_dog_skin_preset: String,
|
pub opponent_dog_skin_preset: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
pub leaderboard_enabled: bool,
|
pub leaderboard_enabled: bool,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -409,7 +479,22 @@ mod tests {
|
|||||||
fn optional_fields_are_omitted_when_absent() {
|
fn optional_fields_are_omitted_when_absent() {
|
||||||
let draft = BarkBattleDraftConfig::default();
|
let draft = BarkBattleDraftConfig::default();
|
||||||
let payload = serde_json::to_value(draft).expect("draft should serialize");
|
let payload = serde_json::to_value(draft).expect("draft should serialize");
|
||||||
|
assert!(!payload.as_object().unwrap().contains_key("workId"));
|
||||||
|
assert!(!payload.as_object().unwrap().contains_key("configVersion"));
|
||||||
|
assert!(!payload.as_object().unwrap().contains_key("rulesetVersion"));
|
||||||
assert!(!payload.as_object().unwrap().contains_key("description"));
|
assert!(!payload.as_object().unwrap().contains_key("description"));
|
||||||
|
assert!(
|
||||||
|
!payload
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("playerCharacterImageSrc")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!payload
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("uiBackgroundImageSrc")
|
||||||
|
);
|
||||||
|
|
||||||
let response = BarkBattlePersonalHistoryResponse {
|
let response = BarkBattlePersonalHistoryResponse {
|
||||||
work_id: None,
|
work_id: None,
|
||||||
@@ -429,6 +514,74 @@ mod tests {
|
|||||||
assert!(!payload.as_object().unwrap().contains_key("bestSummary"));
|
assert!(!payload.as_object().unwrap().contains_key("bestSummary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_config_serializes_persistent_identity_fields() {
|
||||||
|
let draft = BarkBattleDraftConfig {
|
||||||
|
draft_id: "bark-battle-draft-1".to_string(),
|
||||||
|
work_id: Some("bark-battle-work-1".to_string()),
|
||||||
|
config_version: Some(2),
|
||||||
|
ruleset_version: Some("bark-battle-ruleset-v1".to_string()),
|
||||||
|
title: "汪汪测试杯".to_string(),
|
||||||
|
description: None,
|
||||||
|
theme_preset: "sunny-yard".to_string(),
|
||||||
|
player_dog_skin_preset: "主角".to_string(),
|
||||||
|
opponent_dog_skin_preset: "对手".to_string(),
|
||||||
|
player_character_image_src: None,
|
||||||
|
opponent_character_image_src: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
bark_sound_src: None,
|
||||||
|
difficulty_preset: BarkBattleDifficultyPreset::Normal,
|
||||||
|
leaderboard_enabled: true,
|
||||||
|
updated_at: "2026-05-14T10:00:00.000Z".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_value(draft).expect("draft should serialize");
|
||||||
|
|
||||||
|
assert_eq!(payload["draftId"], json!("bark-battle-draft-1"));
|
||||||
|
assert_eq!(payload["workId"], json!("bark-battle-work-1"));
|
||||||
|
assert_eq!(payload["configVersion"], json!(2));
|
||||||
|
assert_eq!(
|
||||||
|
payload["rulesetVersion"],
|
||||||
|
json!("bark-battle-ruleset-v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacement_sources_serialize_as_camel_case_config_fields() {
|
||||||
|
let config = BarkBattleConfigEditorPayload {
|
||||||
|
title: "周末狗狗杯".to_string(),
|
||||||
|
description: Some("轻配置草稿".to_string()),
|
||||||
|
theme_preset: "neon-park".to_string(),
|
||||||
|
player_dog_skin_preset: "shiba".to_string(),
|
||||||
|
opponent_dog_skin_preset: "husky".to_string(),
|
||||||
|
player_character_image_src: Some("/generated-bark-battle/player.png".to_string()),
|
||||||
|
opponent_character_image_src: Some("https://example.test/opponent.png".to_string()),
|
||||||
|
ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()),
|
||||||
|
bark_sound_src: Some("/generated-bark-battle/bark.mp3".to_string()),
|
||||||
|
difficulty_preset: BarkBattleDifficultyPreset::Hard,
|
||||||
|
leaderboard_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_value(config).expect("config should serialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
payload["playerCharacterImageSrc"],
|
||||||
|
json!("/generated-bark-battle/player.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["opponentCharacterImageSrc"],
|
||||||
|
json!("https://example.test/opponent.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["uiBackgroundImageSrc"],
|
||||||
|
json!("/generated-bark-battle/ui.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["barkSoundSrc"],
|
||||||
|
json!("/generated-bark-battle/bark.mp3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finish_response_serializes_player_win_and_accepted() {
|
fn finish_response_serializes_player_win_and_accepted() {
|
||||||
let response = BarkBattleRunFinishResponse {
|
let response = BarkBattleRunFinishResponse {
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ fn create_bark_battle_draft_tx(
|
|||||||
&input.opponent_dog_skin_preset,
|
&input.opponent_dog_skin_preset,
|
||||||
"opponent_dog_skin_preset",
|
"opponent_dog_skin_preset",
|
||||||
)?,
|
)?,
|
||||||
|
player_character_image_src: None,
|
||||||
|
opponent_character_image_src: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
bark_sound_src: None,
|
||||||
difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?,
|
difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?,
|
||||||
leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true),
|
leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true),
|
||||||
};
|
};
|
||||||
@@ -146,8 +150,8 @@ fn update_bark_battle_draft_config_tx(
|
|||||||
require_non_empty(&input.draft_id, "bark_battle draft_id")?;
|
require_non_empty(&input.draft_id, "bark_battle draft_id")?;
|
||||||
require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?;
|
require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?;
|
||||||
require_non_empty(&input.work_id, "bark_battle work_id")?;
|
require_non_empty(&input.work_id, "bark_battle work_id")?;
|
||||||
let editor_config = parse_editor_config(&input.config_json)?;
|
let mut editor_config = parse_editor_config(&input.config_json)?;
|
||||||
validate_editor_config_snapshot(&editor_config)?;
|
normalize_editor_config_snapshot(&mut editor_config)?;
|
||||||
if editor_config.difficulty_preset != input.difficulty_preset
|
if editor_config.difficulty_preset != input.difficulty_preset
|
||||||
|| editor_config.leaderboard_enabled != input.leaderboard_enabled
|
|| editor_config.leaderboard_enabled != input.leaderboard_enabled
|
||||||
{
|
{
|
||||||
@@ -171,7 +175,7 @@ fn update_bark_battle_draft_config_tx(
|
|||||||
row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?;
|
row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?;
|
||||||
row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?;
|
row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?;
|
||||||
row.leaderboard_enabled = input.leaderboard_enabled;
|
row.leaderboard_enabled = input.leaderboard_enabled;
|
||||||
row.config_json = input.config_json;
|
row.config_json = to_json_string(&editor_config);
|
||||||
row.updated_at = updated_at;
|
row.updated_at = updated_at;
|
||||||
ctx.db
|
ctx.db
|
||||||
.bark_battle_draft_config()
|
.bark_battle_draft_config()
|
||||||
@@ -523,12 +527,30 @@ fn require_non_empty(value: &str, label: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_editor_config_snapshot(config: &BarkBattleEditorConfigSnapshot) -> Result<(), String> {
|
fn normalize_editor_config_snapshot(
|
||||||
normalize_title(Some(&config.title))?;
|
config: &mut BarkBattleEditorConfigSnapshot,
|
||||||
normalize_required_preset(&config.theme_preset, "theme_preset")?;
|
) -> Result<(), String> {
|
||||||
|
config.title = normalize_title(Some(&config.title))?;
|
||||||
|
config.theme_preset = normalize_required_preset(&config.theme_preset, "theme_preset")?;
|
||||||
|
config.player_dog_skin_preset =
|
||||||
normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?;
|
normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?;
|
||||||
|
config.opponent_dog_skin_preset =
|
||||||
normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?;
|
normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?;
|
||||||
normalize_difficulty(Some(&config.difficulty_preset))?;
|
config.player_character_image_src = normalize_optional_asset_source(
|
||||||
|
config.player_character_image_src.as_deref(),
|
||||||
|
"player_character_image_src",
|
||||||
|
)?;
|
||||||
|
config.opponent_character_image_src = normalize_optional_asset_source(
|
||||||
|
config.opponent_character_image_src.as_deref(),
|
||||||
|
"opponent_character_image_src",
|
||||||
|
)?;
|
||||||
|
config.ui_background_image_src = normalize_optional_asset_source(
|
||||||
|
config.ui_background_image_src.as_deref(),
|
||||||
|
"ui_background_image_src",
|
||||||
|
)?;
|
||||||
|
config.bark_sound_src =
|
||||||
|
normalize_optional_asset_source(config.bark_sound_src.as_deref(), "bark_sound_src")?;
|
||||||
|
config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +577,19 @@ fn normalize_required_preset(value: &str, field_name: &str) -> Result<String, St
|
|||||||
Ok(preset.to_string())
|
Ok(preset.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_asset_source(
|
||||||
|
value: Option<&str>,
|
||||||
|
field_name: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if value.chars().count() > 512 {
|
||||||
|
return Err(format!("bark_battle {field_name} 不能超过 512 个字符"));
|
||||||
|
}
|
||||||
|
Ok(Some(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_ruleset_version(value: &str) -> Result<String, String> {
|
fn normalize_ruleset_version(value: &str) -> Result<String, String> {
|
||||||
let ruleset = value.trim();
|
let ruleset = value.trim();
|
||||||
if ruleset != BARK_BATTLE_DEFAULT_RULESET_VERSION {
|
if ruleset != BARK_BATTLE_DEFAULT_RULESET_VERSION {
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ pub struct BarkBattleEditorConfigSnapshot {
|
|||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub player_dog_skin_preset: String,
|
pub player_dog_skin_preset: String,
|
||||||
pub opponent_dog_skin_preset: String,
|
pub opponent_dog_skin_preset: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub player_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opponent_character_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_background_image_src: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bark_sound_src: Option<String>,
|
||||||
pub difficulty_preset: String,
|
pub difficulty_preset: String,
|
||||||
pub leaderboard_enabled: bool,
|
pub leaderboard_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,54 +7,74 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
|
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
|
||||||
|
|
||||||
describe('BarkBattleConfigEditor', () => {
|
describe('BarkBattleConfigEditor', () => {
|
||||||
it('allows creators to edit lightweight config and publish a Bark Battle work', async () => {
|
it('allows creators to edit lightweight config and compile a Bark Battle draft', async () => {
|
||||||
const onPublish = vi.fn();
|
const onPreview = vi.fn();
|
||||||
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
|
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
|
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
|
||||||
expect(screen.getByText('轻配置')).toBeTruthy();
|
expect(screen.getByText('轻配置')).toBeTruthy();
|
||||||
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
|
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
|
||||||
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
|
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
|
||||||
expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true);
|
|
||||||
|
|
||||||
await userEvent.clear(screen.getByLabelText('作品标题'));
|
await userEvent.clear(screen.getByLabelText('作品标题'));
|
||||||
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
|
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
|
||||||
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
|
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
|
||||||
await userEvent.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba');
|
await userEvent.clear(screen.getByLabelText('玩家角色设定'));
|
||||||
await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky');
|
await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角');
|
||||||
|
await userEvent.clear(screen.getByLabelText('对手角色设定'));
|
||||||
|
await userEvent.type(screen.getByLabelText('对手角色设定'), '对手');
|
||||||
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
|
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
|
||||||
await userEvent.click(screen.getByLabelText('开启排行榜'));
|
await userEvent.type(
|
||||||
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
|
screen.getByLabelText('玩家形象'),
|
||||||
|
'/generated-bark-battle/player/image.png',
|
||||||
|
);
|
||||||
|
await userEvent.type(
|
||||||
|
screen.getByLabelText('对手形象'),
|
||||||
|
'https://example.test/opponent.png',
|
||||||
|
);
|
||||||
|
await userEvent.type(
|
||||||
|
screen.getByLabelText('UI背景'),
|
||||||
|
'/generated-bark-battle/ui/background.png',
|
||||||
|
);
|
||||||
|
await userEvent.type(
|
||||||
|
screen.getByLabelText('狗叫音效'),
|
||||||
|
'/generated-bark-battle/audio/bark.mp3',
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||||
|
|
||||||
expect(onPublish).toHaveBeenCalledWith({
|
expect(onPreview).toHaveBeenCalledWith({
|
||||||
title: '周末狗狗杯',
|
title: '周末狗狗杯',
|
||||||
description: '',
|
description: '',
|
||||||
themePreset: 'neon-park',
|
themePreset: 'neon-park',
|
||||||
playerDogSkinPreset: 'shiba',
|
playerDogSkinPreset: '主角',
|
||||||
opponentDogSkinPreset: 'husky',
|
opponentDogSkinPreset: '对手',
|
||||||
|
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
|
||||||
|
opponentCharacterImageSrc: 'https://example.test/opponent.png',
|
||||||
|
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
|
||||||
|
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
|
||||||
difficultyPreset: 'hard',
|
difficultyPreset: 'hard',
|
||||||
leaderboardEnabled: false,
|
leaderboardEnabled: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a non-empty title before publishing', async () => {
|
it('requires a non-empty title before compiling a draft', async () => {
|
||||||
const onPublish = vi.fn();
|
const onPreview = vi.fn();
|
||||||
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
|
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
|
||||||
|
|
||||||
await userEvent.clear(screen.getByLabelText('作品标题'));
|
await userEvent.clear(screen.getByLabelText('作品标题'));
|
||||||
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
|
await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||||
|
|
||||||
expect(onPublish).not.toHaveBeenCalled();
|
expect(onPreview).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
|
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can render as an embedded creation form without a local page header', () => {
|
it('can render as an embedded creation form without a local page header', () => {
|
||||||
const onPublish = vi.fn();
|
const onPreview = vi.fn();
|
||||||
render(
|
render(
|
||||||
<BarkBattleConfigEditor
|
<BarkBattleConfigEditor
|
||||||
error="发布失败"
|
error="发布失败"
|
||||||
isBusy={false}
|
isBusy={false}
|
||||||
onPublish={onPublish}
|
onPreview={onPreview}
|
||||||
showBackButton={false}
|
showBackButton={false}
|
||||||
title={null}
|
title={null}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react';
|
import { ArrowLeft, Loader2, Play } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
|
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
|
||||||
@@ -8,7 +8,7 @@ import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
|||||||
export type BarkBattleConfigEditorProps = {
|
export type BarkBattleConfigEditorProps = {
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
|
onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -20,12 +20,6 @@ const THEME_OPTIONS = [
|
|||||||
{ value: 'moonlight-rooftop', label: '月光天台' },
|
{ value: 'moonlight-rooftop', label: '月光天台' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DOG_SKIN_OPTIONS = [
|
|
||||||
{ value: 'corgi', label: '柯基' },
|
|
||||||
{ value: 'shiba', label: '柴犬' },
|
|
||||||
{ value: 'husky', label: '哈士奇' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
|
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
|
||||||
{ value: 'easy', label: '轻松' },
|
{ value: 'easy', label: '轻松' },
|
||||||
{ value: 'normal', label: '标准' },
|
{ value: 'normal', label: '标准' },
|
||||||
@@ -35,7 +29,7 @@ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: stri
|
|||||||
export function BarkBattleConfigEditor({
|
export function BarkBattleConfigEditor({
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
error: externalError = null,
|
error: externalError = null,
|
||||||
onPublish,
|
onPreview,
|
||||||
onBack,
|
onBack,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
title: headingTitle = '汪汪声浪大作战',
|
title: headingTitle = '汪汪声浪大作战',
|
||||||
@@ -43,10 +37,13 @@ export function BarkBattleConfigEditor({
|
|||||||
const [title, setTitle] = useState('我的声浪竞技场');
|
const [title, setTitle] = useState('我的声浪竞技场');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [themePreset, setThemePreset] = useState('sunny-yard');
|
const [themePreset, setThemePreset] = useState('sunny-yard');
|
||||||
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi');
|
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角');
|
||||||
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky');
|
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手');
|
||||||
|
const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState('');
|
||||||
|
const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState('');
|
||||||
|
const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState('');
|
||||||
|
const [barkSoundSrc, setBarkSoundSrc] = useState('');
|
||||||
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
|
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
|
||||||
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
|
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
const payload = useMemo<BarkBattleConfigEditorPayload>(
|
const payload = useMemo<BarkBattleConfigEditorPayload>(
|
||||||
@@ -56,8 +53,18 @@ export function BarkBattleConfigEditor({
|
|||||||
themePreset,
|
themePreset,
|
||||||
playerDogSkinPreset,
|
playerDogSkinPreset,
|
||||||
opponentDogSkinPreset,
|
opponentDogSkinPreset,
|
||||||
|
...(playerCharacterImageSrc.trim()
|
||||||
|
? { playerCharacterImageSrc: playerCharacterImageSrc.trim() }
|
||||||
|
: {}),
|
||||||
|
...(opponentCharacterImageSrc.trim()
|
||||||
|
? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() }
|
||||||
|
: {}),
|
||||||
|
...(uiBackgroundImageSrc.trim()
|
||||||
|
? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() }
|
||||||
|
: {}),
|
||||||
|
...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}),
|
||||||
difficultyPreset,
|
difficultyPreset,
|
||||||
leaderboardEnabled,
|
leaderboardEnabled: true,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
title,
|
title,
|
||||||
@@ -65,24 +72,29 @@ export function BarkBattleConfigEditor({
|
|||||||
themePreset,
|
themePreset,
|
||||||
playerDogSkinPreset,
|
playerDogSkinPreset,
|
||||||
opponentDogSkinPreset,
|
opponentDogSkinPreset,
|
||||||
|
playerCharacterImageSrc,
|
||||||
|
opponentCharacterImageSrc,
|
||||||
|
uiBackgroundImageSrc,
|
||||||
|
barkSoundSrc,
|
||||||
difficultyPreset,
|
difficultyPreset,
|
||||||
leaderboardEnabled,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePublish = () => {
|
const runValidatedAction = (
|
||||||
|
action: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>,
|
||||||
|
) => {
|
||||||
if (!payload.title) {
|
if (!payload.title) {
|
||||||
setLocalError('请先填写作品标题');
|
setLocalError('请先填写作品标题');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
void onPublish(payload);
|
void action(payload);
|
||||||
};
|
};
|
||||||
const visibleError = localError ?? externalError;
|
const visibleError = localError ?? externalError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden"
|
className="platform-remap-surface mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col overflow-y-auto overscroll-y-contain pr-0.5"
|
||||||
aria-label="汪汪声浪轻配置编辑器"
|
aria-label="汪汪声浪轻配置编辑器"
|
||||||
>
|
>
|
||||||
{showBackButton && onBack ? (
|
{showBackButton && onBack ? (
|
||||||
@@ -101,7 +113,7 @@ export function BarkBattleConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{headingTitle ? (
|
{headingTitle ? (
|
||||||
<div className="mb-3 shrink-0 sm:mb-5">
|
<div className="mb-3 shrink-0 sm:mb-5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -116,9 +128,9 @@ export function BarkBattleConfigEditor({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
|
className={`grid flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
|
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
|
||||||
<label className="block shrink-0">
|
<label className="block shrink-0">
|
||||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
作品标题
|
作品标题
|
||||||
@@ -193,64 +205,89 @@ export function BarkBattleConfigEditor({
|
|||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
玩家狗狗
|
玩家角色设定
|
||||||
</span>
|
</span>
|
||||||
<select
|
<input
|
||||||
value={playerDogSkinPreset}
|
value={playerDogSkinPreset}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(event) =>
|
onChange={(event) => setPlayerDogSkinPreset(event.target.value)}
|
||||||
setPlayerDogSkinPreset(event.target.value)
|
|
||||||
}
|
|
||||||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||||
aria-label="玩家狗狗"
|
aria-label="玩家角色设定"
|
||||||
>
|
/>
|
||||||
{DOG_SKIN_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
对手狗狗
|
对手角色设定
|
||||||
</span>
|
</span>
|
||||||
<select
|
<input
|
||||||
value={opponentDogSkinPreset}
|
value={opponentDogSkinPreset}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(event) =>
|
onChange={(event) => setOpponentDogSkinPreset(event.target.value)}
|
||||||
setOpponentDogSkinPreset(event.target.value)
|
|
||||||
}
|
|
||||||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||||
aria-label="对手狗狗"
|
aria-label="对手角色设定"
|
||||||
>
|
/>
|
||||||
{DOG_SKIN_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3 text-sm font-black text-[var(--platform-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]">
|
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
|
||||||
<span className="inline-flex items-center gap-2">
|
<label className="block">
|
||||||
<Trophy className="h-4 w-4 text-amber-500" />
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
开启排行榜
|
玩家形象
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
aria-label="开启排行榜"
|
value={playerCharacterImageSrc}
|
||||||
type="checkbox"
|
|
||||||
checked={leaderboardEnabled}
|
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(event) =>
|
onChange={(event) => setPlayerCharacterImageSrc(event.target.value)}
|
||||||
setLeaderboardEnabled(event.target.checked)
|
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||||
}
|
placeholder=""
|
||||||
className="h-5 w-5 accent-[#ff4f6a]"
|
aria-label="玩家形象"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
对手形象
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={opponentCharacterImageSrc}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(event) => setOpponentCharacterImageSrc(event.target.value)}
|
||||||
|
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||||
|
placeholder=""
|
||||||
|
aria-label="对手形象"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
UI背景
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={uiBackgroundImageSrc}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(event) => setUiBackgroundImageSrc(event.target.value)}
|
||||||
|
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||||
|
placeholder=""
|
||||||
|
aria-label="UI背景"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
狗叫音效
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={barkSoundSrc}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(event) => setBarkSoundSrc(event.target.value)}
|
||||||
|
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||||
|
placeholder=""
|
||||||
|
aria-label="狗叫音效"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{visibleError ? (
|
{visibleError ? (
|
||||||
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
|
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
|
||||||
{visibleError}
|
{visibleError}
|
||||||
@@ -262,20 +299,20 @@ export function BarkBattleConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
<div className="mt-3 flex shrink-0 flex-wrap justify-center gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={handlePublish}
|
onClick={() => runValidatedAction(onPreview)}
|
||||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<WandSparkles className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span>{isBusy ? '发布中' : '发布并试玩'}</span>
|
<span>{isBusy ? '处理中' : '生成草稿'}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
|
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
|
||||||
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
type BarkBattlePreviewCardProps = {
|
type BarkBattlePreviewCardProps = {
|
||||||
config: BarkBattleConfigEditorPayload;
|
config: BarkBattleConfigEditorPayload;
|
||||||
@@ -10,12 +11,6 @@ const THEME_LABELS: Record<string, string> = {
|
|||||||
'moonlight-rooftop': '月光天台',
|
'moonlight-rooftop': '月光天台',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOG_LABELS: Record<string, string> = {
|
|
||||||
corgi: '柯基',
|
|
||||||
shiba: '柴犬',
|
|
||||||
husky: '哈士奇',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DIFFICULTY_LABELS = {
|
const DIFFICULTY_LABELS = {
|
||||||
easy: '轻松',
|
easy: '轻松',
|
||||||
normal: '标准',
|
normal: '标准',
|
||||||
@@ -23,6 +18,8 @@ const DIFFICULTY_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
|
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
|
||||||
|
const hasCustomSound = Boolean(config.barkSoundSrc?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
|
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
|
||||||
@@ -30,10 +27,41 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
|
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
|
||||||
<div
|
<div
|
||||||
className="mb-4 flex min-h-[8.5rem] items-center justify-center rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] text-5xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
|
className="relative mb-4 grid min-h-[8.5rem] grid-cols-[1fr_auto_1fr] items-center gap-3 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-4 text-center text-3xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span>汪 VS 嗷</span>
|
{config.uiBackgroundImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={config.uiBackgroundImageSrc}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover opacity-70"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="relative grid place-items-center">
|
||||||
|
{config.playerCharacterImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={config.playerCharacterImageSrc}
|
||||||
|
alt=""
|
||||||
|
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-5xl sm:text-6xl">🐕</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="relative rounded-full bg-white/70 px-3 py-1 text-base font-black text-[var(--platform-text-strong)]">
|
||||||
|
VS
|
||||||
|
</span>
|
||||||
|
<span className="relative grid place-items-center">
|
||||||
|
{config.opponentCharacterImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={config.opponentCharacterImageSrc}
|
||||||
|
alt=""
|
||||||
|
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-5xl sm:text-6xl">🐶</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
|
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
|
||||||
{config.title || '未命名声浪竞技场'}
|
{config.title || '未命名声浪竞技场'}
|
||||||
@@ -51,9 +79,9 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
|
|||||||
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
|
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
|
||||||
<dt className="text-[var(--platform-text-muted)]">阵容</dt>
|
<dt className="text-[var(--platform-text-muted)]">阵容</dt>
|
||||||
<dd className="font-black text-[var(--platform-text-strong)]">
|
<dd className="font-black text-[var(--platform-text-strong)]">
|
||||||
{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset}
|
{config.playerDogSkinPreset || '主角'}
|
||||||
{' vs '}
|
{' vs '}
|
||||||
{DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}
|
{config.opponentDogSkinPreset || '对手'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
|
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
|
||||||
@@ -63,9 +91,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
|
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
|
||||||
<dt className="text-[var(--platform-text-muted)]">排行榜</dt>
|
<dt className="text-[var(--platform-text-muted)]">替换</dt>
|
||||||
<dd className="font-black text-[var(--platform-text-strong)]">
|
<dd className="font-black text-[var(--platform-text-strong)]">
|
||||||
{config.leaderboardEnabled ? '开启' : '关闭'}
|
{[
|
||||||
|
config.playerCharacterImageSrc || config.opponentCharacterImageSrc ? '形象' : '',
|
||||||
|
config.uiBackgroundImageSrc ? 'UI' : '',
|
||||||
|
hasCustomSound ? '狗叫' : '',
|
||||||
|
].filter(Boolean).join(' / ') || '预设'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { uploadBarkBattleAsset } from '../../services/bark-battle-creation';
|
||||||
|
import { BarkBattleResultView } from './BarkBattleResultView';
|
||||||
|
|
||||||
|
vi.mock('../../services/bark-battle-creation', () => ({
|
||||||
|
regenerateBarkBattleImageAsset: vi.fn(),
|
||||||
|
uploadBarkBattleAsset: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ResolvedAssetImage', () => ({
|
||||||
|
ResolvedAssetImage: ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
src?: string | null;
|
||||||
|
alt?: string;
|
||||||
|
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const draft = {
|
||||||
|
draftId: 'bark-battle-draft-1',
|
||||||
|
workId: 'bark-battle-work-1',
|
||||||
|
title: '汪汪测试杯',
|
||||||
|
description: '',
|
||||||
|
themePreset: 'sunny-yard',
|
||||||
|
playerDogSkinPreset: '主角',
|
||||||
|
opponentDogSkinPreset: '对手',
|
||||||
|
difficultyPreset: 'normal' as const,
|
||||||
|
leaderboardEnabled: true,
|
||||||
|
configVersion: 1,
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
|
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BarkBattleResultView', () => {
|
||||||
|
it('exposes draft preview actions before publish', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onStartTestRun = vi.fn();
|
||||||
|
const onPublish = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<BarkBattleResultView
|
||||||
|
draft={draft}
|
||||||
|
onBack={() => {}}
|
||||||
|
onDraftChange={() => {}}
|
||||||
|
onStartTestRun={onStartTestRun}
|
||||||
|
onPublish={onPublish}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('草稿编译')).toBeTruthy();
|
||||||
|
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||||
|
expect(onStartTestRun).toHaveBeenCalledWith(draft);
|
||||||
|
expect(onPublish).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||||
|
expect(onPublish).toHaveBeenCalledWith(draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads replacement assets into the selected slot', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDraftChange = vi.fn();
|
||||||
|
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
|
||||||
|
assetObjectId: 'asset-player-1',
|
||||||
|
assetKind: 'bark_battle_player_character_image',
|
||||||
|
objectKey: 'generated-bark-battle-assets/player.png',
|
||||||
|
assetSrc: '/generated-bark-battle-assets/player.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<BarkBattleResultView
|
||||||
|
draft={draft}
|
||||||
|
onBack={() => {}}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
onStartTestRun={() => {}}
|
||||||
|
onPublish={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const playerSlot = screen.getByText('玩家形象').closest('article');
|
||||||
|
expect(playerSlot).toBeTruthy();
|
||||||
|
const fileInput = within(playerSlot as HTMLElement).getByLabelText(
|
||||||
|
'上传玩家形象文件',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(
|
||||||
|
fileInput,
|
||||||
|
new File(['image-bytes'], 'player.png', { type: 'image/png' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadBarkBattleAsset).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
slot: 'player-character',
|
||||||
|
draftId: 'bark-battle-draft-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onDraftChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
339
src/components/bark-battle-creation/BarkBattleResultView.tsx
Normal file
339
src/components/bark-battle-creation/BarkBattleResultView.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
ImagePlus,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Upload,
|
||||||
|
Volume2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BarkBattleConfigEditorPayload,
|
||||||
|
BarkBattleDraftConfig,
|
||||||
|
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||||
|
import {
|
||||||
|
type BarkBattleAssetSlot,
|
||||||
|
regenerateBarkBattleImageAsset,
|
||||||
|
uploadBarkBattleAsset,
|
||||||
|
} from '../../services/bark-battle-creation';
|
||||||
|
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
||||||
|
|
||||||
|
type BarkBattleResultViewProps = {
|
||||||
|
draft: BarkBattleDraftConfig;
|
||||||
|
isBusy?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onBack: () => void;
|
||||||
|
onDraftChange: (draft: BarkBattleDraftConfig) => void;
|
||||||
|
onStartTestRun: (draft: BarkBattleDraftConfig) => void;
|
||||||
|
onPublish: (draft: BarkBattleDraftConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BarkBattleImageSlot = Exclude<BarkBattleAssetSlot, 'bark-sound'>;
|
||||||
|
|
||||||
|
const SLOT_LABELS = {
|
||||||
|
'player-character': '玩家形象',
|
||||||
|
'opponent-character': '对手形象',
|
||||||
|
'ui-background': 'UI背景',
|
||||||
|
'bark-sound': '狗叫音效',
|
||||||
|
} satisfies Record<BarkBattleAssetSlot, string>;
|
||||||
|
|
||||||
|
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
|
||||||
|
return {
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
themePreset: draft.themePreset,
|
||||||
|
playerDogSkinPreset: draft.playerDogSkinPreset,
|
||||||
|
opponentDogSkinPreset: draft.opponentDogSkinPreset,
|
||||||
|
...(draft.playerCharacterImageSrc
|
||||||
|
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
|
||||||
|
: {}),
|
||||||
|
...(draft.opponentCharacterImageSrc
|
||||||
|
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
|
||||||
|
: {}),
|
||||||
|
...(draft.uiBackgroundImageSrc
|
||||||
|
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
|
||||||
|
: {}),
|
||||||
|
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
|
||||||
|
difficultyPreset: draft.difficultyPreset,
|
||||||
|
leaderboardEnabled: draft.leaderboardEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAssetToDraft(
|
||||||
|
draft: BarkBattleDraftConfig,
|
||||||
|
slot: BarkBattleAssetSlot,
|
||||||
|
assetSrc: string,
|
||||||
|
): BarkBattleDraftConfig {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
if (slot === 'player-character') {
|
||||||
|
return { ...draft, playerCharacterImageSrc: assetSrc, updatedAt };
|
||||||
|
}
|
||||||
|
if (slot === 'opponent-character') {
|
||||||
|
return { ...draft, opponentCharacterImageSrc: assetSrc, updatedAt };
|
||||||
|
}
|
||||||
|
if (slot === 'ui-background') {
|
||||||
|
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
|
||||||
|
}
|
||||||
|
return { ...draft, barkSoundSrc: assetSrc, updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
|
||||||
|
if (slot === 'player-character') {
|
||||||
|
return draft.playerCharacterImageSrc ?? '';
|
||||||
|
}
|
||||||
|
if (slot === 'opponent-character') {
|
||||||
|
return draft.opponentCharacterImageSrc ?? '';
|
||||||
|
}
|
||||||
|
if (slot === 'ui-background') {
|
||||||
|
return draft.uiBackgroundImageSrc ?? '';
|
||||||
|
}
|
||||||
|
return draft.barkSoundSrc ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultActionButton({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
tone = 'secondary',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
tone?: 'primary' | 'secondary';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`platform-button ${
|
||||||
|
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
|
||||||
|
} min-h-11 justify-center disabled:cursor-not-allowed disabled:opacity-55`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarkBattleAssetSlotControl({
|
||||||
|
draft,
|
||||||
|
slot,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
draft: BarkBattleDraftConfig;
|
||||||
|
slot: BarkBattleAssetSlot;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (draft: BarkBattleDraftConfig) => void;
|
||||||
|
onError: (message: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
const assetSrc = getSlotAssetSrc(draft, slot);
|
||||||
|
const isImageSlot = slot !== 'bark-sound';
|
||||||
|
|
||||||
|
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.currentTarget.files?.[0] ?? null;
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
onError(null);
|
||||||
|
try {
|
||||||
|
const asset = await uploadBarkBattleAsset({
|
||||||
|
slot,
|
||||||
|
file,
|
||||||
|
draftId: draft.draftId,
|
||||||
|
});
|
||||||
|
onChange(applyAssetToDraft(draft, slot, asset.assetSrc));
|
||||||
|
} catch (error) {
|
||||||
|
onError(error instanceof Error ? error.message : '上传素材失败。');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
if (!isImageSlot) {
|
||||||
|
onError('狗叫音效暂未接入自动生成,请先手动上传音频。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRegenerating(true);
|
||||||
|
onError(null);
|
||||||
|
try {
|
||||||
|
const result = await regenerateBarkBattleImageAsset({
|
||||||
|
slot: slot as BarkBattleImageSlot,
|
||||||
|
config: mapDraftToConfig(draft),
|
||||||
|
draftId: draft.draftId,
|
||||||
|
});
|
||||||
|
onChange(applyAssetToDraft(draft, slot, result.imageSrc));
|
||||||
|
} catch (error) {
|
||||||
|
onError(error instanceof Error ? error.message : '重新生成素材失败。');
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSlotBusy = isUploading || isRegenerating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="m-0 text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
{SLOT_LABELS[slot]}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 truncate text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||||
|
{assetSrc || '未替换'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSlotBusy ? (
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
|
||||||
|
) : isImageSlot ? (
|
||||||
|
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={isImageSlot ? 'image/png,image/jpeg,image/webp' : 'audio/mpeg,audio/wav,audio/ogg,audio/webm'}
|
||||||
|
className="hidden"
|
||||||
|
aria-label={`上传${SLOT_LABELS[slot]}文件`}
|
||||||
|
onChange={handleUpload}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || isSlotBusy}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
上传
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || isSlotBusy}
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
重新生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BarkBattleResultView({
|
||||||
|
draft,
|
||||||
|
isBusy = false,
|
||||||
|
error = null,
|
||||||
|
onBack,
|
||||||
|
onDraftChange,
|
||||||
|
onStartTestRun,
|
||||||
|
onPublish,
|
||||||
|
}: BarkBattleResultViewProps) {
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
|
||||||
|
const visibleError = localError ?? error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
|
||||||
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||||
|
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
返回编辑
|
||||||
|
</button>
|
||||||
|
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||||
|
草稿
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
|
||||||
|
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||||
|
<div className="text-sm font-black text-[var(--platform-text-soft)]">
|
||||||
|
草稿编译
|
||||||
|
</div>
|
||||||
|
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
|
||||||
|
{draft.title || '未命名声浪竞技场'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
'player-character',
|
||||||
|
'opponent-character',
|
||||||
|
'ui-background',
|
||||||
|
'bark-sound',
|
||||||
|
] as const
|
||||||
|
).map((slot) => (
|
||||||
|
<BarkBattleAssetSlotControl
|
||||||
|
key={slot}
|
||||||
|
draft={draft}
|
||||||
|
slot={slot}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(nextDraft) => {
|
||||||
|
setLocalError(null);
|
||||||
|
onDraftChange(nextDraft);
|
||||||
|
}}
|
||||||
|
onError={setLocalError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BarkBattlePreviewCard config={previewConfig} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{visibleError ? (
|
||||||
|
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||||
|
{visibleError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
|
||||||
|
<ResultActionButton
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onStartTestRun(draft)}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
试玩
|
||||||
|
</ResultActionButton>
|
||||||
|
<ResultActionButton
|
||||||
|
tone="primary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onPublish(draft)}
|
||||||
|
>
|
||||||
|
{isBusy ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
发布
|
||||||
|
</ResultActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarkBattleResultView;
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||||
import type {
|
import type {
|
||||||
BarkBattleConfigEditorPayload,
|
BarkBattleConfigEditorPayload,
|
||||||
|
BarkBattleDraftConfig,
|
||||||
BarkBattlePublishedConfig,
|
BarkBattlePublishedConfig,
|
||||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||||
import type {
|
import type {
|
||||||
@@ -409,6 +410,7 @@ type PuzzleOnboardingDraft = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
|
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
|
||||||
|
type BarkBattleRuntimeReturnStage = 'bark-battle-result' | 'platform';
|
||||||
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
||||||
type RecommendRuntimeKind =
|
type RecommendRuntimeKind =
|
||||||
| 'big-fish'
|
| 'big-fish'
|
||||||
@@ -2092,6 +2094,13 @@ const BarkBattleConfigEditor = lazy(async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BarkBattleResultView = lazy(async () => {
|
||||||
|
const module = await import('../bark-battle-creation/BarkBattleResultView');
|
||||||
|
return {
|
||||||
|
default: module.BarkBattleResultView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const BarkBattleRuntimeShell = lazy(async () => {
|
const BarkBattleRuntimeShell = lazy(async () => {
|
||||||
const module = await import('../../games/bark-battle/ui/BarkBattleRuntimeShell');
|
const module = await import('../../games/bark-battle/ui/BarkBattleRuntimeShell');
|
||||||
return {
|
return {
|
||||||
@@ -2319,6 +2328,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
useState<MiniGameDraftGenerationState | null>(null);
|
useState<MiniGameDraftGenerationState | null>(null);
|
||||||
const [barkBattlePublishedConfig, setBarkBattlePublishedConfig] =
|
const [barkBattlePublishedConfig, setBarkBattlePublishedConfig] =
|
||||||
useState<BarkBattlePublishedConfig | null>(null);
|
useState<BarkBattlePublishedConfig | null>(null);
|
||||||
|
const [barkBattleDraftConfig, setBarkBattleDraftConfig] =
|
||||||
|
useState<BarkBattleDraftConfig | null>(null);
|
||||||
|
const [barkBattleRuntimeReturnStage, setBarkBattleRuntimeReturnStage] =
|
||||||
|
useState<BarkBattleRuntimeReturnStage>('platform');
|
||||||
const [barkBattleError, setBarkBattleError] = useState<string | null>(null);
|
const [barkBattleError, setBarkBattleError] = useState<string | null>(null);
|
||||||
const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false);
|
const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false);
|
||||||
const [bigFishRun, setBigFishRun] =
|
const [bigFishRun, setBigFishRun] =
|
||||||
@@ -5540,24 +5553,104 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}, [squareHoleFlow]);
|
}, [squareHoleFlow]);
|
||||||
|
|
||||||
const leaveBarkBattleFlow = useCallback(() => {
|
const leaveBarkBattleFlow = useCallback(() => {
|
||||||
|
setBarkBattleDraftConfig(null);
|
||||||
setBarkBattlePublishedConfig(null);
|
setBarkBattlePublishedConfig(null);
|
||||||
|
setBarkBattleRuntimeReturnStage('platform');
|
||||||
setBarkBattleError(null);
|
setBarkBattleError(null);
|
||||||
setIsBarkBattleBusy(false);
|
setIsBarkBattleBusy(false);
|
||||||
selectionStageRef.current = 'platform';
|
selectionStageRef.current = 'platform';
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
}, [setSelectionStage]);
|
}, [setSelectionStage]);
|
||||||
|
|
||||||
const publishBarkBattleConfig = useCallback(
|
const createBarkBattleResultDraft = useCallback(
|
||||||
async (payload: BarkBattleConfigEditorPayload) => {
|
async (payload: BarkBattleConfigEditorPayload) => {
|
||||||
setBarkBattleError(null);
|
setBarkBattleError(null);
|
||||||
setIsBarkBattleBusy(true);
|
setIsBarkBattleBusy(true);
|
||||||
try {
|
try {
|
||||||
const draft = await createBarkBattleDraft(payload);
|
const draft = await createBarkBattleDraft(payload);
|
||||||
|
setBarkBattleDraftConfig(draft);
|
||||||
|
setBarkBattlePublishedConfig(null);
|
||||||
|
setSelectionStage('bark-battle-result');
|
||||||
|
} catch (error) {
|
||||||
|
setBarkBattleError(
|
||||||
|
resolvePuzzleErrorMessage(error, '生成汪汪声浪草稿失败。'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBarkBattleBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resolvePuzzleErrorMessage, setSelectionStage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildBarkBattleDraftRuntimeConfig = useCallback(
|
||||||
|
(draft: BarkBattleDraftConfig): BarkBattlePublishedConfig => ({
|
||||||
|
workId: draft.workId ?? draft.draftId,
|
||||||
|
draftId: draft.draftId,
|
||||||
|
configVersion: draft.configVersion ?? 1,
|
||||||
|
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
|
||||||
|
playTypeId: 'bark-battle',
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
themePreset: draft.themePreset,
|
||||||
|
playerDogSkinPreset: draft.playerDogSkinPreset,
|
||||||
|
opponentDogSkinPreset: draft.opponentDogSkinPreset,
|
||||||
|
playerCharacterImageSrc: draft.playerCharacterImageSrc,
|
||||||
|
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
|
||||||
|
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
|
||||||
|
barkSoundSrc: draft.barkSoundSrc,
|
||||||
|
difficultyPreset: draft.difficultyPreset,
|
||||||
|
leaderboardEnabled: draft.leaderboardEnabled,
|
||||||
|
updatedAt: draft.updatedAt,
|
||||||
|
publishedAt: draft.updatedAt,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const testBarkBattleDraft = useCallback(
|
||||||
|
(draft: BarkBattleDraftConfig) => {
|
||||||
|
setBarkBattleError(null);
|
||||||
|
setBarkBattleRuntimeReturnStage('bark-battle-result');
|
||||||
|
setBarkBattlePublishedConfig(buildBarkBattleDraftRuntimeConfig(draft));
|
||||||
|
setSelectionStage('bark-battle-runtime');
|
||||||
|
},
|
||||||
|
[buildBarkBattleDraftRuntimeConfig, setSelectionStage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const publishBarkBattleDraft = useCallback(
|
||||||
|
async (draft: BarkBattleDraftConfig) => {
|
||||||
|
setBarkBattleError(null);
|
||||||
|
const workId = draft.workId?.trim();
|
||||||
|
if (!workId) {
|
||||||
|
setBarkBattleError('这份汪汪声浪草稿缺少作品ID,请重新生成草稿后再发布。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBarkBattleBusy(true);
|
||||||
|
try {
|
||||||
|
const publishedSnapshot: BarkBattleConfigEditorPayload = {
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
themePreset: draft.themePreset,
|
||||||
|
playerDogSkinPreset: draft.playerDogSkinPreset,
|
||||||
|
opponentDogSkinPreset: draft.opponentDogSkinPreset,
|
||||||
|
...(draft.playerCharacterImageSrc
|
||||||
|
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
|
||||||
|
: {}),
|
||||||
|
...(draft.opponentCharacterImageSrc
|
||||||
|
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
|
||||||
|
: {}),
|
||||||
|
...(draft.uiBackgroundImageSrc
|
||||||
|
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
|
||||||
|
: {}),
|
||||||
|
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
|
||||||
|
difficultyPreset: draft.difficultyPreset,
|
||||||
|
leaderboardEnabled: draft.leaderboardEnabled,
|
||||||
|
};
|
||||||
const published = await publishBarkBattleWork({
|
const published = await publishBarkBattleWork({
|
||||||
draftId: draft.draftId,
|
draftId: draft.draftId,
|
||||||
workId: draft.workId,
|
workId,
|
||||||
publishedSnapshot: payload,
|
publishedSnapshot,
|
||||||
});
|
});
|
||||||
|
setBarkBattleRuntimeReturnStage('platform');
|
||||||
setBarkBattlePublishedConfig(published);
|
setBarkBattlePublishedConfig(published);
|
||||||
setSelectionStage('bark-battle-runtime');
|
setSelectionStage('bark-battle-runtime');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -10855,7 +10948,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 min-h-0 flex-1 overflow-hidden">
|
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{activeCreationFormType === 'match3d' ? (
|
{activeCreationFormType === 'match3d' ? (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<LazyPanelFallback label="正在加载抓大鹅创作..." />}
|
fallback={<LazyPanelFallback label="正在加载抓大鹅创作..." />}
|
||||||
@@ -10921,8 +11014,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isBusy={isBarkBattleBusy}
|
isBusy={isBarkBattleBusy}
|
||||||
error={barkBattleError}
|
error={barkBattleError}
|
||||||
onBack={leaveBarkBattleFlow}
|
onBack={leaveBarkBattleFlow}
|
||||||
onPublish={(payload) => {
|
onPreview={(payload) => {
|
||||||
void publishBarkBattleConfig(payload);
|
void createBarkBattleResultDraft(payload);
|
||||||
}}
|
}}
|
||||||
showBackButton={false}
|
showBackButton={false}
|
||||||
title={null}
|
title={null}
|
||||||
@@ -12544,6 +12637,36 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectionStage === 'bark-battle-result' && barkBattleDraftConfig && (
|
||||||
|
<motion.div
|
||||||
|
key="bark-battle-result"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
className="flex h-full min-h-0 flex-col"
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={<LazyPanelFallback label="正在加载汪汪声浪草稿..." />}
|
||||||
|
>
|
||||||
|
<BarkBattleResultView
|
||||||
|
draft={barkBattleDraftConfig}
|
||||||
|
isBusy={isBarkBattleBusy}
|
||||||
|
error={barkBattleError}
|
||||||
|
onBack={() => {
|
||||||
|
enterCreateTab();
|
||||||
|
setActiveCreationFormType('bark-battle');
|
||||||
|
setSelectionStage('platform');
|
||||||
|
}}
|
||||||
|
onDraftChange={setBarkBattleDraftConfig}
|
||||||
|
onStartTestRun={testBarkBattleDraft}
|
||||||
|
onPublish={(draft) => {
|
||||||
|
void publishBarkBattleDraft(draft);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
|
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="bark-battle-runtime"
|
key="bark-battle-runtime"
|
||||||
@@ -12560,9 +12683,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
workId={barkBattlePublishedConfig.workId}
|
workId={barkBattlePublishedConfig.workId}
|
||||||
publishedConfig={barkBattlePublishedConfig}
|
publishedConfig={barkBattlePublishedConfig}
|
||||||
onExit={() => {
|
onExit={() => {
|
||||||
|
if (
|
||||||
|
barkBattleRuntimeReturnStage === 'bark-battle-result' &&
|
||||||
|
barkBattleDraftConfig
|
||||||
|
) {
|
||||||
|
setSelectionStage('bark-battle-result');
|
||||||
|
} else {
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setActiveCreationFormType('bark-battle');
|
setActiveCreationFormType('bark-battle');
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type SelectionStage =
|
|||||||
| 'square-hole-generating'
|
| 'square-hole-generating'
|
||||||
| 'square-hole-result'
|
| 'square-hole-result'
|
||||||
| 'square-hole-runtime'
|
| 'square-hole-runtime'
|
||||||
|
| 'bark-battle-result'
|
||||||
| 'bark-battle-runtime'
|
| 'bark-battle-runtime'
|
||||||
| 'creative-agent-workspace'
|
| 'creative-agent-workspace'
|
||||||
| 'visual-novel-agent-workspace'
|
| 'visual-novel-agent-workspace'
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||||
import type {
|
|
||||||
BabyObjectMatchDraft,
|
|
||||||
CreateBabyObjectMatchDraftRequest,
|
|
||||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
|
||||||
import type {
|
import type {
|
||||||
CustomWorldAgentSessionSnapshot,
|
CustomWorldAgentSessionSnapshot,
|
||||||
CustomWorldWorkSummary,
|
CustomWorldWorkSummary,
|
||||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
|
import type {
|
||||||
|
BabyObjectMatchDraft,
|
||||||
|
CreateBabyObjectMatchDraftRequest,
|
||||||
|
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
|
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||||
import type {
|
import type {
|
||||||
PuzzleAnchorPack,
|
PuzzleAnchorPack,
|
||||||
PuzzleResultDraft,
|
PuzzleResultDraft,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
|
||||||
import type {
|
import type {
|
||||||
CreatePuzzleAgentSessionRequest,
|
CreatePuzzleAgentSessionRequest,
|
||||||
PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionSnapshot,
|
||||||
@@ -42,13 +42,9 @@ import {
|
|||||||
import { ApiClientError } from '../../services/apiClient';
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
import type { AuthUser } from '../../services/authService';
|
import type { AuthUser } from '../../services/authService';
|
||||||
import {
|
import {
|
||||||
createBabyObjectMatchDraft,
|
createBarkBattleDraft,
|
||||||
deleteLocalBabyObjectMatchDraft,
|
publishBarkBattleWork,
|
||||||
listLocalBabyObjectMatchDrafts,
|
} from '../../services/bark-battle-creation';
|
||||||
publishBabyObjectMatchWork,
|
|
||||||
regenerateBabyObjectMatchDraftAssets,
|
|
||||||
saveBabyObjectMatchDraft,
|
|
||||||
} from '../../services/edutainment-baby-object';
|
|
||||||
import {
|
import {
|
||||||
createBigFishCreationSession,
|
createBigFishCreationSession,
|
||||||
getBigFishCreationSession,
|
getBigFishCreationSession,
|
||||||
@@ -60,10 +56,6 @@ import {
|
|||||||
submitBigFishInput,
|
submitBigFishInput,
|
||||||
} from '../../services/big-fish-runtime';
|
} from '../../services/big-fish-runtime';
|
||||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||||
import {
|
|
||||||
createBarkBattleDraft,
|
|
||||||
publishBarkBattleWork,
|
|
||||||
} from '../../services/bark-battle-creation';
|
|
||||||
import {
|
import {
|
||||||
type CreationEntryConfig,
|
type CreationEntryConfig,
|
||||||
fetchCreationEntryConfig,
|
fetchCreationEntryConfig,
|
||||||
@@ -75,6 +67,14 @@ import {
|
|||||||
streamCreativeAgentMessage,
|
streamCreativeAgentMessage,
|
||||||
streamCreativeDraftEdit,
|
streamCreativeDraftEdit,
|
||||||
} from '../../services/creative-agent';
|
} from '../../services/creative-agent';
|
||||||
|
import {
|
||||||
|
createBabyObjectMatchDraft,
|
||||||
|
deleteLocalBabyObjectMatchDraft,
|
||||||
|
listLocalBabyObjectMatchDrafts,
|
||||||
|
publishBabyObjectMatchWork,
|
||||||
|
regenerateBabyObjectMatchDraftAssets,
|
||||||
|
saveBabyObjectMatchDraft,
|
||||||
|
} from '../../services/edutainment-baby-object';
|
||||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||||
import {
|
import {
|
||||||
@@ -476,6 +476,8 @@ vi.mock('../../services/big-fish-runtime', () => ({
|
|||||||
vi.mock('../../services/bark-battle-creation', () => ({
|
vi.mock('../../services/bark-battle-creation', () => ({
|
||||||
createBarkBattleDraft: vi.fn(),
|
createBarkBattleDraft: vi.fn(),
|
||||||
publishBarkBattleWork: vi.fn(),
|
publishBarkBattleWork: vi.fn(),
|
||||||
|
regenerateBarkBattleImageAsset: vi.fn(),
|
||||||
|
uploadBarkBattleAsset: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/edutainment-baby-object', () => ({
|
vi.mock('../../services/edutainment-baby-object', () => ({
|
||||||
@@ -989,13 +991,13 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
|
|||||||
isBusy,
|
isBusy,
|
||||||
showBackButton,
|
showBackButton,
|
||||||
title,
|
title,
|
||||||
onPublish,
|
onPreview,
|
||||||
}: {
|
}: {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
onPublish: (payload: {
|
onPreview: (payload: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
themePreset: string;
|
themePreset: string;
|
||||||
@@ -1021,7 +1023,7 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPublish({
|
onPreview({
|
||||||
title: '汪汪测试杯',
|
title: '汪汪测试杯',
|
||||||
description: '',
|
description: '',
|
||||||
themePreset: 'sunny-yard',
|
themePreset: 'sunny-yard',
|
||||||
@@ -1032,7 +1034,40 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
发布并试玩
|
生成草稿
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../bark-battle-creation/BarkBattleResultView', () => ({
|
||||||
|
BarkBattleResultView: ({
|
||||||
|
draft,
|
||||||
|
onBack,
|
||||||
|
onPublish,
|
||||||
|
onStartTestRun,
|
||||||
|
}: {
|
||||||
|
draft: {
|
||||||
|
title: string;
|
||||||
|
draftId: string;
|
||||||
|
workId?: string;
|
||||||
|
};
|
||||||
|
onBack: () => void;
|
||||||
|
onPublish: (draft: unknown) => void;
|
||||||
|
onStartTestRun: (draft: unknown) => void;
|
||||||
|
}) => (
|
||||||
|
<div className="bark-battle-result-view-mock">
|
||||||
|
<div>汪汪声浪结果页:{draft.title}</div>
|
||||||
|
<div>草稿ID:{draft.draftId}</div>
|
||||||
|
<div>作品ID:{draft.workId ?? 'missing-work'}</div>
|
||||||
|
<button type="button" onClick={() => onStartTestRun(draft)}>
|
||||||
|
试玩
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onPublish(draft)}>
|
||||||
|
发布
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onBack}>
|
||||||
|
返回编辑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -3201,14 +3236,14 @@ test('create tab switches bark battle into the embedded config form', async () =
|
|||||||
expect(publishBarkBattleWork).not.toHaveBeenCalled();
|
expect(publishBarkBattleWork).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bark battle publish preview returns to the embedded config form', async () => {
|
test('bark battle draft result can test before publish and return to the embedded form', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openCreateTemplateHub(user);
|
await openCreateTemplateHub(user);
|
||||||
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
|
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
|
||||||
await user.click(await screen.findByRole('button', { name: '发布并试玩' }));
|
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||||
|
|
||||||
expect(createBarkBattleDraft).toHaveBeenCalledWith({
|
expect(createBarkBattleDraft).toHaveBeenCalledWith({
|
||||||
title: '汪汪测试杯',
|
title: '汪汪测试杯',
|
||||||
@@ -3219,6 +3254,27 @@ test('bark battle publish preview returns to the embedded config form', async ()
|
|||||||
difficultyPreset: 'normal',
|
difficultyPreset: 'normal',
|
||||||
leaderboardEnabled: true,
|
leaderboardEnabled: true,
|
||||||
});
|
});
|
||||||
|
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
|
||||||
|
expect(await screen.findByText('作品ID:bark-battle-work-1')).toBeTruthy();
|
||||||
|
expect(publishBarkBattleWork).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||||
|
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '返回配置' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||||
|
|
||||||
|
expect(publishBarkBattleWork).toHaveBeenCalledWith({
|
||||||
|
draftId: 'bark-battle-draft-1',
|
||||||
|
workId: 'bark-battle-work-1',
|
||||||
|
publishedSnapshot: expect.objectContaining({
|
||||||
|
title: '汪汪测试杯',
|
||||||
|
leaderboardEnabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
|
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '返回配置' }));
|
await user.click(screen.getByRole('button', { name: '返回配置' }));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.bark-battle-hud {
|
.bark-battle-hud {
|
||||||
|
position: relative;
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
color: #fff7ed;
|
color: #fff7ed;
|
||||||
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
|
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
|
||||||
@@ -10,7 +11,18 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bark-battle-hud__background-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
.bark-battle-hud__topline {
|
.bark-battle-hud__topline {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -38,6 +50,8 @@
|
|||||||
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
|
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
|
||||||
|
|
||||||
.bark-battle-arena {
|
.bark-battle-arena {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -54,6 +68,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bark-battle-dog__body {
|
.bark-battle-dog__body {
|
||||||
|
display: grid;
|
||||||
|
width: clamp(112px, 34vw, 170px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
place-items: center;
|
||||||
font-size: clamp(92px, 30vw, 150px);
|
font-size: clamp(92px, 30vw, 150px);
|
||||||
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
|
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
|
||||||
}
|
}
|
||||||
@@ -62,6 +80,12 @@
|
|||||||
transform: rotateY(180deg) translateY(4px);
|
transform: rotateY(180deg) translateY(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bark-battle-dog__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.bark-battle-dog__label,
|
.bark-battle-dog__label,
|
||||||
.bark-battle-dog__burst,
|
.bark-battle-dog__burst,
|
||||||
.bark-battle-vs {
|
.bark-battle-vs {
|
||||||
@@ -94,6 +118,8 @@
|
|||||||
|
|
||||||
.bark-battle-controls,
|
.bark-battle-controls,
|
||||||
.bark-battle-result__stats {
|
.bark-battle-result__stats {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import './BarkBattleHud.css';
|
import './BarkBattleHud.css';
|
||||||
|
|
||||||
|
import { ResolvedAssetImage } from '../../../components/ResolvedAssetImage';
|
||||||
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
|
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
|
||||||
|
|
||||||
type BarkBattleHudProps = {
|
type BarkBattleHudProps = {
|
||||||
@@ -10,6 +11,9 @@ type BarkBattleHudProps = {
|
|||||||
onMockBark?: () => void;
|
onMockBark?: () => void;
|
||||||
onMockQuiet?: () => void;
|
onMockQuiet?: () => void;
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
|
playerCharacterImageSrc?: string | null;
|
||||||
|
opponentCharacterImageSrc?: string | null;
|
||||||
|
uiBackgroundImageSrc?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const failureText = {
|
const failureText = {
|
||||||
@@ -32,6 +36,9 @@ export function BarkBattleHud({
|
|||||||
onMockBark,
|
onMockBark,
|
||||||
onMockQuiet,
|
onMockQuiet,
|
||||||
onRestart,
|
onRestart,
|
||||||
|
playerCharacterImageSrc,
|
||||||
|
opponentCharacterImageSrc,
|
||||||
|
uiBackgroundImageSrc,
|
||||||
}: BarkBattleHudProps) {
|
}: BarkBattleHudProps) {
|
||||||
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
|
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
|
||||||
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
|
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
|
||||||
@@ -39,6 +46,14 @@ export function BarkBattleHud({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
|
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
|
||||||
|
{uiBackgroundImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={uiBackgroundImageSrc}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="bark-battle-hud__background-image"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<header className="bark-battle-hud__topline">
|
<header className="bark-battle-hud__topline">
|
||||||
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
|
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
|
||||||
<div
|
<div
|
||||||
@@ -65,17 +80,37 @@ export function BarkBattleHud({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
|
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
|
||||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
|
||||||
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
|
||||||
<span className="bark-battle-dog__body">🐕</span>
|
|
||||||
<span className="bark-battle-dog__label">你 · {snapshot.player.barkCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bark-battle-vs">VS</div>
|
|
||||||
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
|
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
|
||||||
<span className="bark-battle-dog__burst" aria-hidden="true">反击</span>
|
<span className="bark-battle-dog__burst" aria-hidden="true">反击</span>
|
||||||
<span className="bark-battle-dog__body">🐶</span>
|
<span className="bark-battle-dog__body">
|
||||||
|
{opponentCharacterImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={opponentCharacterImageSrc}
|
||||||
|
alt=""
|
||||||
|
className="bark-battle-dog__image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'🐶'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="bark-battle-dog__label">对手 · {snapshot.opponent.barkCount}</span>
|
<span className="bark-battle-dog__label">对手 · {snapshot.opponent.barkCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bark-battle-vs">VS</div>
|
||||||
|
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
||||||
|
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
||||||
|
<span className="bark-battle-dog__body">
|
||||||
|
{playerCharacterImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={playerCharacterImageSrc}
|
||||||
|
alt=""
|
||||||
|
className="bark-battle-dog__image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'🐕'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="bark-battle-dog__label">你 · {snapshot.player.barkCount}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
|
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
|
||||||
|
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
|
||||||
import {
|
import {
|
||||||
type BarkBattleConfig,
|
type BarkBattleConfig,
|
||||||
DEFAULT_BARK_BATTLE_CONFIG,
|
DEFAULT_BARK_BATTLE_CONFIG,
|
||||||
@@ -113,11 +114,25 @@ export function BarkBattleRuntimeShell({
|
|||||||
const [playerPulseKey, setPlayerPulseKey] = useState(0);
|
const [playerPulseKey, setPlayerPulseKey] = useState(0);
|
||||||
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
|
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
|
||||||
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
|
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
|
||||||
|
const barkAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const heldRef = useRef(false);
|
const heldRef = useRef(false);
|
||||||
const lastPlayerBarkCountRef = useRef(0);
|
const lastPlayerBarkCountRef = useRef(0);
|
||||||
const lastOpponentPowerRef = useRef(0);
|
const lastOpponentPowerRef = useRef(0);
|
||||||
const debugEventIdRef = useRef(0);
|
const debugEventIdRef = useRef(0);
|
||||||
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
|
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
|
||||||
|
const replacementConfig = publishedConfig ?? null;
|
||||||
|
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl(
|
||||||
|
replacementConfig?.barkSoundSrc ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const playBarkSound = useCallback(() => {
|
||||||
|
const audio = barkAudioRef.current;
|
||||||
|
if (!audio || !resolvedBarkSoundSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audio.currentTime = 0;
|
||||||
|
void audio.play().catch(() => {});
|
||||||
|
}, [resolvedBarkSoundSrc]);
|
||||||
|
|
||||||
const appendDebugEvent = useCallback((text: string) => {
|
const appendDebugEvent = useCallback((text: string) => {
|
||||||
debugEventIdRef.current += 1;
|
debugEventIdRef.current += 1;
|
||||||
@@ -129,16 +144,18 @@ export function BarkBattleRuntimeShell({
|
|||||||
const nextSnapshot = controller.getSnapshot();
|
const nextSnapshot = controller.getSnapshot();
|
||||||
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
|
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
|
||||||
setPlayerPulseKey((current) => current + 1);
|
setPlayerPulseKey((current) => current + 1);
|
||||||
|
playBarkSound();
|
||||||
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
|
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
|
||||||
}
|
}
|
||||||
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
|
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
|
||||||
setOpponentPulseKey((current) => current + 1);
|
setOpponentPulseKey((current) => current + 1);
|
||||||
|
playBarkSound();
|
||||||
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
|
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
|
||||||
}
|
}
|
||||||
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
|
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
|
||||||
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
|
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
|
||||||
setSnapshot(nextSnapshot);
|
setSnapshot(nextSnapshot);
|
||||||
}, [appendDebugEvent, controller]);
|
}, [appendDebugEvent, controller, playBarkSound]);
|
||||||
|
|
||||||
const stopMicrophone = useCallback(() => {
|
const stopMicrophone = useCallback(() => {
|
||||||
microphoneSamplerRef.current?.stop();
|
microphoneSamplerRef.current?.stop();
|
||||||
@@ -237,10 +254,16 @@ export function BarkBattleRuntimeShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bark-battle-runtime" aria-label={title}>
|
<main className="bark-battle-runtime" aria-label={title}>
|
||||||
|
{resolvedBarkSoundSrc ? (
|
||||||
|
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" />
|
||||||
|
) : null}
|
||||||
<BarkBattleHud
|
<BarkBattleHud
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
playerPulseKey={playerPulseKey}
|
playerPulseKey={playerPulseKey}
|
||||||
opponentPulseKey={opponentPulseKey}
|
opponentPulseKey={opponentPulseKey}
|
||||||
|
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc}
|
||||||
|
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc}
|
||||||
|
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc}
|
||||||
onStartMicrophone={startMicrophone}
|
onStartMicrophone={startMicrophone}
|
||||||
onMockBark={bark}
|
onMockBark={bark}
|
||||||
onMockQuiet={() => {
|
onMockQuiet={() => {
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
|
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
|
||||||
import { BarkBattleHud } from '../BarkBattleHud';
|
import { BarkBattleHud } from '../BarkBattleHud';
|
||||||
|
|
||||||
|
vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||||
|
ResolvedAssetImage: ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
src?: string | null;
|
||||||
|
alt?: string;
|
||||||
|
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
|
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
|
||||||
return {
|
return {
|
||||||
phase: 'playing',
|
phase: 'playing',
|
||||||
@@ -33,6 +44,8 @@ describe('BarkBattleHud', () => {
|
|||||||
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
|
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
|
||||||
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
|
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
|
||||||
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
|
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
|
||||||
|
const arenaText = screen.getByLabelText('竖屏声浪竞技场').textContent ?? '';
|
||||||
|
expect(arenaText.indexOf('对手 · 1')).toBeLessThan(arenaText.indexOf('你 · 3'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('energy 正负值会改变玩家侧和对手侧占比', () => {
|
it('energy 正负值会改变玩家侧和对手侧占比', () => {
|
||||||
@@ -54,4 +67,19 @@ describe('BarkBattleHud', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('展示自定义角色形象和 UI 背景', () => {
|
||||||
|
render(
|
||||||
|
<BarkBattleHud
|
||||||
|
snapshot={buildSnapshot()}
|
||||||
|
playerCharacterImageSrc="/generated-bark-battle/player.png"
|
||||||
|
opponentCharacterImageSrc="https://example.test/opponent.png"
|
||||||
|
uiBackgroundImageSrc="/generated-bark-battle/ui.png"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
|
||||||
|
expect(document.querySelector('img[src="https://example.test/opponent.png"]')).toBeTruthy();
|
||||||
|
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,60 @@
|
|||||||
|
|
||||||
import { render, screen, within } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
|
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/useResolvedAssetReadUrl', () => ({
|
||||||
|
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
|
||||||
|
resolvedUrl: source ?? '',
|
||||||
|
isResolving: false,
|
||||||
|
shouldResolve: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||||
|
ResolvedAssetImage: ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
src?: string | null;
|
||||||
|
alt?: string;
|
||||||
|
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('BarkBattleRuntimeShell 调试面板', () => {
|
describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||||
|
it('从发布配置加载自定义狗叫音效资源', () => {
|
||||||
|
render(
|
||||||
|
<BarkBattleRuntimeShell
|
||||||
|
publishedConfig={{
|
||||||
|
workId: 'work-bark-1',
|
||||||
|
draftId: 'draft-bark-1',
|
||||||
|
configVersion: 2,
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
|
playTypeId: 'bark-battle',
|
||||||
|
title: '周末狗狗杯',
|
||||||
|
themePreset: 'neon-park',
|
||||||
|
playerDogSkinPreset: 'shiba',
|
||||||
|
opponentDogSkinPreset: 'husky',
|
||||||
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||||
|
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||||
|
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
|
||||||
|
barkSoundSrc: '/generated-bark-battle/bark.mp3',
|
||||||
|
difficultyPreset: 'hard',
|
||||||
|
leaderboardEnabled: true,
|
||||||
|
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||||
|
publishedAt: '2026-05-13T03:00:00.000Z',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('audio[src="/generated-bark-battle/bark.mp3"]')).toBeTruthy();
|
||||||
|
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
|
||||||
|
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||||
render(<BarkBattleRuntimeShell />);
|
render(<BarkBattleRuntimeShell />);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ describe('barkBattleCreationClient', () => {
|
|||||||
themePreset: 'neon-park',
|
themePreset: 'neon-park',
|
||||||
playerDogSkinPreset: 'shiba',
|
playerDogSkinPreset: 'shiba',
|
||||||
opponentDogSkinPreset: 'husky',
|
opponentDogSkinPreset: 'husky',
|
||||||
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||||
|
opponentCharacterImageSrc: 'https://example.test/opponent.png',
|
||||||
|
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
|
||||||
|
barkSoundSrc: '/generated-bark-battle/bark.mp3',
|
||||||
difficultyPreset: 'hard',
|
difficultyPreset: 'hard',
|
||||||
leaderboardEnabled: true,
|
leaderboardEnabled: true,
|
||||||
});
|
});
|
||||||
@@ -37,6 +41,10 @@ describe('barkBattleCreationClient', () => {
|
|||||||
themePreset: 'neon-park',
|
themePreset: 'neon-park',
|
||||||
playerDogSkinPreset: 'shiba',
|
playerDogSkinPreset: 'shiba',
|
||||||
opponentDogSkinPreset: 'husky',
|
opponentDogSkinPreset: 'husky',
|
||||||
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||||
|
opponentCharacterImageSrc: 'https://example.test/opponent.png',
|
||||||
|
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
|
||||||
|
barkSoundSrc: '/generated-bark-battle/bark.mp3',
|
||||||
difficultyPreset: 'hard',
|
difficultyPreset: 'hard',
|
||||||
leaderboardEnabled: true,
|
leaderboardEnabled: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BarkBattleConfigEditorPayload,
|
||||||
BarkBattleDraftConfig,
|
BarkBattleDraftConfig,
|
||||||
BarkBattleDraftCreateRequest,
|
BarkBattleDraftCreateRequest,
|
||||||
BarkBattlePublishedConfig,
|
BarkBattlePublishedConfig,
|
||||||
BarkBattleWorkPublishRequest,
|
BarkBattleWorkPublishRequest,
|
||||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||||
|
import type { CustomWorldSceneImageResult } from '../aiTypes';
|
||||||
import {
|
import {
|
||||||
type ApiRequestOptions,
|
type ApiRequestOptions,
|
||||||
type ApiRetryOptions,
|
type ApiRetryOptions,
|
||||||
requestJson,
|
requestJson,
|
||||||
} from '../apiClient';
|
} from '../apiClient';
|
||||||
|
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
|
||||||
|
|
||||||
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
|
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
|
||||||
|
const BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
|
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
@@ -27,6 +32,171 @@ export type BarkBattleCreationRequestOptions = Pick<
|
|||||||
| 'clearAuthOnUnauthorized'
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type BarkBattleAssetSlot =
|
||||||
|
| 'player-character'
|
||||||
|
| 'opponent-character'
|
||||||
|
| 'ui-background'
|
||||||
|
| 'bark-sound';
|
||||||
|
|
||||||
|
export type BarkBattleUploadedAsset = {
|
||||||
|
assetObjectId: string;
|
||||||
|
assetKind: string;
|
||||||
|
objectKey: string;
|
||||||
|
assetSrc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectUploadTicketResponse = {
|
||||||
|
upload: {
|
||||||
|
bucket: string;
|
||||||
|
host: string;
|
||||||
|
objectKey: string;
|
||||||
|
legacyPublicPath: string;
|
||||||
|
formFields: Record<string, string | null | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfirmAssetObjectResponse = {
|
||||||
|
assetObject: {
|
||||||
|
assetObjectId: string;
|
||||||
|
objectKey: string;
|
||||||
|
assetKind: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLOT_UPLOAD_CONFIG = {
|
||||||
|
'player-character': {
|
||||||
|
acceptKind: 'image',
|
||||||
|
assetKind: 'bark_battle_player_character_image',
|
||||||
|
legacyPrefix: 'generated-bark-battle-assets',
|
||||||
|
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
|
||||||
|
},
|
||||||
|
'opponent-character': {
|
||||||
|
acceptKind: 'image',
|
||||||
|
assetKind: 'bark_battle_opponent_character_image',
|
||||||
|
legacyPrefix: 'generated-bark-battle-assets',
|
||||||
|
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
|
||||||
|
},
|
||||||
|
'ui-background': {
|
||||||
|
acceptKind: 'image',
|
||||||
|
assetKind: 'bark_battle_ui_background_image',
|
||||||
|
legacyPrefix: 'generated-bark-battle-assets',
|
||||||
|
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
|
||||||
|
},
|
||||||
|
'bark-sound': {
|
||||||
|
acceptKind: 'audio',
|
||||||
|
assetKind: 'bark_battle_bark_sound',
|
||||||
|
legacyPrefix: 'generated-bark-battle-assets',
|
||||||
|
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES,
|
||||||
|
},
|
||||||
|
} satisfies Record<
|
||||||
|
BarkBattleAssetSlot,
|
||||||
|
{
|
||||||
|
acceptKind: 'image' | 'audio';
|
||||||
|
assetKind: string;
|
||||||
|
legacyPrefix: string;
|
||||||
|
maxSizeBytes: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
png: 'image/png',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
webm: 'audio/webm',
|
||||||
|
webp: 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveUploadContentType(file: File) {
|
||||||
|
if (file.type.trim()) {
|
||||||
|
return file.type.trim();
|
||||||
|
}
|
||||||
|
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
|
||||||
|
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) {
|
||||||
|
const config = SLOT_UPLOAD_CONFIG[slot];
|
||||||
|
const contentType = resolveUploadContentType(file);
|
||||||
|
if (file.size <= 0) {
|
||||||
|
throw new Error('素材文件为空,请重新选择。');
|
||||||
|
}
|
||||||
|
if (file.size > config.maxSizeBytes) {
|
||||||
|
throw new Error('素材文件过大,请压缩后再上传。');
|
||||||
|
}
|
||||||
|
if (config.acceptKind === 'image' && !contentType.startsWith('image/')) {
|
||||||
|
throw new Error('请选择图片素材。');
|
||||||
|
}
|
||||||
|
if (config.acceptKind === 'audio' && !contentType.startsWith('audio/')) {
|
||||||
|
throw new Error('请选择音频素材。');
|
||||||
|
}
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssetPathSegment(value: string) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9_-]+/gu, '-')
|
||||||
|
.replace(/^-+|-+$/gu, '')
|
||||||
|
.slice(0, 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUploadPathSegments(slot: BarkBattleAssetSlot, draftId?: string) {
|
||||||
|
return [
|
||||||
|
'bark-battle',
|
||||||
|
normalizeAssetPathSegment(draftId || 'draft') || 'draft',
|
||||||
|
slot,
|
||||||
|
String(Date.now()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDirectUploadFile(
|
||||||
|
upload: DirectUploadTicketResponse['upload'],
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(upload.formFields).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(upload.host, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('上传平台资产失败。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBarkBattleImagePrompt(
|
||||||
|
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>,
|
||||||
|
payload: BarkBattleConfigEditorPayload,
|
||||||
|
) {
|
||||||
|
const slotPrompt = {
|
||||||
|
'player-character': `玩家角色形象:${payload.playerDogSkinPreset}`,
|
||||||
|
'opponent-character': `对手角色形象:${payload.opponentDogSkinPreset}`,
|
||||||
|
'ui-background': `游戏 UI 背景:${payload.themePreset}`,
|
||||||
|
} satisfies Record<Exclude<BarkBattleAssetSlot, 'bark-sound'>, string>;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`汪汪声浪大作战《${payload.title}》`,
|
||||||
|
payload.description ?? '',
|
||||||
|
slotPrompt[slot],
|
||||||
|
slot === 'ui-background'
|
||||||
|
? '竖屏移动端游戏背景,无文字,无按钮,无角色遮挡'
|
||||||
|
: '游戏角色立绘,完整主体,透明感背景,无文字,无 UI',
|
||||||
|
]
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
export function createBarkBattleDraft(
|
export function createBarkBattleDraft(
|
||||||
payload: BarkBattleDraftCreateRequest,
|
payload: BarkBattleDraftCreateRequest,
|
||||||
options: BarkBattleCreationRequestOptions = {},
|
options: BarkBattleCreationRequestOptions = {},
|
||||||
@@ -71,7 +241,106 @@ export function publishBarkBattleWork(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadBarkBattleAsset(payload: {
|
||||||
|
slot: BarkBattleAssetSlot;
|
||||||
|
file: File;
|
||||||
|
draftId?: string | null;
|
||||||
|
}): Promise<BarkBattleUploadedAsset> {
|
||||||
|
const contentType = validateBarkBattleUploadFile(payload.slot, payload.file);
|
||||||
|
const config = SLOT_UPLOAD_CONFIG[payload.slot];
|
||||||
|
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||||
|
'/api/assets/direct-upload-tickets',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
legacyPrefix: config.legacyPrefix,
|
||||||
|
pathSegments: buildUploadPathSegments(
|
||||||
|
payload.slot,
|
||||||
|
payload.draftId ?? undefined,
|
||||||
|
),
|
||||||
|
fileName: payload.file.name,
|
||||||
|
contentType,
|
||||||
|
access: 'private',
|
||||||
|
maxSizeBytes: config.maxSizeBytes,
|
||||||
|
metadata: {
|
||||||
|
asset_kind: config.assetKind,
|
||||||
|
bark_battle_slot: payload.slot,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'创建汪汪声浪素材上传凭证失败',
|
||||||
|
);
|
||||||
|
|
||||||
|
await postDirectUploadFile(ticket.upload, payload.file);
|
||||||
|
|
||||||
|
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||||
|
'/api/assets/objects/confirm',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
bucket: ticket.upload.bucket,
|
||||||
|
objectKey: ticket.upload.objectKey,
|
||||||
|
contentType,
|
||||||
|
contentLength: payload.file.size,
|
||||||
|
assetKind: config.assetKind,
|
||||||
|
accessPolicy: 'private',
|
||||||
|
profileId: payload.draftId?.trim() || null,
|
||||||
|
entityId: payload.slot,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'确认汪汪声浪素材失败',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||||
|
assetKind: confirmed.assetObject.assetKind,
|
||||||
|
objectKey: confirmed.assetObject.objectKey,
|
||||||
|
assetSrc: ticket.upload.legacyPublicPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateBarkBattleImageAsset(payload: {
|
||||||
|
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>;
|
||||||
|
config: BarkBattleConfigEditorPayload;
|
||||||
|
draftId?: string | null;
|
||||||
|
}): Promise<CustomWorldSceneImageResult> {
|
||||||
|
return generateRpgWorldSceneImage({
|
||||||
|
profile: {
|
||||||
|
id: payload.draftId?.trim() || 'bark-battle-draft',
|
||||||
|
name: payload.config.title.trim() || '汪汪声浪大作战',
|
||||||
|
subtitle: '汪汪声浪',
|
||||||
|
summary: payload.config.description?.trim() || payload.config.themePreset,
|
||||||
|
tone: payload.config.themePreset,
|
||||||
|
playerGoal: '用声浪压过对手',
|
||||||
|
settingText: [
|
||||||
|
payload.config.themePreset,
|
||||||
|
payload.config.playerDogSkinPreset,
|
||||||
|
payload.config.opponentDogSkinPreset,
|
||||||
|
]
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
},
|
||||||
|
landmark: {
|
||||||
|
id: payload.slot,
|
||||||
|
name:
|
||||||
|
payload.slot === 'ui-background'
|
||||||
|
? '声浪竞技 UI 背景'
|
||||||
|
: payload.slot === 'player-character'
|
||||||
|
? payload.config.playerDogSkinPreset || '玩家角色'
|
||||||
|
: payload.config.opponentDogSkinPreset || '对手角色',
|
||||||
|
description: buildBarkBattleImagePrompt(payload.slot, payload.config),
|
||||||
|
},
|
||||||
|
userPrompt: buildBarkBattleImagePrompt(payload.slot, payload.config),
|
||||||
|
size: payload.slot === 'ui-background' ? '1024*1792' : '1024*1024',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const barkBattleCreationClient = {
|
export const barkBattleCreationClient = {
|
||||||
createDraft: createBarkBattleDraft,
|
createDraft: createBarkBattleDraft,
|
||||||
|
regenerateImageAsset: regenerateBarkBattleImageAsset,
|
||||||
publish: publishBarkBattleWork,
|
publish: publishBarkBattleWork,
|
||||||
|
uploadAsset: uploadBarkBattleAsset,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export {
|
export {
|
||||||
|
type BarkBattleAssetSlot,
|
||||||
barkBattleCreationClient,
|
barkBattleCreationClient,
|
||||||
type BarkBattleCreationRequestOptions,
|
type BarkBattleCreationRequestOptions,
|
||||||
|
type BarkBattleUploadedAsset,
|
||||||
createBarkBattleDraft,
|
createBarkBattleDraft,
|
||||||
publishBarkBattleWork,
|
publishBarkBattleWork,
|
||||||
|
regenerateBarkBattleImageAsset,
|
||||||
|
uploadBarkBattleAsset,
|
||||||
} from './barkBattleCreationClient';
|
} from './barkBattleCreationClient';
|
||||||
|
|||||||
Reference in New Issue
Block a user