Skip to content

Commit b5ec524

Browse files
committed
feat(archipelago): 添加移动端航行控制 / add mobile sailing controls
中文:新增空灵方向键与 Three.js 风帆加速控件,支持靠岸后继续驶离;修复登岛点击立即关闭档案面板的时序问题,并明确 Archipelago 使用 Vue 表现层、未来 World 可使用独立 UI runtime。 English: Add ethereal directional controls and a Three.js sail boost for mobile, preserve controls while docked, fix the island dossier opening-click race, and document Vue as the Archipelago presentation layer while allowing future Worlds to use local UI runtimes.
1 parent 383168c commit b5ec524

16 files changed

Lines changed: 630 additions & 25 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Archipelago Art Direction
2+
3+
更新时间:2026-08-08
4+
5+
这份文档记录 Archipelago World 的视觉语言和 UI 资产处理经验。它约束的是
6+
海岛世界自己的表现层,不约束 Cosmic World 或其他未来 World。
7+
8+
## 核心方向
9+
10+
Archipelago UI 应该来自航海图、岛屿志、手绘边注、罗盘、船舵、帆布、
11+
羊皮纸、低多边形海岸线和温暖墨迹,而不是后台仪表盘、现代游戏手柄、
12+
霓虹科幻面板或 Cosmic World 的深色空间站语言。
13+
14+
推荐气质:
15+
16+
- 温暖、可触摸、有航海道具感。
17+
- 奶油纸色、暖金、木色、旧铜色、柔和海蓝作为辅助。
18+
- 控件像世界内的物件,而不是覆盖在画面上的网页组件。
19+
- 文字少,优先用道具形状表达功能。
20+
21+
避免:
22+
23+
- 大面积高饱和蓝色圆盘。
24+
- 纯 CSS 画出的现代圆形按钮。
25+
- 闪电、火箭、霓虹加速等脱离小船航行语义的符号。
26+
- 方形 AI 生成图直接贴进 UI,保留背景、光晕或画布边缘。
27+
28+
## 移动端航行控件
29+
30+
移动端使用两类控件:
31+
32+
- 航向:四枚独立的按住方向键,使用透明航迹光环和轻玻璃质感。
33+
- 加速:顺风帆或鼓风帆徽章,表达“借风加速”,不要使用文字按钮。
34+
35+
交互逻辑仍属于 `vendor/island-sea/src/three/world-controls.ts`;Vue 组件只把
36+
触摸输入映射成 `throttle``steer``sprint`。移动端 UI 资产不应该直接
37+
修改小船、相机或导航状态。
38+
39+
方向控件需要满足:
40+
41+
- 不使用摇杆、拖拽球或需要精细手势定位的交互。
42+
- 四个方向键保持独立,允许同时按住两个方向形成斜向输入。
43+
- 海面应该透过控件可见,避免厚重的深色实心底盘。
44+
- 靠岸但未打开档案时继续显示,只有进入 `landed` 档案状态才隐藏。
45+
46+
## AI 图片资产处理经验
47+
48+
AI 生成的控件图可以提高质感,但必须经过项目化处理后再接入:
49+
50+
1. 生成时要求透明 PNG。如果拿到的是带背景的方形图,不要直接使用。
51+
2. 如果图片有渐变背景或光晕,自动抠图可能会把光晕识别成主体。
52+
3. 对规则道具优先使用可控遮罩:例如船舵罗盘可以用主圆盘加握柄的几何遮罩。
53+
4. 输出成带透明通道的稳定正方形资产,例如 `512x512`
54+
5. 保存到 World 自己的资产目录,例如 `vendor/island-sea/src/assets/`
55+
6. 让 Vite 通过 CSS 或模块引用打包资产,不要引用仓库根目录里的临时图片。
56+
57+
本次罗盘实验的结论:
58+
59+
- 罗盘图片可以作为视觉参考,但不适合继续承担摇杆底盘。
60+
- 用户不喜欢摇杆本身时,替换摇杆皮肤不能解决交互问题,应更换交互模型。
61+
- 已撤回生成的罗盘运行时资产,仓库不提交未被代码引用的根目录源图。
62+
- 当前方向控件使用 CSS 轻玻璃按钮;加速图标使用可销毁的 Three.js 风帆模型。
63+
64+
踩坑记录:
65+
66+
- 直接用 GrabCut 自动抠图时,暖色背景光晕被误判为主体,边缘会像一块不规则底板。
67+
- Windows + PowerShell + Python/OpenCV 读取中文文件名时,`cv2.imread()` 可能失败。
68+
更稳的方式是用 `Path.glob()` 找文件,并通过 `np.fromfile()` + `cv2.imdecode()` 读取。
69+
70+
## 验收标准
71+
72+
新增或替换 Archipelago UI 资产时,至少确认:
73+
74+
- 资产边缘没有方形底、背景晕或明显抠图毛边。
75+
- 在移动端海面背景上仍然清晰,但不会抢过岛屿、小船和小地图。
76+
- 触控区域足够大,视觉资产可以小一些,但按钮命中区域不能太小。
77+
- 靠岸状态仍可操作,按下方向键后能够重新驶离岛屿。
78+
- 销毁、登岛、暂停或 Portal 转场时,移动输入会清零。
79+
- `npm run build:archipelago` 能通过,并且构建产物包含所需模型与样式。

