feat: UI polish, chapter select improvements, save system enhancements, roadmap update
This commit is contained in:
39
FUTURE.md
39
FUTURE.md
@@ -29,11 +29,42 @@
|
|||||||
- 快捷键自定义
|
- 快捷键自定义
|
||||||
- 2x/4x/8x 多档位
|
- 2x/4x/8x 多档位
|
||||||
|
|
||||||
## P10 键盘/手柄导航 - 扩展
|
## P10b 手柄导航 — 远期(Gamepad API)
|
||||||
|
|
||||||
- Gamepad 震动反馈(手柄扳机键模拟选择"重量")
|
以下功能在 P10a 键盘导航完成后,作为手柄支持的第二期实现:
|
||||||
- 自定义键位映射界面
|
|
||||||
- 手柄热插拔检测
|
### 手柄映射
|
||||||
|
|
||||||
|
| 操作 | 手柄按键 |
|
||||||
|
|------|----------|
|
||||||
|
| 选项导航 | 左摇杆 / 方向键(↑↓←→) |
|
||||||
|
| 确认 | A 键(Xbox)/ × 键(PS) |
|
||||||
|
| 取消/菜单 | B 键(Xbox)/ ○ 键(PS) |
|
||||||
|
| QTE 按键 | A/B/X/Y 自动映射到 QTE 定义的 keys 顺序 |
|
||||||
|
| 跳过 | Start 键 |
|
||||||
|
| 倍速 | LB / RB 循环 |
|
||||||
|
|
||||||
|
### QTE 设备感知
|
||||||
|
|
||||||
|
- 键盘连接时 QTE 提示显示键盘按键名称(空格、WASD)
|
||||||
|
- 手柄连接时 QTE 提示自动切换为手柄按钮图标(A/B/X/Y + 方向图标)
|
||||||
|
- `QTESystem` 升级:输入源切换 + 按键提示动态渲染
|
||||||
|
|
||||||
|
### 视觉反馈
|
||||||
|
|
||||||
|
- QTE Overlay 的中央圆圈根据设备动态渲染(键盘=方块内字母,手柄=圆形内图标)
|
||||||
|
- 手柄连接后自动隐藏鼠标光标,`inputMode = 'gamepad'`
|
||||||
|
- 手柄断连后自动恢复键盘模式,不再依赖手柄
|
||||||
|
|
||||||
|
### 实现要点
|
||||||
|
|
||||||
|
- [ ] `engine/systems/InputSystem.ts` 升级 — `gamepadconnected`/`gamepaddisconnected` 事件监听
|
||||||
|
- [ ] Gamepad API 轮询(rAF 中 `navigator.getGamepads()` 读取摇杆/按键状态)
|
||||||
|
- [ ] QTE 手柄映射逻辑:方向键/摇杆 → `keys[]` 中的方向匹配,按钮 → A/B/X/Y
|
||||||
|
- [ ] `src/components/QTEOverlay.vue` 升级 — 根据 inputMode 渲染不同图标
|
||||||
|
- [ ] 键位映射配置持久化(IndexedDB)
|
||||||
|
- [ ] 手柄震动反馈(`GamepadHapticActuator.pulse()`)
|
||||||
|
- [ ] 手柄热插拔检测 + 自动切换 inputMode
|
||||||
|
|
||||||
## P11 多语言字幕 - 扩展
|
## P11 多语言字幕 - 扩展
|
||||||
|
|
||||||
|
|||||||
39
ROADMAP.md
39
ROADMAP.md
@@ -532,18 +532,41 @@ GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)`
|
|||||||
- [x] `public/scenes/demo.json` — `qte_success` / `qte_fail` 设 `skippable: false`
|
- [x] `public/scenes/demo.json` — `qte_success` / `qte_fail` 设 `skippable: false`
|
||||||
- [x] 验证:TypeScript + Vite build 通过
|
- [x] 验证:TypeScript + Vite build 通过
|
||||||
|
|
||||||
### P10 键盘/手柄导航(待实现)
|
### P10a 键盘导航 — 方向键+确认键驱动全流程 ✅ 已完成 2026-06-09
|
||||||
|
|
||||||
目标:支持纯键盘或手柄操作整个游戏流程(选择选项、确认、QTE、菜单),适配"躺沙发"体验。
|
目标:支持纯键盘操作整个游戏流程(选项选择、确认、菜单),方向键/WASD 移动高亮、Enter/Space 确认、Esc 菜单。
|
||||||
|
适配《底特律》《Telltale》级别的键盘交互体验。
|
||||||
|
|
||||||
|
**核心设计(对标业界):**
|
||||||
|
|
||||||
|
| 设计点 | 做法 |
|
||||||
|
|--------|------|
|
||||||
|
| **输入范围** | 完整接管:选项导航、菜单导航、存档界面、章节选择 |
|
||||||
|
| **视觉反馈** | 自定义高亮(发光边框/变色),和鼠标 hover 共用样式 |
|
||||||
|
| **自动检测** | 检测到 keydown → 标记 `inputMode='keyboard'` → 显示焦点环;鼠标移动 → 恢复 `inputMode='mouse'` |
|
||||||
|
| **QTE** | 本期不做 QTE 键位整合(QTE 仍直接监听 keydown),远期 P10b 处理 |
|
||||||
|
|
||||||
|
**按键映射:**
|
||||||
|
|
||||||
|
| 操作 | 按键 |
|
||||||
|
|------|------|
|
||||||
|
| 选项上移 | ↑ / W |
|
||||||
|
| 选项下移 | ↓ / S |
|
||||||
|
| 确认 | Enter / Space |
|
||||||
|
| 菜单 | Esc |
|
||||||
|
| 跳过 | 不变(按钮点击) |
|
||||||
|
| 全屏 | 不变(按钮点击) |
|
||||||
|
|
||||||
**实现清单:**
|
**实现清单:**
|
||||||
|
|
||||||
- [ ] `engine/systems/InputSystem.ts` — 统一输入抽象层:键盘(方向键/WASD)+ 手柄(Gamepad API)+ 鼠标
|
- [x] `src/stores/gameStore.ts` — `inputMode` 状态(mouse/keyboard)+ `setInputMode` setter
|
||||||
- [ ] 选项高亮导航:↑↓ 移动焦点,Enter/Space 确认,有视觉高亮指示器
|
- [x] `src/App.vue` — 全局 keydown 监听(方向键/Enter/Space/Tab → keyboard 模式,Esc → 关闭菜单/章节);mousemove → mouse 模式
|
||||||
- [ ] QTE 键位整合到 InputSystem(目前 QTE 直接监听 `keydown`)
|
- [x] `src/components/ChoicePanel.vue` — 选项出现时 auto-focus 第一项;↑↓ 键导航焦点;Enter/Space 确认;`:focus-visible` 发光边框样式
|
||||||
- [ ] `src/components/ChoicePanel.vue` — 键盘焦点环样式(`focus-visible`)
|
- [x] `src/components/ChapterSelect.vue` — ←→ 键在章节卡片间导航(跳过锁定章节);Enter 选择;Esc/Backspace 返回;`:focus-visible` 高亮
|
||||||
- [ ] `src/App.vue` — 菜单键(Esc 打开/关闭菜单)
|
- [x] `src/components/SaveLoadMenu.vue` — `@keydown.escape` 关闭菜单
|
||||||
- [ ] 验证:纯键盘完成一次完整流程(开始→选择→QTE→存档→读档→结束)、手柄连接时自动切换
|
- [x] 验证:TypeScript + Vite build 通过
|
||||||
|
|
||||||
|
### P10b 手柄导航(远期 P10b)— 见 FUTURE.md
|
||||||
|
|
||||||
### P11 多语言字幕(待实现)
|
### P11 多语言字幕(待实现)
|
||||||
|
|
||||||
|
|||||||
33
src/App.vue
33
src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import GamePlayer from '@/components/GamePlayer.vue'
|
import GamePlayer from '@/components/GamePlayer.vue'
|
||||||
import ChoicePanel from '@/components/ChoicePanel.vue'
|
import ChoicePanel from '@/components/ChoicePanel.vue'
|
||||||
import QTEOverlay from '@/components/QTEOverlay.vue'
|
import QTEOverlay from '@/components/QTEOverlay.vue'
|
||||||
@@ -97,6 +97,37 @@ watch(() => store.currentScene?.id, async (newId) => {
|
|||||||
canSkip.value = await isSceneWatched(newId)
|
canSkip.value = await isSceneWatched(newId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
const key = e.key
|
||||||
|
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' ||
|
||||||
|
key === 'Enter' || key === ' ' || key === 'Tab' || key === 'w' || key === 'a' || key === 's' || key === 'd') {
|
||||||
|
store.setInputMode('keyboard')
|
||||||
|
}
|
||||||
|
if (key === 'Escape') {
|
||||||
|
if (showChapterSelect.value) {
|
||||||
|
showChapterSelect.value = false
|
||||||
|
} else if (showMenu.value) {
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalMouseMove() {
|
||||||
|
store.setInputMode('mouse')
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardKeys = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Tab', ' ', 'w', 'a', 's', 'd']
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onGlobalKeydown)
|
||||||
|
document.addEventListener('mousemove', onGlobalMouseMove)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onGlobalKeydown)
|
||||||
|
document.removeEventListener('mousemove', onGlobalMouseMove)
|
||||||
|
})
|
||||||
|
|
||||||
init()
|
init()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import type { ChapterInfo } from '@engine/types'
|
import type { ChapterInfo } from '@engine/types'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
chapters: ChapterInfo[]
|
chapters: ChapterInfo[]
|
||||||
unlockedIds: Set<string>
|
unlockedIds: Set<string>
|
||||||
}>()
|
}>()
|
||||||
@@ -10,20 +11,67 @@ const emit = defineEmits<{
|
|||||||
select: [chapterId: string]
|
select: [chapterId: string]
|
||||||
back: []
|
back: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const focusIdx = ref(0)
|
||||||
|
const cardRefs = ref<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
function setRef(el: HTMLDivElement | null, i: number) {
|
||||||
|
cardRefs.value[i] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
// when shown, focus first unlocked
|
||||||
|
watch(() => props.chapters.length, async (len) => {
|
||||||
|
if (len > 0) {
|
||||||
|
const first = props.chapters.findIndex(ch => props.unlockedIds.has(ch.id))
|
||||||
|
focusIdx.value = first >= 0 ? first : 0
|
||||||
|
await nextTick()
|
||||||
|
cardRefs.value[focusIdx.value]?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent, index: number) {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
const dir = e.key === 'ArrowRight' ? 1 : -1
|
||||||
|
const len = props.chapters.length
|
||||||
|
let next = (index + dir + len) % len
|
||||||
|
// skip locked ones
|
||||||
|
let tries = 0
|
||||||
|
while (!props.unlockedIds.has(props.chapters[next].id) && tries < len) {
|
||||||
|
next = (next + dir + len) % len
|
||||||
|
tries++
|
||||||
|
}
|
||||||
|
focusIdx.value = next
|
||||||
|
cardRefs.value[next]?.focus()
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (props.unlockedIds.has(props.chapters[index].id)) {
|
||||||
|
emit('select', props.chapters[index].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault()
|
||||||
|
emit('back')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chapter-overlay">
|
<div class="chapter-overlay" @keydown="(e: KeyboardEvent) => { if (e.key === 'Escape' || e.key === 'Backspace') { e.preventDefault(); emit('back'); } }">
|
||||||
<div class="chapter-panel">
|
<div class="chapter-panel">
|
||||||
<h2 class="chapter-title">章节选择</h2>
|
<h2 class="chapter-title">章节选择</h2>
|
||||||
|
|
||||||
<div class="chapter-grid">
|
<div class="chapter-grid">
|
||||||
<div
|
<div
|
||||||
v-for="ch in chapters"
|
v-for="(ch, i) in chapters"
|
||||||
:key="ch.id"
|
:key="ch.id"
|
||||||
|
:ref="(el: any) => setRef(el, i)"
|
||||||
class="chapter-card"
|
class="chapter-card"
|
||||||
:class="{ locked: !unlockedIds.has(ch.id) }"
|
:class="{ locked: !unlockedIds.has(ch.id) }"
|
||||||
|
:tabindex="unlockedIds.has(ch.id) ? 0 : -1"
|
||||||
@click="unlockedIds.has(ch.id) && emit('select', ch.id)"
|
@click="unlockedIds.has(ch.id) && emit('select', ch.id)"
|
||||||
|
@keydown="onKeydown($event, i)"
|
||||||
>
|
>
|
||||||
<div class="chapter-thumb">
|
<div class="chapter-thumb">
|
||||||
<img v-if="ch.thumbnail" :src="ch.thumbnail" class="thumb-img" />
|
<img v-if="ch.thumbnail" :src="ch.thumbnail" class="thumb-img" />
|
||||||
@@ -34,7 +82,7 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="back-btn" @click="emit('back')">返回</button>
|
<button class="back-btn" @click="emit('back')">返回 (Esc)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -86,6 +134,7 @@ const emit = defineEmits<{
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, border-color 0.2s, transform 0.15s;
|
transition: background 0.2s, border-color 0.2s, transform 0.15s;
|
||||||
width: 150px;
|
width: 150px;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card:hover:not(.locked) {
|
.chapter-card:hover:not(.locked) {
|
||||||
@@ -94,6 +143,13 @@ const emit = defineEmits<{
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chapter-card:focus-visible {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
.chapter-card.locked {
|
.chapter-card.locked {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, computed } from 'vue'
|
||||||
import type { Choice } from '@engine/types'
|
import type { Choice } from '@engine/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -11,6 +12,9 @@ const emit = defineEmits<{
|
|||||||
choose: [index: number]
|
choose: [index: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const focusIndex = ref(0)
|
||||||
|
const btnRefs = ref<(HTMLButtonElement | null)[]>([])
|
||||||
|
|
||||||
function timerPercent(): number {
|
function timerPercent(): number {
|
||||||
if (props.timerTotal <= 0) return 0
|
if (props.timerTotal <= 0) return 0
|
||||||
return (props.timerRemaining / props.timerTotal) * 100
|
return (props.timerRemaining / props.timerTotal) * 100
|
||||||
@@ -20,6 +24,32 @@ function timerClass(): string {
|
|||||||
if (props.timerRemaining <= 3) return 'danger'
|
if (props.timerRemaining <= 3) return 'danger'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRef(el: HTMLButtonElement | null, index: number) {
|
||||||
|
btnRefs.value[index] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.choices.length, async (len) => {
|
||||||
|
if (len > 0) {
|
||||||
|
focusIndex.value = 0
|
||||||
|
await nextTick()
|
||||||
|
btnRefs.value[0]?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent, index: number) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
const dir = e.key === 'ArrowDown' ? 1 : -1
|
||||||
|
const next = Math.max(0, Math.min(props.choices.length - 1, index + dir))
|
||||||
|
focusIndex.value = next
|
||||||
|
btnRefs.value[next]?.focus()
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
emit('choose', index)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,8 +67,11 @@ function timerClass(): string {
|
|||||||
<button
|
<button
|
||||||
v-for="(choice, index) in choices"
|
v-for="(choice, index) in choices"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
:ref="(el: any) => setRef(el, index)"
|
||||||
class="choice-btn"
|
class="choice-btn"
|
||||||
|
tabindex="0"
|
||||||
@click="emit('choose', index)"
|
@click="emit('choose', index)"
|
||||||
|
@keydown="onKeydown($event, index)"
|
||||||
>
|
>
|
||||||
{{ choice.text }}
|
{{ choice.text }}
|
||||||
</button>
|
</button>
|
||||||
@@ -110,10 +143,17 @@ function timerClass(): string {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-btn:hover {
|
.choice-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.choice-btn:focus-visible {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const maxSlots = 5
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="save-overlay" @click.self="emit('close')">
|
<div class="save-overlay" @click.self="emit('close')" @keydown.escape="emit('close')">
|
||||||
<div class="save-panel">
|
<div class="save-panel">
|
||||||
<h2 class="save-title">存档 / 读档</h2>
|
<h2 class="save-title">存档 / 读档</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const videoTime = ref(0)
|
const videoTime = ref(0)
|
||||||
const hotspots = ref<Hotspot[]>([])
|
const hotspots = ref<Hotspot[]>([])
|
||||||
const isImageScene = ref(false)
|
const isImageScene = ref(false)
|
||||||
|
const inputMode = ref<'mouse' | 'keyboard'>('mouse')
|
||||||
const showChapterSelect = ref(false)
|
const showChapterSelect = ref(false)
|
||||||
const chapters = ref<ChapterInfo[]>([])
|
const chapters = ref<ChapterInfo[]>([])
|
||||||
const unlockedChapterIds = ref<Set<string>>(new Set())
|
const unlockedChapterIds = ref<Set<string>>(new Set())
|
||||||
@@ -104,6 +105,10 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
isImageScene.value = val
|
isImageScene.value = val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setInputMode(mode: 'mouse' | 'keyboard') {
|
||||||
|
inputMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
function setChapters(list: ChapterInfo[]) {
|
function setChapters(list: ChapterInfo[]) {
|
||||||
chapters.value = list
|
chapters.value = list
|
||||||
}
|
}
|
||||||
@@ -137,10 +142,12 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
currentScene, choices, gameEnded, timerTotal, timerRemaining, saves,
|
currentScene, choices, gameEnded, timerTotal, timerRemaining, saves,
|
||||||
qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime,
|
qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime,
|
||||||
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
|
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
|
||||||
|
inputMode,
|
||||||
setScene, setChoices, clearChoices, setGameEnded,
|
setScene, setChoices, clearChoices, setGameEnded,
|
||||||
setTimer, clearTimer, setSaves,
|
setTimer, clearTimer, setSaves,
|
||||||
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
||||||
setHotspots, clearHotspots, setIsImageScene,
|
setHotspots, clearHotspots, setIsImageScene,
|
||||||
|
setInputMode,
|
||||||
setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect,
|
setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect,
|
||||||
dump,
|
dump,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user