diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index d4209973..6f747f92 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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 创作页图像输入统一封装为图像组件 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4f448db2..61e12cea 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -124,7 +124,26 @@ - 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。 - 排行榜分榜:按 `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 不再展示排行榜开关。 ## 方洞挑战 diff --git a/packages/shared/src/contracts/barkBattle.test.ts b/packages/shared/src/contracts/barkBattle.test.ts index ae2822c4..8a65acbe 100644 --- a/packages/shared/src/contracts/barkBattle.test.ts +++ b/packages/shared/src/contracts/barkBattle.test.ts @@ -12,11 +12,18 @@ describe('Bark Battle shared contracts', () => { test('default draft config fixture uses normal difficulty and camelCase fields', () => { const draft: BarkBattleDraftConfig = { draftId: 'draft-bark-1', + workId: 'work-bark-1', + configVersion: 2, + rulesetVersion: 'bark-battle-ruleset-v1', title: '汪汪声浪挑战', description: '轻配置草稿', themePreset: 'city-park', playerDogSkinPreset: 'corgi', 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', leaderboardEnabled: true, updatedAt: '2026-05-13T03:00:00.000Z', @@ -26,15 +33,23 @@ describe('Bark Battle shared contracts', () => { expect(draft.difficultyPreset).toBe('normal'); expect(Object.keys(draft)).toEqual([ 'draftId', + 'workId', + 'configVersion', + 'rulesetVersion', 'title', 'description', 'themePreset', 'playerDogSkinPreset', 'opponentDogSkinPreset', + 'playerCharacterImageSrc', + 'opponentCharacterImageSrc', + 'uiBackgroundImageSrc', + 'barkSoundSrc', 'difficultyPreset', 'leaderboardEnabled', 'updatedAt', ]); + expect(draft.playerCharacterImageSrc).toContain('/generated-bark-battle/'); }); test('finish accepted player_win fixture exposes backend adjudication result', () => { diff --git a/packages/shared/src/contracts/barkBattle.ts b/packages/shared/src/contracts/barkBattle.ts index 14449115..99e0763e 100644 --- a/packages/shared/src/contracts/barkBattle.ts +++ b/packages/shared/src/contracts/barkBattle.ts @@ -16,7 +16,14 @@ export type BarkBattleFinishStatus = 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; description?: string; themePreset: string; @@ -30,7 +37,7 @@ export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayl export interface BarkBattleWorkPublishRequest { draftId: string; - workId?: string; + workId: string; publishedSnapshot?: BarkBattleConfigEditorPayload; } @@ -53,6 +60,10 @@ export interface BarkBattlePublishedConfig { themePreset: string; playerDogSkinPreset: string; opponentDogSkinPreset: string; + playerCharacterImageSrc?: string; + opponentCharacterImageSrc?: string; + uiBackgroundImageSrc?: string; + barkSoundSrc?: string; difficultyPreset: BarkBattleDifficultyPreset; leaderboardEnabled: boolean; updatedAt: string; @@ -73,6 +84,10 @@ export interface BarkBattleRuntimeConfig { themePreset: string; playerDogSkinPreset: string; opponentDogSkinPreset: string; + playerCharacterImageSrc?: string; + opponentCharacterImageSrc?: string; + uiBackgroundImageSrc?: string; + barkSoundSrc?: string; leaderboardEnabled: boolean; updatedAt: string; } diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 34bb66db..8f6cbe49 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -21,8 +21,9 @@ use shared_kernel::{ offset_datetime_to_unix_micros, parse_rfc3339, }; use spacetime_client::{ - BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord, - BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError, + BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput, + BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput, + BarkBattleWorkPublishRecordInput, SpacetimeClientError, }; use time::{Duration as TimeDuration, OffsetDateTime}; @@ -73,11 +74,8 @@ struct BarkBattleRunSnapshotRecord { #[serde(rename_all = "camelCase")] struct BarkBattleDraftConfigSnapshotRecord { draft_id: String, - #[allow(dead_code)] work_id: String, - #[allow(dead_code)] config_version: u64, - #[allow(dead_code)] ruleset_version: String, #[serde(default)] config_json: String, @@ -105,6 +103,35 @@ pub async fn create_bark_battle_draft( ) -> Result, Response> { let Json(payload) = bark_battle_json(payload, &request_context)?; 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 .spacetime_client() .create_bark_battle_draft(BarkBattleDraftCreateRecordInput { @@ -127,7 +154,35 @@ pub async fn create_bark_battle_draft( .map_err(|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)) } @@ -139,13 +194,17 @@ pub async fn publish_bark_battle_work( ) -> Result, Response> { let Json(payload) = bark_battle_json(payload, &request_context)?; ensure_non_empty(&request_context, &payload.draft_id, "draftId")?; - let work_id = payload + let Some(work_id) = payload .work_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX)); + .map(ToString::to_string) else { + return Err(bark_battle_bad_request( + &request_context, + "workId 缺失,请重新生成草稿后再发布。", + )); + }; let published_snapshot_json = payload .published_snapshot .as_ref() @@ -473,11 +532,18 @@ fn map_draft_config_record( let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?; Ok(BarkBattleDraftConfig { 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, description: editor_config.description, theme_preset: editor_config.theme_preset, player_dog_skin_preset: editor_config.player_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, leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), @@ -505,6 +571,10 @@ fn map_runtime_config_record( theme_preset: editor_config.theme_preset, player_dog_skin_preset: editor_config.player_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, updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) @@ -527,6 +597,10 @@ fn map_published_config_record( theme_preset: editor_config.theme_preset, player_dog_skin_preset: editor_config.player_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, leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), @@ -592,6 +666,23 @@ fn ensure_non_empty( Ok(()) } +fn normalize_optional_bark_battle_asset_source( + request_context: &RequestContext, + value: Option<&str>, + field_name: &str, +) -> Result, 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 { bark_battle_error_response( request_context, @@ -753,6 +844,7 @@ fn format_rfc3339_or_timestamp_micros(micros: i64) -> String { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] fn unit_and_energy_are_clamped_to_spacetime_millis() { @@ -773,4 +865,43 @@ mod tests { 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") + ); + } } diff --git a/server-rs/crates/shared-contracts/src/bark_battle.rs b/server-rs/crates/shared-contracts/src/bark_battle.rs index addbd723..d86abae2 100644 --- a/server-rs/crates/shared-contracts/src/bark_battle.rs +++ b/server-rs/crates/shared-contracts/src/bark_battle.rs @@ -30,6 +30,19 @@ pub enum BarkBattleFinishStatus { 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleConfigEditorPayload { @@ -39,6 +52,14 @@ pub struct BarkBattleConfigEditorPayload { pub theme_preset: String, pub player_dog_skin_preset: String, pub opponent_dog_skin_preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, pub leaderboard_enabled: bool, @@ -53,6 +74,14 @@ pub struct BarkBattleDraftCreateRequest { pub theme_preset: String, pub player_dog_skin_preset: String, pub opponent_dog_skin_preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, pub leaderboard_enabled: bool, @@ -66,6 +95,10 @@ impl From for BarkBattleConfigEditorPayload { theme_preset: value.theme_preset, player_dog_skin_preset: value.player_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, leaderboard_enabled: value.leaderboard_enabled, } @@ -86,12 +119,26 @@ pub struct BarkBattleWorkPublishRequest { #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftConfig { pub draft_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ruleset_version: Option, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub theme_preset: String, pub player_dog_skin_preset: String, pub opponent_dog_skin_preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, pub leaderboard_enabled: bool, @@ -102,11 +149,18 @@ impl Default for BarkBattleDraftConfig { fn default() -> Self { Self { draft_id: String::new(), + work_id: None, + config_version: None, + ruleset_version: None, title: String::new(), description: None, theme_preset: String::new(), player_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, leaderboard_enabled: true, updated_at: String::new(), @@ -129,6 +183,14 @@ pub struct BarkBattlePublishedConfig { pub theme_preset: String, pub player_dog_skin_preset: String, pub opponent_dog_skin_preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, pub difficulty_preset: BarkBattleDifficultyPreset, pub leaderboard_enabled: bool, pub updated_at: String, @@ -151,6 +213,14 @@ pub struct BarkBattleRuntimeConfig { pub theme_preset: String, pub player_dog_skin_preset: String, pub opponent_dog_skin_preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, pub leaderboard_enabled: bool, pub updated_at: String, } @@ -409,7 +479,22 @@ mod tests { fn optional_fields_are_omitted_when_absent() { let draft = BarkBattleDraftConfig::default(); 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("playerCharacterImageSrc") + ); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("uiBackgroundImageSrc") + ); let response = BarkBattlePersonalHistoryResponse { work_id: None, @@ -429,6 +514,74 @@ mod tests { 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] fn finish_response_serializes_player_win_and_accepted() { let response = BarkBattleRunFinishResponse { diff --git a/server-rs/crates/spacetime-module/src/bark_battle/mod.rs b/server-rs/crates/spacetime-module/src/bark_battle/mod.rs index d4afb89b..04e166d4 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/mod.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/mod.rs @@ -116,6 +116,10 @@ fn create_bark_battle_draft_tx( &input.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())?, 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.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?; - let editor_config = parse_editor_config(&input.config_json)?; - validate_editor_config_snapshot(&editor_config)?; + let mut editor_config = parse_editor_config(&input.config_json)?; + normalize_editor_config_snapshot(&mut editor_config)?; if editor_config.difficulty_preset != input.difficulty_preset || 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.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?; 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; ctx.db .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> { - normalize_title(Some(&config.title))?; - normalize_required_preset(&config.theme_preset, "theme_preset")?; - normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?; - normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?; - normalize_difficulty(Some(&config.difficulty_preset))?; +fn normalize_editor_config_snapshot( + config: &mut BarkBattleEditorConfigSnapshot, +) -> 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")?; + config.opponent_dog_skin_preset = + normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_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(()) } @@ -555,6 +577,19 @@ fn normalize_required_preset(value: &str, field_name: &str) -> Result, + field_name: &str, +) -> Result, 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 { let ruleset = value.trim(); if ruleset != BARK_BATTLE_DEFAULT_RULESET_VERSION { diff --git a/server-rs/crates/spacetime-module/src/bark_battle/types.rs b/server-rs/crates/spacetime-module/src/bark_battle/types.rs index e26a2747..7d56b6de 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/types.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/types.rs @@ -117,6 +117,14 @@ pub struct BarkBattleEditorConfigSnapshot { pub theme_preset: String, pub player_dog_skin_preset: String, pub opponent_dog_skin_preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bark_sound_src: Option, pub difficulty_preset: String, pub leaderboard_enabled: bool, } diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx index ac43de6a..77fd197e 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx @@ -7,54 +7,74 @@ import { describe, expect, it, vi } from 'vitest'; import { BarkBattleConfigEditor } from './BarkBattleConfigEditor'; describe('BarkBattleConfigEditor', () => { - it('allows creators to edit lightweight config and publish a Bark Battle work', async () => { - const onPublish = vi.fn(); - render(); + it('allows creators to edit lightweight config and compile a Bark Battle draft', async () => { + const onPreview = vi.fn(); + render(); expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy(); expect(screen.getByText('轻配置')).toBeTruthy(); expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场'); expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal'); - expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true); await userEvent.clear(screen.getByLabelText('作品标题')); await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯'); await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park'); - await userEvent.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba'); - await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky'); + await userEvent.clear(screen.getByLabelText('玩家角色设定')); + await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角'); + await userEvent.clear(screen.getByLabelText('对手角色设定')); + await userEvent.type(screen.getByLabelText('对手角色设定'), '对手'); await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard'); - await userEvent.click(screen.getByLabelText('开启排行榜')); - await userEvent.click(screen.getByRole('button', { name: '发布并试玩' })); + await userEvent.type( + 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: '周末狗狗杯', description: '', themePreset: 'neon-park', - playerDogSkinPreset: 'shiba', - opponentDogSkinPreset: 'husky', + playerDogSkinPreset: '主角', + 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', - leaderboardEnabled: false, + leaderboardEnabled: true, }); }); - it('requires a non-empty title before publishing', async () => { - const onPublish = vi.fn(); - render(); + it('requires a non-empty title before compiling a draft', async () => { + const onPreview = vi.fn(); + render(); 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(); }); it('can render as an embedded creation form without a local page header', () => { - const onPublish = vi.fn(); + const onPreview = vi.fn(); render( , diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx index fd2345ff..edc5fa8b 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx @@ -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 type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; @@ -8,7 +8,7 @@ import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; export type BarkBattleConfigEditorProps = { isBusy?: boolean; error?: string | null; - onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise; + onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise; onBack?: () => void; showBackButton?: boolean; title?: string | null; @@ -20,12 +20,6 @@ const THEME_OPTIONS = [ { 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 }> = [ { value: 'easy', label: '轻松' }, { value: 'normal', label: '标准' }, @@ -35,7 +29,7 @@ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: stri export function BarkBattleConfigEditor({ isBusy = false, error: externalError = null, - onPublish, + onPreview, onBack, showBackButton = true, title: headingTitle = '汪汪声浪大作战', @@ -43,10 +37,13 @@ export function BarkBattleConfigEditor({ const [title, setTitle] = useState('我的声浪竞技场'); const [description, setDescription] = useState(''); const [themePreset, setThemePreset] = useState('sunny-yard'); - const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi'); - const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky'); + const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角'); + const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手'); + const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState(''); + const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState(''); + const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState(''); + const [barkSoundSrc, setBarkSoundSrc] = useState(''); const [difficultyPreset, setDifficultyPreset] = useState('normal'); - const [leaderboardEnabled, setLeaderboardEnabled] = useState(true); const [localError, setLocalError] = useState(null); const payload = useMemo( @@ -56,8 +53,18 @@ export function BarkBattleConfigEditor({ themePreset, playerDogSkinPreset, opponentDogSkinPreset, + ...(playerCharacterImageSrc.trim() + ? { playerCharacterImageSrc: playerCharacterImageSrc.trim() } + : {}), + ...(opponentCharacterImageSrc.trim() + ? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() } + : {}), + ...(uiBackgroundImageSrc.trim() + ? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() } + : {}), + ...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}), difficultyPreset, - leaderboardEnabled, + leaderboardEnabled: true, }), [ title, @@ -65,24 +72,29 @@ export function BarkBattleConfigEditor({ themePreset, playerDogSkinPreset, opponentDogSkinPreset, + playerCharacterImageSrc, + opponentCharacterImageSrc, + uiBackgroundImageSrc, + barkSoundSrc, difficultyPreset, - leaderboardEnabled, ], ); - const handlePublish = () => { + const runValidatedAction = ( + action: (payload: BarkBattleConfigEditorPayload) => void | Promise, + ) => { if (!payload.title) { setLocalError('请先填写作品标题'); return; } setLocalError(null); - void onPublish(payload); + void action(payload); }; const visibleError = localError ?? externalError; return (
{showBackButton && onBack ? ( @@ -101,7 +113,7 @@ export function BarkBattleConfigEditor({ ) : null} -
+
{headingTitle ? (
@@ -116,9 +128,9 @@ export function BarkBattleConfigEditor({ ) : null}
-
+
- +
+ + + + + + + +
{visibleError ? (
@@ -262,20 +299,20 @@ export function BarkBattleConfigEditor({
-
+
diff --git a/src/components/bark-battle-creation/BarkBattlePreviewCard.tsx b/src/components/bark-battle-creation/BarkBattlePreviewCard.tsx index 829d2746..b5995952 100644 --- a/src/components/bark-battle-creation/BarkBattlePreviewCard.tsx +++ b/src/components/bark-battle-creation/BarkBattlePreviewCard.tsx @@ -1,4 +1,5 @@ import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; type BarkBattlePreviewCardProps = { config: BarkBattleConfigEditorPayload; @@ -10,12 +11,6 @@ const THEME_LABELS: Record = { 'moonlight-rooftop': '月光天台', }; -const DOG_LABELS: Record = { - corgi: '柯基', - shiba: '柴犬', - husky: '哈士奇', -}; - const DIFFICULTY_LABELS = { easy: '轻松', normal: '标准', @@ -23,6 +18,8 @@ const DIFFICULTY_LABELS = { }; export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) { + const hasCustomSound = Boolean(config.barkSoundSrc?.trim()); + return (
-
+
{activeCreationFormType === 'match3d' ? ( } @@ -10921,8 +11014,8 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBarkBattleBusy} error={barkBattleError} onBack={leaveBarkBattleFlow} - onPublish={(payload) => { - void publishBarkBattleConfig(payload); + onPreview={(payload) => { + void createBarkBattleResultDraft(payload); }} showBackButton={false} title={null} @@ -12544,6 +12637,36 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'bark-battle-result' && barkBattleDraftConfig && ( + + } + > + { + enterCreateTab(); + setActiveCreationFormType('bark-battle'); + setSelectionStage('platform'); + }} + onDraftChange={setBarkBattleDraftConfig} + onStartTestRun={testBarkBattleDraft} + onPublish={(draft) => { + void publishBarkBattleDraft(draft); + }} + /> + + + )} + {selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && ( { - enterCreateTab(); - setActiveCreationFormType('bark-battle'); - setSelectionStage('platform'); + if ( + barkBattleRuntimeReturnStage === 'bark-battle-result' && + barkBattleDraftConfig + ) { + setSelectionStage('bark-battle-result'); + } else { + enterCreateTab(); + setActiveCreationFormType('bark-battle'); + setSelectionStage('platform'); + } }} /> diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 51eb7800..3df22250 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -31,6 +31,7 @@ export type SelectionStage = | 'square-hole-generating' | 'square-hole-result' | 'square-hole-runtime' + | 'bark-battle-result' | 'bark-battle-runtime' | 'creative-agent-workspace' | 'visual-novel-agent-workspace' diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 351ef722..5ac6891e 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -7,22 +7,22 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent'; -import type { - BabyObjectMatchDraft, - CreateBabyObjectMatchDraftRequest, -} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { CustomWorldAgentSessionSnapshot, CustomWorldWorkSummary, } 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 { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { PuzzleAnchorPack, PuzzleResultDraft, } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; -import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, @@ -42,13 +42,9 @@ import { import { ApiClientError } from '../../services/apiClient'; import type { AuthUser } from '../../services/authService'; import { - createBabyObjectMatchDraft, - deleteLocalBabyObjectMatchDraft, - listLocalBabyObjectMatchDrafts, - publishBabyObjectMatchWork, - regenerateBabyObjectMatchDraftAssets, - saveBabyObjectMatchDraft, -} from '../../services/edutainment-baby-object'; + createBarkBattleDraft, + publishBarkBattleWork, +} from '../../services/bark-battle-creation'; import { createBigFishCreationSession, getBigFishCreationSession, @@ -60,10 +56,6 @@ import { submitBigFishInput, } from '../../services/big-fish-runtime'; import { listBigFishWorks } from '../../services/big-fish-works'; -import { - createBarkBattleDraft, - publishBarkBattleWork, -} from '../../services/bark-battle-creation'; import { type CreationEntryConfig, fetchCreationEntryConfig, @@ -75,6 +67,14 @@ import { streamCreativeAgentMessage, streamCreativeDraftEdit, } from '../../services/creative-agent'; +import { + createBabyObjectMatchDraft, + deleteLocalBabyObjectMatchDraft, + listLocalBabyObjectMatchDrafts, + publishBabyObjectMatchWork, + regenerateBabyObjectMatchDraftAssets, + saveBabyObjectMatchDraft, +} from '../../services/edutainment-baby-object'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -476,6 +476,8 @@ vi.mock('../../services/big-fish-runtime', () => ({ vi.mock('../../services/bark-battle-creation', () => ({ createBarkBattleDraft: vi.fn(), publishBarkBattleWork: vi.fn(), + regenerateBarkBattleImageAsset: vi.fn(), + uploadBarkBattleAsset: vi.fn(), })); vi.mock('../../services/edutainment-baby-object', () => ({ @@ -989,13 +991,13 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({ isBusy, showBackButton, title, - onPublish, + onPreview, }: { error?: string | null; isBusy?: boolean; showBackButton?: boolean; title?: string | null; - onPublish: (payload: { + onPreview: (payload: { title: string; description: string; themePreset: string; @@ -1021,7 +1023,7 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({ type="button" disabled={isBusy} onClick={() => { - onPublish({ + onPreview({ title: '汪汪测试杯', description: '', themePreset: 'sunny-yard', @@ -1032,7 +1034,40 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({ }); }} > - 发布并试玩 + 生成草稿 + +
+ ), +})); + +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; + }) => ( +
+
汪汪声浪结果页:{draft.title}
+
草稿ID:{draft.draftId}
+
作品ID:{draft.workId ?? 'missing-work'}
+ + +
), @@ -3201,14 +3236,14 @@ test('create tab switches bark battle into the embedded config form', async () = 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(); render(); await openCreateTemplateHub(user); 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({ title: '汪汪测试杯', @@ -3219,6 +3254,27 @@ test('bark battle publish preview returns to the embedded config form', async () difficultyPreset: 'normal', 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(); await user.click(screen.getByRole('button', { name: '返回配置' })); diff --git a/src/games/bark-battle/ui/BarkBattleHud.css b/src/games/bark-battle/ui/BarkBattleHud.css index 65aded4d..c5fca0ba 100644 --- a/src/games/bark-battle/ui/BarkBattleHud.css +++ b/src/games/bark-battle/ui/BarkBattleHud.css @@ -1,4 +1,5 @@ .bark-battle-hud { + position: relative; min-height: 100svh; 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%); @@ -10,7 +11,18 @@ 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 { + position: relative; + z-index: 1; display: grid; gap: 10px; } @@ -38,6 +50,8 @@ .bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); } .bark-battle-arena { + position: relative; + z-index: 1; flex: 1; min-height: 0; display: grid; @@ -54,6 +68,10 @@ } .bark-battle-dog__body { + display: grid; + width: clamp(112px, 34vw, 170px); + aspect-ratio: 1; + place-items: center; font-size: clamp(92px, 30vw, 150px); filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42)); } @@ -62,6 +80,12 @@ transform: rotateY(180deg) translateY(4px); } +.bark-battle-dog__image { + width: 100%; + height: 100%; + object-fit: contain; +} + .bark-battle-dog__label, .bark-battle-dog__burst, .bark-battle-vs { @@ -94,6 +118,8 @@ .bark-battle-controls, .bark-battle-result__stats { + position: relative; + z-index: 1; display: flex; gap: 10px; justify-content: center; diff --git a/src/games/bark-battle/ui/BarkBattleHud.tsx b/src/games/bark-battle/ui/BarkBattleHud.tsx index 72210648..6c54a69d 100644 --- a/src/games/bark-battle/ui/BarkBattleHud.tsx +++ b/src/games/bark-battle/ui/BarkBattleHud.tsx @@ -1,5 +1,6 @@ import './BarkBattleHud.css'; +import { ResolvedAssetImage } from '../../../components/ResolvedAssetImage'; import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes'; type BarkBattleHudProps = { @@ -10,6 +11,9 @@ type BarkBattleHudProps = { onMockBark?: () => void; onMockQuiet?: () => void; onRestart?: () => void; + playerCharacterImageSrc?: string | null; + opponentCharacterImageSrc?: string | null; + uiBackgroundImageSrc?: string | null; }; const failureText = { @@ -32,6 +36,9 @@ export function BarkBattleHud({ onMockBark, onMockQuiet, onRestart, + playerCharacterImageSrc, + opponentCharacterImageSrc, + uiBackgroundImageSrc, }: BarkBattleHudProps) { const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`; const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`; @@ -39,6 +46,14 @@ export function BarkBattleHud({ return (
+ {uiBackgroundImageSrc ? ( +