docs/ARCHITECTURE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,14 @@ The Content Kernel and Runtime are deep modules:
7878
| Build | Vite | TypeScript, code splitting, hashed assets, and GitHub Pages builds |
7979
| Language | TypeScript | Stable contracts for content, Worlds, resources, and transitions |
8080
| 3D baseline | Three.js `WebGLRenderer` | Compatible with both current experiences and custom GLSL/post-processing |
81-
| Archipelago UI | Vue adapter initially | Reuse working HUD while preventing Vue from owning the site |
81+
| Archipelago World presentation | Vue adapter initially | The island World's HUD and panels use Vue while Runtime ownership stays outside Vue |
8282
| Deployment | GitHub Actions + GitHub Pages | Pure static hosting in the account-name repository |
8383

84-
Astro is the host, not the visual engine. Vue is an optional adapter inside a
85-
World, not the application architecture.
84+
Astro is the host, not the visual engine. Vue is the current presentation-layer
85+
technology for the Archipelago World, and future Worlds may use their own local
86+
UI runtimes such as React. Those runtimes remain World-internal adapters; they
87+
do not own site navigation, the shared renderer, the browser frame loop, Portal
88+
Journeys, or cross-World state.
8689

8790
## 5. Repository Shape
8891

docs/ARCHITECTURE.zh-CN.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,13 @@ Content Kernel 和 Runtime 是两个深模块:
7171
| 构建 | Vite | TypeScript、代码分块、哈希资源和 GitHub Pages 构建 |
7272
| 语言 | TypeScript | 为内容、世界、资源和转场提供稳定契约 |
7373
| 3D 基线 | Three.js `WebGLRenderer` | 兼容当前两种体验及自定义 GLSL/后处理 |
74-
| Archipelago UI | 初期保留 Vue Adapter | 复用已可用 HUD,同时避免 Vue 接管整站 |
74+
| Archipelago World 表现层 | 初期保留 Vue Adapter | 海岛世界的 HUD 和面板使用 Vue,但 Runtime 所有权仍在 Vue 外部 |
7575
| 部署 | GitHub Actions + GitHub Pages | 账户名仓库中的纯静态托管 |
7676

77-
Astro 是宿主,不是视觉引擎。Vue 是世界内部的可选 Adapter,不是应用架构。
77+
Astro 是宿主,不是视觉引擎。Vue 是当前 Archipelago World 的表现层技术;
78+
未来其他 World 也可以使用自己的局部 UI runtime,例如 React。这些 runtime
79+
都只是 World 内部 Adapter,不拥有整站导航、共享 renderer、浏览器帧循环、
80+
Portal Journey 或跨世界状态。
7881

7982
## 5. 仓库形态
8083

