拼图
This commit is contained in:
@@ -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. 不操作时野生对象仍会持续游动。
|
||||
@@ -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. 左上返回按钮在直达页语义为重开当前占位局。
|
||||
26
docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md
Normal file
26
docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 拼图玩法直达路由说明
|
||||
|
||||
## 背景
|
||||
|
||||
现有前端已经包含拼图运行时组件 `PuzzleRuntimeShell` 和本地运行时 `puzzleLocalRuntime`,但只能从平台创作中心、作品卡或拼图广场链路间接进入。为了快速验证玩法交互,需要补一个可直接打开的前端路由。
|
||||
|
||||
## 路由设计
|
||||
|
||||
- `/puzzle`:进入拼图玩法直达页。
|
||||
- 路由挂到现有 `src/routing/appRoutes.tsx` 的轻量路由解析层,不引入 React Router,也不新增独立路由系统。
|
||||
|
||||
## 运行态边界
|
||||
|
||||
- 直达页复用 `PuzzleRuntimeShell`,不复制棋盘 UI。
|
||||
- 初始关卡通过 `startLocalPuzzleRun` 生成,图片使用内联 SVG 占位图。
|
||||
- 交换、拖动、重开均走 `puzzleLocalRuntime`,保持与现有前端玩法实现一致。
|
||||
- 该入口仅用于直达体验和调试,不改变已发布拼图作品、Agent 创作、拼图广场和后端持久化链路。
|
||||
|
||||
## 验收口径
|
||||
|
||||
1. 浏览器访问 `/puzzle` 后直接显示全屏拼图画布。
|
||||
2. 棋盘应显示占位图切片,而不是空白格。
|
||||
3. 点击两块拼图可以交换;拖动拼图到目标格可以交换位置。
|
||||
4. 左上返回按钮在直达页语义为重开当前占位关卡。
|
||||
|
||||
|
||||
@@ -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/` 一起看,更容易判断先后顺序。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
219
src/BigFishPlaygroundApp.tsx
Normal file
219
src/BigFishPlaygroundApp.tsx
Normal file
@@ -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(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
|
||||
<defs>
|
||||
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#38bdf8" />
|
||||
<stop offset="0.52" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#020617" />
|
||||
</linearGradient>
|
||||
<radialGradient id="light" cx="50%" cy="12%" r="52%">
|
||||
<stop offset="0" stop-color="#ecfeff" stop-opacity="0.72" />
|
||||
<stop offset="1" stop-color="#ecfeff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="720" height="1280" fill="url(#water)" />
|
||||
<rect width="720" height="1280" fill="url(#light)" />
|
||||
<path d="M70 324 C164 268 256 384 362 320 C492 242 582 330 656 282" fill="none" stroke="#a7f3d0" stroke-width="16" stroke-linecap="round" opacity="0.28" />
|
||||
<path d="M34 760 C156 700 238 806 372 724 C520 634 606 746 704 682" fill="none" stroke="#bae6fd" stroke-width="18" stroke-linecap="round" opacity="0.18" />
|
||||
<circle cx="120" cy="210" r="18" fill="#ecfeff" opacity="0.36" />
|
||||
<circle cx="548" cy="410" r="12" fill="#ecfeff" opacity="0.28" />
|
||||
<circle cx="304" cy="590" r="10" fill="#ecfeff" opacity="0.24" />
|
||||
<path d="M0 1060 C128 1010 244 1096 366 1030 C492 962 612 1026 720 976 V1280 H0 Z" fill="#022c22" opacity="0.62" />
|
||||
</svg>`);
|
||||
|
||||
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<BigFishAssetSlotResponse[]>(
|
||||
() => [
|
||||
{
|
||||
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 (
|
||||
<BigFishRuntimeShell
|
||||
run={run}
|
||||
assetSlots={assetSlots}
|
||||
onBack={handleRestart}
|
||||
onSubmitInput={handleSubmitInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
89
src/PuzzlePlaygroundApp.tsx
Normal file
89
src/PuzzlePlaygroundApp.tsx
Normal file
@@ -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(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fef3c7" />
|
||||
<stop offset="0.45" stop-color="#fb7185" />
|
||||
<stop offset="1" stop-color="#312e81" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="42%" cy="34%" r="46%">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.78" />
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="960" height="960" fill="url(#sky)" />
|
||||
<circle cx="312" cy="282" r="210" fill="url(#glow)" />
|
||||
<path d="M0 680 C170 610 278 724 424 650 C574 574 704 612 960 512 V960 H0 Z" fill="#1e1b4b" opacity="0.9" />
|
||||
<path d="M0 766 C178 710 320 794 492 732 C642 678 780 708 960 652 V960 H0 Z" fill="#111827" opacity="0.78" />
|
||||
<path d="M160 356 C238 298 326 304 388 376 C456 456 550 436 626 374 C710 306 824 330 882 410" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
|
||||
<path d="M204 502 h552" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
|
||||
<path d="M268 566 h424" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
|
||||
</svg>`);
|
||||
|
||||
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 (
|
||||
<PuzzleRuntimeShell
|
||||
run={run}
|
||||
onBack={handleRestart}
|
||||
onSwapPieces={handleSwapPieces}
|
||||
onDragPiece={handleDragPiece}
|
||||
onAdvanceNextLevel={handleAdvanceNextLevel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
submitDirection(
|
||||
resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY),
|
||||
);
|
||||
setStick(vector);
|
||||
onSubmitInput(vector);
|
||||
};
|
||||
|
||||
const endTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
setTouchOrigin(null);
|
||||
submitDirection({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
if (!run) {
|
||||
@@ -206,7 +246,14 @@ export function BigFishRuntimeShell({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div className="relative h-full w-full max-w-[430px] overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative h-full w-full max-w-[430px] touch-none overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]"
|
||||
onPointerDown={beginTouchControl}
|
||||
onPointerMove={updateTouchControl}
|
||||
onPointerUp={endTouchControl}
|
||||
onPointerCancel={endTouchControl}
|
||||
>
|
||||
{backgroundAsset ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundAsset}
|
||||
@@ -251,40 +298,7 @@ export function BigFishRuntimeShell({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 left-4 z-30">
|
||||
<div
|
||||
ref={padRef}
|
||||
role="presentation"
|
||||
className="relative h-28 w-28 rounded-full border border-white/18 bg-black/24 backdrop-blur"
|
||||
onPointerDown={(event) => {
|
||||
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 });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-11 w-11 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-200 shadow-lg shadow-cyan-950/30"
|
||||
style={{
|
||||
transform: `translate(calc(-50% + ${stick.x * 34}px), calc(-50% + ${stick.y * 34}px))`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
||||
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
||||
{isBusy ? <div>同步中...</div> : null}
|
||||
{error ? <div className="text-rose-200">{error}</div> : null}
|
||||
{run.eventLog.slice(-3).map((event) => (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '正在载入游戏',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user