From a6029639b0cdbdcddbe7566f0f40376fa2c36a48 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 11:22:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=BC=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md | 27 +++ ...FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md | 25 ++ ...ZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md | 26 +++ docs/technical/README.md | 9 +- server-rs/crates/api-server/src/app.rs | 14 +- server-rs/crates/api-server/src/config.rs | 6 +- src/BigFishPlaygroundApp.tsx | 219 ++++++++++++++++++ src/PuzzlePlaygroundApp.tsx | 89 +++++++ .../big-fish-runtime/BigFishRuntimeShell.tsx | 112 +++++---- src/routing/appRoutes.test.ts | 12 + src/routing/appRoutes.tsx | 40 +++- .../puzzle-runtime/puzzleLocalRuntime.ts | 2 +- 12 files changed, 518 insertions(+), 63 deletions(-) create mode 100644 docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md create mode 100644 docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md create mode 100644 docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md create mode 100644 src/BigFishPlaygroundApp.tsx create mode 100644 src/PuzzlePlaygroundApp.tsx diff --git a/docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md b/docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md new file mode 100644 index 00000000..3b4d3699 --- /dev/null +++ b/docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md @@ -0,0 +1,27 @@ +# 大鱼吃小鱼方向触控操作优化说明 + +## 背景 + +当前大鱼运行时使用左下固定虚拟摇杆,玩家必须点到摇杆区域才能移动。移动端实际体验应改为屏幕任意位置触控:第一次触点只建立方向原点,不直接产生移动;后续触点相对原点形成方向向量,角色按恒定速度朝该方向行动。 + +## 交互规则 + +1. 玩家在玩法舞台内按下时,记录第一个触点坐标为本次操作原点。 +2. 按下瞬间提交 `{ x: 0, y: 0 }`,保证一开始玩家不动。 +3. 手指/鼠标移动后,用“当前触点 - 原点”的向量计算方向。 +4. 输入只表达方向,不表达速度;超过死区后归一化为单位方向向量。 +5. 松开或取消触控后,清空操作原点并提交 `{ x: 0, y: 0 }`。 +6. 前端继续定时提交当前方向,即使没有玩家输入也提交零向量,让后端或本地直达局持续推进世界 tick。 + +## 本地直达局边界 + +- `/big-fish` 的本地占位局必须在玩家未操作时继续移动野生对象。 +- 玩家速度保持恒定,只由方向决定移动方向。 +- 野生对象使用确定性游动规则,避免直达入口看起来像静态截图。 + +## 验收口径 + +1. 在舞台任意位置按下时玩家不立即移动。 +2. 按住并拖动后,玩家朝拖动方向恒速移动。 +3. 松开后玩家停止。 +4. 不操作时野生对象仍会持续游动。 diff --git a/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md b/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md new file mode 100644 index 00000000..a0c4b96c --- /dev/null +++ b/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md @@ -0,0 +1,25 @@ +# 大鱼吃小鱼玩法直达路由说明 + +## 背景 + +现有前端已经包含 `BigFishRuntimeShell`,正式链路从创作中心或作品卡启动后端运行局。为了便于快速验收玩法手感,需要补一个不依赖后端会话的直达入口。 + +## 路由设计 + +- `/big-fish`:进入大鱼吃小鱼玩法直达页。 +- 路由挂在 `src/routing/appRoutes.tsx`,与 `/puzzle` 一样走现有轻量路由解析层,不新增独立路由系统。 +- 每个玩法仅保留一个直达入口,避免 `/play` 这类重复路径造成维护分叉。 + +## 运行态边界 + +- 直达页复用 `BigFishRuntimeShell`,不复制运行时 UI。 +- 初始快照由前端本地构造,背景使用内联 SVG 占位图。 +- 摇杆输入在本地推进角色位置、碰撞与成长等级,仅用于直达体验。 +- 该入口不改变正式 `api-server` 运行局、作品发布、资产生成和 SpacetimeDB 持久化链路。 + +## 验收口径 + +1. 浏览器访问 `/big-fish` 后直接显示竖屏大鱼吃小鱼舞台。 +2. 左下摇杆可移动玩家实体。 +3. 玩家碰到不高于自身等级的实体后成长,并在事件日志显示成长结果。 +4. 左上返回按钮在直达页语义为重开当前占位局。 diff --git a/docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md b/docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md new file mode 100644 index 00000000..389d9393 --- /dev/null +++ b/docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md @@ -0,0 +1,26 @@ +# 拼图玩法直达路由说明 + +## 背景 + +现有前端已经包含拼图运行时组件 `PuzzleRuntimeShell` 和本地运行时 `puzzleLocalRuntime`,但只能从平台创作中心、作品卡或拼图广场链路间接进入。为了快速验证玩法交互,需要补一个可直接打开的前端路由。 + +## 路由设计 + +- `/puzzle`:进入拼图玩法直达页。 +- 路由挂到现有 `src/routing/appRoutes.tsx` 的轻量路由解析层,不引入 React Router,也不新增独立路由系统。 + +## 运行态边界 + +- 直达页复用 `PuzzleRuntimeShell`,不复制棋盘 UI。 +- 初始关卡通过 `startLocalPuzzleRun` 生成,图片使用内联 SVG 占位图。 +- 交换、拖动、重开均走 `puzzleLocalRuntime`,保持与现有前端玩法实现一致。 +- 该入口仅用于直达体验和调试,不改变已发布拼图作品、Agent 创作、拼图广场和后端持久化链路。 + +## 验收口径 + +1. 浏览器访问 `/puzzle` 后直接显示全屏拼图画布。 +2. 棋盘应显示占位图切片,而不是空白格。 +3. 点击两块拼图可以交换;拖动拼图到目标格可以交换位置。 +4. 左上返回按钮在直达页语义为重开当前占位关卡。 + + diff --git a/docs/technical/README.md b/docs/technical/README.md index cfaa5cb1..8cee0a2a 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -1,9 +1,12 @@ -# 技术方案 +# 技术方案 这一组文档偏技术选型、实现路线和外部产品形态拆解。 ## 文档列表 +- [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。 +- [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。 +- [PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/puzzle` 拼图玩法直达入口,明确复用现有 `PuzzleRuntimeShell` 和本地占位图运行态的调试边界。 - [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。 - [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界。 - [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。 @@ -158,3 +161,7 @@ - 做实现选型时,优先看这一组。 - 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 + + + + diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 9beca63a..7e19a372 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -48,13 +48,13 @@ use crate::{ custom_world::{ create_custom_world_agent_session, delete_custom_world_agent_session, delete_custom_world_library_profile, execute_custom_world_agent_action, - get_custom_world_agent_card_detail, - get_custom_world_agent_operation, get_custom_world_agent_session, - get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code, - get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, - list_custom_world_gallery, publish_custom_world_library_profile, - put_custom_world_library_profile, stream_custom_world_agent_message, - submit_custom_world_agent_message, unpublish_custom_world_library_profile, + get_custom_world_agent_card_detail, get_custom_world_agent_operation, + get_custom_world_agent_session, get_custom_world_gallery_detail, + get_custom_world_gallery_detail_by_code, get_custom_world_library, + get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery, + publish_custom_world_library_profile, put_custom_world_library_profile, + stream_custom_world_agent_message, submit_custom_world_agent_message, + unpublish_custom_world_library_profile, }, custom_world_ai::{ generate_custom_world_cover_image, generate_custom_world_entity, diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 2804401d..1f1cd3a9 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -397,16 +397,14 @@ impl AppConfig { if let Some(spacetime_server_url) = read_first_non_empty_env(&[ "GENARRATIVE_SPACETIME_SERVER_URL", "GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL", - ]) - { + ]) { config.spacetime_server_url = spacetime_server_url; } if let Some(spacetime_database) = read_first_non_empty_env(&[ "GENARRATIVE_SPACETIME_DATABASE", "GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE", - ]) - { + ]) { config.spacetime_database = spacetime_database; } diff --git a/src/BigFishPlaygroundApp.tsx b/src/BigFishPlaygroundApp.tsx new file mode 100644 index 00000000..d11179a4 --- /dev/null +++ b/src/BigFishPlaygroundApp.tsx @@ -0,0 +1,219 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { + BigFishAssetSlotResponse, + BigFishRuntimeEntityResponse, + BigFishRuntimeSnapshotResponse, + SubmitBigFishInputRequest, +} from '../packages/shared/src/contracts/bigFish'; +import { BigFishRuntimeShell } from './components/big-fish-runtime/BigFishRuntimeShell'; + +const BIG_FISH_BACKGROUND_IMAGE = + 'data:image/svg+xml;utf8,' + + encodeURIComponent(` + + + + + + + + + + + + + + + + + + + + +`); + +const WORLD_MIN_X = 60; +const WORLD_MAX_X = 780; +const WORLD_MIN_Y = 80; +const WORLD_MAX_Y = 1240; +const PLAYER_SPEED = 20; + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function buildEntity( + entityId: string, + level: number, + x: number, + y: number, +): BigFishRuntimeEntityResponse { + return { + entityId, + level, + position: { x, y }, + radius: 12 + level * 5, + offscreenSeconds: 0, + }; +} + +function buildInitialRun(): BigFishRuntimeSnapshotResponse { + const leader = buildEntity('player-leader', 1, 360, 640); + return { + runId: `local-big-fish-run-${Date.now()}`, + sessionId: 'local-big-fish-session', + status: 'running', + tick: 0, + playerLevel: 1, + winLevel: 5, + leaderEntityId: leader.entityId, + ownedEntities: [leader], + wildEntities: [ + buildEntity('wild-small-1', 1, 250, 560), + buildEntity('wild-small-2', 1, 470, 760), + buildEntity('wild-mid-1', 2, 560, 520), + buildEntity('wild-mid-2', 3, 210, 820), + buildEntity('wild-boss-1', 5, 610, 930), + ], + cameraCenter: { ...leader.position }, + lastInput: { x: 0, y: 0 }, + eventLog: ['按住屏幕任意位置,再拖动控制方向。'], + updatedAt: new Date().toISOString(), + }; +} + +function distanceBetween( + first: BigFishRuntimeEntityResponse, + second: BigFishRuntimeEntityResponse, +) { + return Math.hypot( + first.position.x - second.position.x, + first.position.y - second.position.y, + ); +} + +function respawnWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) { + const offset = tick * 37 + entity.level * 53; + return { + ...entity, + position: { + x: WORLD_MIN_X + (offset % Math.floor(WORLD_MAX_X - WORLD_MIN_X)), + y: WORLD_MIN_Y + ((offset * 7) % Math.floor(WORLD_MAX_Y - WORLD_MIN_Y)), + }, + }; +} + +function moveWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) { + const phase = tick * 0.32 + entity.level * 1.7; + const speed = 6 + entity.level * 0.8; + const nextX = entity.position.x + Math.cos(phase) * speed; + const nextY = entity.position.y + Math.sin(phase * 0.73) * speed; + return { + ...entity, + position: { + x: clamp(nextX, WORLD_MIN_X, WORLD_MAX_X), + y: clamp(nextY, WORLD_MIN_Y, WORLD_MAX_Y), + }, + }; +} + +function applyLocalInput( + run: BigFishRuntimeSnapshotResponse, + input: SubmitBigFishInputRequest, +): BigFishRuntimeSnapshotResponse { + if (run.status !== 'running') { + return run; + } + + const leader = run.ownedEntities.find( + (entity) => entity.entityId === run.leaderEntityId, + ); + if (!leader) { + return run; + } + + const nextLeader = { + ...leader, + position: { + x: clamp(leader.position.x + input.x * PLAYER_SPEED, WORLD_MIN_X, WORLD_MAX_X), + y: clamp(leader.position.y + input.y * PLAYER_SPEED, WORLD_MIN_Y, WORLD_MAX_Y), + }, + }; + + let nextPlayerLevel = run.playerLevel; + const nextEvents = [...run.eventLog]; + const nextWildEntities = run.wildEntities.map((entity) => { + const movedEntity = moveWildEntity(entity, run.tick + 1); + const touched = distanceBetween(nextLeader, movedEntity) <= nextLeader.radius + movedEntity.radius; + if (!touched) { + return movedEntity; + } + + if (movedEntity.level <= nextPlayerLevel) { + nextPlayerLevel = Math.min(run.winLevel, nextPlayerLevel + 1); + nextEvents.push(`吞噬 Lv.${movedEntity.level},成长到 Lv.${nextPlayerLevel}`); + return respawnWildEntity(movedEntity, run.tick + nextPlayerLevel); + } + + nextEvents.push(`撞上 Lv.${movedEntity.level},暂时避开更大的鱼。`); + return movedEntity; + }); + + const scaledLeader = { + ...nextLeader, + level: nextPlayerLevel, + radius: 12 + nextPlayerLevel * 5, + }; + const status = nextPlayerLevel >= run.winLevel ? 'won' : 'running'; + if (status === 'won' && run.status !== 'won') { + nextEvents.push('已经成长为海域霸主。'); + } + + return { + ...run, + status, + tick: run.tick + 1, + playerLevel: nextPlayerLevel, + ownedEntities: [scaledLeader], + wildEntities: nextWildEntities, + cameraCenter: { ...scaledLeader.position }, + lastInput: input, + eventLog: nextEvents.slice(-5), + updatedAt: new Date().toISOString(), + }; +} + +export default function BigFishPlaygroundApp() { + const [run, setRun] = useState(buildInitialRun); + const assetSlots = useMemo( + () => [ + { + slotId: 'local-big-fish-background', + assetKind: 'stage_background', + status: 'ready', + assetUrl: BIG_FISH_BACKGROUND_IMAGE, + promptSnapshot: '本地直达入口占位海域背景', + updatedAt: new Date(0).toISOString(), + }, + ], + [], + ); + + const handleSubmitInput = useCallback((payload: SubmitBigFishInputRequest) => { + setRun((currentRun) => applyLocalInput(currentRun, payload)); + }, []); + + const handleRestart = useCallback(() => { + setRun(buildInitialRun()); + }, []); + + return ( + + ); +} diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx new file mode 100644 index 00000000..e4798653 --- /dev/null +++ b/src/PuzzlePlaygroundApp.tsx @@ -0,0 +1,89 @@ +import { useMemo, useState } from 'react'; + +import type { + DragPuzzlePieceRequest, + SwapPuzzlePiecesRequest, +} from '../packages/shared/src/contracts/puzzleRuntimeSession'; +import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary'; +import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell'; +import { + advanceLocalPuzzleLevel, + dragLocalPuzzlePiece, + startLocalPuzzleRun, + swapLocalPuzzlePieces, +} from './services/puzzle-runtime/puzzleLocalRuntime'; + +const PLACEHOLDER_PUZZLE_IMAGE = + 'data:image/svg+xml;utf8,' + + encodeURIComponent(` + + + + + + + + + + + + + + + + + + + +`); + +function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { + return { + workId: 'placeholder-puzzle-work', + profileId: 'placeholder-puzzle-profile', + ownerUserId: 'placeholder-user', + sourceSessionId: null, + authorDisplayName: '占位作者', + levelName: '暮色群山', + summary: '用于直达玩法调试的本地占位拼图。', + themeTags: ['占位', '风景', '调试'], + coverImageSrc: PLACEHOLDER_PUZZLE_IMAGE, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: new Date(0).toISOString(), + publishedAt: new Date(0).toISOString(), + playCount: 0, + publishReady: true, + }; +} + +export default function PuzzlePlaygroundApp() { + const placeholderWork = useMemo(() => buildPlaceholderPuzzleWork(), []); + const [run, setRun] = useState(() => startLocalPuzzleRun(placeholderWork)); + + const handleSwapPieces = (payload: SwapPuzzlePiecesRequest) => { + setRun((currentRun) => swapLocalPuzzlePieces(currentRun, payload)); + }; + + const handleDragPiece = (payload: DragPuzzlePieceRequest) => { + setRun((currentRun) => dragLocalPuzzlePiece(currentRun, payload)); + }; + + const handleRestart = () => { + setRun(startLocalPuzzleRun(placeholderWork)); + }; + + const handleAdvanceNextLevel = () => { + setRun((currentRun) => advanceLocalPuzzleLevel(currentRun)); + }; + + return ( + + ); +} diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx index c729e33c..e49471d1 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx @@ -1,5 +1,5 @@ import { ArrowLeft, Loader2 } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type PointerEvent } from 'react'; import type { BigFishAssetSlotResponse, @@ -9,6 +9,12 @@ import type { } from '../../../packages/shared/src/contracts/bigFish'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; +type TouchOrigin = { + pointerId: number; + x: number; + y: number; +}; + type BigFishRuntimeShellProps = { run: BigFishRuntimeSnapshotResponse | null; assetSlots?: BigFishAssetSlotResponse[]; @@ -34,6 +40,20 @@ function normalizeVector(x: number, y: number) { }; } +function resolveDirectionFromOrigin( + origin: TouchOrigin, + clientX: number, + clientY: number, +) { + const deadZone = 12; + const deltaX = clientX - origin.x; + const deltaY = clientY - origin.y; + if (Math.hypot(deltaX, deltaY) < deadZone) { + return { x: 0, y: 0 }; + } + return normalizeVector(deltaX, deltaY); +} + function projectEntity( entity: BigFishRuntimeEntityResponse, run: BigFishRuntimeSnapshotResponse, @@ -152,7 +172,8 @@ export function BigFishRuntimeShell({ onBack, onSubmitInput, }: BigFishRuntimeShellProps) { - const padRef = useRef(null); + const stageRef = useRef(null); + const [touchOrigin, setTouchOrigin] = useState(null); const [stick, setStick] = useState({ x: 0, y: 0 }); const stickRef = useRef(stick); @@ -163,7 +184,7 @@ export function BigFishRuntimeShell({ useEffect(() => { const timer = window.setInterval(() => { const current = stickRef.current; - // 即使摇杆静止也持续回传当前输入,让后端持续推进刷怪、清理与胜负裁决。 + // 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。 onSubmitInput(current); }, 220); @@ -172,20 +193,39 @@ export function BigFishRuntimeShell({ }; }, [onSubmitInput]); - const updateStickFromPointer = (clientX: number, clientY: number) => { - const pad = padRef.current; - if (!pad) { + const submitDirection = (direction: SubmitBigFishInputRequest) => { + setStick(direction); + onSubmitInput(direction); + }; + + const beginTouchControl = (event: PointerEvent) => { + if (event.target instanceof HTMLElement && event.target.closest('button')) { return; } - const rect = pad.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const vector = normalizeVector( - (clientX - centerX) / (rect.width / 2), - (clientY - centerY) / (rect.height / 2), + event.currentTarget.setPointerCapture(event.pointerId); + setTouchOrigin({ + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }); + submitDirection({ x: 0, y: 0 }); + }; + + const updateTouchControl = (event: PointerEvent) => { + if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { + return; + } + submitDirection( + resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY), ); - setStick(vector); - onSubmitInput(vector); + }; + + const endTouchControl = (event: PointerEvent) => { + if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { + return; + } + setTouchOrigin(null); + submitDirection({ x: 0, y: 0 }); }; if (!run) { @@ -206,7 +246,14 @@ export function BigFishRuntimeShell({ return (
-
+
{backgroundAsset ? ( -
-
{ - event.currentTarget.setPointerCapture(event.pointerId); - updateStickFromPointer(event.clientX, event.clientY); - }} - onPointerMove={(event) => { - if (event.buttons <= 0) { - return; - } - updateStickFromPointer(event.clientX, event.clientY); - }} - onPointerUp={() => { - setStick({ x: 0, y: 0 }); - onSubmitInput({ x: 0, y: 0 }); - }} - onPointerCancel={() => { - setStick({ x: 0, y: 0 }); - onSubmitInput({ x: 0, y: 0 }); - }} - > -
-
-
- -
+
{isBusy ?
同步中...
: null} {error ?
{error}
: null} {run.eventLog.slice(-3).map((event) => ( diff --git a/src/routing/appRoutes.test.ts b/src/routing/appRoutes.test.ts index f3a89a74..ffe9c12d 100644 --- a/src/routing/appRoutes.test.ts +++ b/src/routing/appRoutes.test.ts @@ -9,6 +9,18 @@ describe('matchAppRoute', () => { }); }); + it('routes puzzle playground path to the standalone puzzle runtime', () => { + expect(matchAppRoute('/puzzle')).toEqual({ + kind: 'puzzle-playground', + }); + }); + + it('routes big fish playground path to the standalone big fish runtime', () => { + expect(matchAppRoute('/BIG-FISH/')).toEqual({ + kind: 'big-fish-playground', + }); + }); + it('routes former standalone editor paths back to the main game', () => { expect(matchAppRoute('/item-editor/tools')).toEqual({ kind: 'game', diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 49b5c16d..85101039 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -7,6 +7,12 @@ type AppRouteComponent = LazyExoticComponent< >; export type AppRouteMatch = + | { + kind: 'puzzle-playground'; + } + | { + kind: 'big-fish-playground'; + } | { kind: 'game'; }; @@ -20,6 +26,8 @@ export type ResolvedAppRoute = { }; const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent; +const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent; +const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent; function normalizeRoutePath(pathname: string) { const trimmedPathname = pathname.trim().toLowerCase(); @@ -32,7 +40,19 @@ function normalizeRoutePath(pathname: string) { } export function matchAppRoute(pathname: string): AppRouteMatch { - void normalizeRoutePath(pathname); + const normalizedPath = normalizeRoutePath(pathname); + + if (normalizedPath === '/puzzle') { + return { + kind: 'puzzle-playground', + }; + } + + if (normalizedPath === '/big-fish') { + return { + kind: 'big-fish-playground', + }; + } return { kind: 'game', @@ -42,6 +62,24 @@ export function matchAppRoute(pathname: string): AppRouteMatch { export function resolveAppRoute(pathname: string): ResolvedAppRoute { const matchedRoute = matchAppRoute(pathname); + if (matchedRoute.kind === 'puzzle-playground') { + return { + kind: 'puzzle-playground', + loadingEyebrow: '正在载入拼图', + loadingText: '正在进入拼图关卡...', + Component: PuzzlePlaygroundApp, + }; + } + + if (matchedRoute.kind === 'big-fish-playground') { + return { + kind: 'big-fish-playground', + loadingEyebrow: '正在载入大鱼', + loadingText: '正在进入玩法...', + Component: BigFishPlaygroundApp, + }; + } + return { kind: 'game', loadingEyebrow: '正在载入游戏', diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index 5480b24c..cfa15349 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -68,7 +68,7 @@ function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot { const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => { const correctRow = Math.floor(index / gridSize); const correctCol = index % gridSize; - const current = shuffledPositions[index]; + const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol }; return { pieceId: `piece-${index}`, correctRow,