docs/MIGRATION-DEBT.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,18 +304,19 @@ Content 可以进入多个 World,但每个 World 必须拥有自己的 UI 语
304304
- 岛屿目录改为“岛屿志 / 航海图册”方向。
305305
- 目录只展示真实作品岛,不再列出 `未命名海域 01` 这类未来雾岛。
306306
- 目录行去掉重复 builder 列,避免后台表格感和文字挤压。
307+
- 已新增 `docs/ARCHIPELAGO-ART-DIRECTION.zh-CN.md`,记录移动端航行控件、
308+
AI 图片资产透明化实验和罗盘方案回退经验。
307309

308310
剩余问题:
309311

310-
- 还没有独立的 Archipelago Art Direction 文档约束后续修改。
311312
- HUD 仍在 `vendor/island-sea` 内完成,后续迁移到正式 World Module 时
312313
可能再次丢失视觉意图。
313314
- 需要为移动端、不同天色、已登陆岛屿状态补一组视觉回归截图。
314315

315316
偿还目标:
316317

317-
- 新增 `docs/ARCHIPELAGO-ART-DIRECTION.zh-CN.md`明确 Archipelago
318-
的 UI 应该来自航海图、岛屿志、手绘边注、低多边形海岸线,而不是
318+
- 继续维护 `docs/ARCHIPELAGO-ART-DIRECTION.zh-CN.md`确保 Archipelago
319+
的 UI 来自航海图、岛屿志、手绘边注、低多边形海岸线,而不是
319320
Cosmic 的深色仪表盘。
320321
- 在正式 Archipelago UI Binding 迁移时,把这些规则作为验收标准。
321322
- 建立至少桌面和移动端两张截图基线,避免后续 AI 或人工改动把画风带偏。

docs/adr/0003-static-first-astro-host.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ application.
1515
## Decision
1616

1717
Use Astro static output as the document host, Vite for the build, and TypeScript
18-
for the Content Kernel and Runtime. Interactive Worlds load on demand. Vue may
19-
remain inside the Archipelago UI adapter but does not own site navigation,
20-
content, rendering, or cross-World state.
18+
for the Content Kernel and Runtime. Interactive Worlds load on demand. Vue is
19+
the current presentation-layer technology for the Archipelago World UI adapter.
20+
Other Worlds may use different local UI runtimes, such as React, when that
21+
runtime belongs only to the World adapter and does not own site navigation,
22+
content, rendering, the browser frame loop, or cross-World state.
2123

2224
## Consequences
2325

docs/adr/0003-static-first-astro-host.zh-CN.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@
1111

1212
## 决策
1313

14-
使用 Astro 静态输出作为文档宿主,Vite 负责构建,TypeScript 负责 Content Kernel 和 Runtime。交互世界按需加载。Vue 可以暂时保留在 Archipelago UI Adapter 内部,但不拥有整站导航、内容、渲染或跨世界状态
14+
使用 Astro 静态输出作为文档宿主,Vite 负责构建,TypeScript 负责 Content Kernel 和 Runtime。交互世界按需加载。Vue 是当前 Archipelago World UI Adapter 的表现层技术。其他 World 也可以在自身 Adapter 内使用不同的局部 UI runtime,例如 React,但这些 runtime 不拥有整站导航、内容、渲染、浏览器帧循环或跨世界状态
1515

1616
## 后果
1717

1818
- GitHub Pages 仍只接收普通静态文件。
1919
- 3D Runtime 启动前,Portfolio 内容已经存在。
2020
- 只有交互区域需要支付 JavaScript 成本。
2121
- 仓库会新增构建步骤和 GitHub Actions 部署。
22-
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { installDeferredDocumentClick } from '../../vendor/island-sea/src/deferred-document-click'
3+
4+
describe('installDeferredDocumentClick', () => {
5+
beforeEach(() => {
6+
vi.useFakeTimers()
7+
document.body.replaceChildren()
8+
})
9+
10+
afterEach(() => {
11+
vi.useRealTimers()
12+
})
13+
14+
it('ignores the click that installs the listener, then handles later clicks', () => {
15+
const trigger = document.createElement('button')
16+
const listener = vi.fn()
17+
let dispose = () => {}
18+
trigger.addEventListener('click', () => {
19+
dispose = installDeferredDocumentClick(listener)
20+
})
21+
document.body.append(trigger)
22+
23+
trigger.click()
24+
expect(listener).not.toHaveBeenCalled()
25+
26+
vi.runOnlyPendingTimers()
27+
document.body.click()
28+
expect(listener).toHaveBeenCalledOnce()
29+
30+
dispose()
31+
document.body.click()
32+
expect(listener).toHaveBeenCalledOnce()
33+
})
34+
35+
it('can be disposed before the listener is installed', () => {
36+
const listener = vi.fn()
37+
const dispose = installDeferredDocumentClick(listener)
38+
39+
dispose()
40+
vi.runOnlyPendingTimers()
41+
document.body.click()
42+
43+
expect(listener).not.toHaveBeenCalled()
44+
})
45+
})

vendor/island-sea/src/App.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
requestProjectOpen,
1010
} from './host-bridge'
1111
import Hud from './components/Hud.vue'
12+
import MobileControls from './components/MobileControls.vue'
1213
import Minimap from './components/Minimap.vue'
1314
import IslandList from './components/IslandList.vue'
1415
import IslandPanel from './components/IslandPanel.vue'
@@ -65,6 +66,7 @@ onBeforeUnmount(() => {
6566
<Hud />
6667
<IslandList v-if="store.mode !== 'landed'" />
6768
<Minimap v-if="store.mode !== 'landed'" />
69+
<MobileControls v-if="store.mode !== 'landed'" />
6870
<IslandPanel v-if="store.mode === 'landed'" />
6971
<div v-if="empty" class="empty-world">海面正在等待第一座岛</div>
7072
</template>

vendor/island-sea/src/components/Hud.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ function skipTime() {
5151
<!-- 底部航行提示 -->
5252
<div class="hud-bottom">
5353
<div v-if="store.mode === 'sailing'" class="chip hint">
54-
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / 方向键 开船 · 点岛屿或小地图即可快速前往
54+
<span class="desktop-hint"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / 方向键 开船 · 点岛屿或小地图即可快速前往</span>
55+
<span class="mobile-hint">按住方向键开船 · 点岛屿或小地图即可快速前往</span>
5556
</div>
5657
</div>
5758
</div>

vendor/island-sea/src/components/IslandPanel.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { store, islandById } from '../store'
44
import { getWorld } from '../three/world'
55
import { requestProjectOpen, requestReturnToHost } from '../host-bridge'
66
import type { IslandPhotoRef } from '../data/islands'
7+
import { installDeferredDocumentClick } from '../deferred-document-click'
78
89
const island = computed(() => (store.dockedId ? islandById(store.dockedId) : undefined))
910
const viewer = ref<HTMLDialogElement | null>(null)
@@ -18,6 +19,7 @@ const activePhotoIndex = computed(() =>
1819
island.value?.photos.findIndex(photo => photo.id === activePhotoId.value) ?? -1
1920
)
2021
let carouselTimer: number | undefined
22+
let disposeDocumentClick: (() => void) | undefined
2123
2224
function clearCarouselTimer() {
2325
if (carouselTimer !== undefined) {
@@ -45,11 +47,14 @@ function scheduleCarousel() {
4547
4648
onMounted(() => {
4749
scheduleCarousel()
48-
document.addEventListener('click', handleDocumentClick)
50+
// The panel can mount while the island-selection click is still bubbling.
51+
// Defer the global exit listener so that opening click cannot close it again.
52+
disposeDocumentClick = installDeferredDocumentClick(handleDocumentClick)
4953
})
5054
onBeforeUnmount(() => {
5155
clearCarouselTimer()
52-
document.removeEventListener('click', handleDocumentClick)
56+
disposeDocumentClick?.()
57+
disposeDocumentClick = undefined
5358
})
5459
5560
watch(

0 commit comments

Comments
 (0)