feat: chapter select system, multi-chapter support, scene manager refactor, and docs update

This commit is contained in:
2026-06-09 11:35:11 +08:00
parent 655b9a23d0
commit ace5ed1fb3
14 changed files with 413 additions and 17 deletions

View File

@@ -6,6 +6,7 @@ import QTEOverlay from '@/components/QTEOverlay.vue'
import Subtitles from '@/components/Subtitles.vue'
import HotspotLayer from '@/components/HotspotLayer.vue'
import SaveLoadMenu from '@/components/SaveLoadMenu.vue'
import ChapterSelect from '@/components/ChapterSelect.vue'
import { useGameEngine } from '@/composables/useGameEngine'
import { useGameStore } from '@/stores/gameStore'
import { useFullscreen } from '@/composables/useFullscreen'
@@ -17,9 +18,11 @@ const videoElB = ref<HTMLVideoElement | null>(null)
const loading = ref(true)
const started = ref(false)
const showMenu = ref(false)
const showChapterSelect = ref(false)
const hasAutoSave = ref(false)
const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, saveGame, loadGameFromSlot, refreshSaves, saveSystem } =
const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter,
saveGame, loadGameFromSlot, refreshSaves, saveSystem } =
useGameEngine(() => [videoElA.value, videoElB.value])
async function init() {
@@ -63,6 +66,17 @@ async function onLoad(slot: number) {
showMenu.value = false
}
function openChapterSelect() {
showMenu.value = false
showChapterSelect.value = true
}
async function onStartChapter(chapterId: string) {
showChapterSelect.value = false
started.value = true
startChapter(chapterId)
}
init()
</script>
@@ -107,9 +121,13 @@ init()
<div v-if="!started" class="start-overlay">
<button class="start-btn" @click="handleStart">开始游戏</button>
<button v-if="hasAutoSave" class="start-btn resume-btn" @click="handleResume">继续上次进度</button>
<button v-if="store.chapters.length > 0" class="start-btn chapters-btn" @click="openChapterSelect">章节选择</button>
</div>
<div v-if="store.gameEnded" class="game-end-overlay">
<div class="game-end-text">游戏结束</div>
<div v-if="store.chapters.length > 0" class="game-end-actions">
<button class="end-btn" @click="openChapterSelect">章节选择</button>
</div>
</div>
<SaveLoadMenu
v-if="showMenu"
@@ -118,6 +136,13 @@ init()
@load="onLoad"
@close="showMenu = false"
/>
<ChapterSelect
v-if="showChapterSelect"
:chapters="store.chapters"
:unlocked-ids="store.unlockedChapterIds"
@select="onStartChapter"
@back="showChapterSelect = false"
/>
</template>
</div>
</template>
@@ -236,4 +261,33 @@ html, body {
border-color: rgba(100, 200, 255, 0.3);
color: #8cf;
}
.chapters-btn {
margin-top: 16px;
border-color: rgba(255, 200, 100, 0.3);
color: #fc8;
}
.game-end-actions {
margin-top: 24px;
display: flex;
gap: 12px;
justify-content: center;
}
.end-btn {
padding: 14px 32px;
font-size: 18px;
color: #fc8;
background: rgba(255, 200, 100, 0.08);
border: 1px solid rgba(255, 200, 100, 0.2);
border-radius: 4px;
cursor: pointer;
letter-spacing: 2px;
transition: background 0.2s;
}
.end-btn:hover {
background: rgba(255, 200, 100, 0.15);
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { ChapterInfo } from '@engine/types'
defineProps<{
chapters: ChapterInfo[]
unlockedIds: Set<string>
}>()
const emit = defineEmits<{
select: [chapterId: string]
back: []
}>()
</script>
<template>
<div class="chapter-overlay">
<div class="chapter-panel">
<h2 class="chapter-title">章节选择</h2>
<div class="chapter-grid">
<div
v-for="ch in chapters"
:key="ch.id"
class="chapter-card"
:class="{ locked: !unlockedIds.has(ch.id) }"
@click="unlockedIds.has(ch.id) && emit('select', ch.id)"
>
<div class="chapter-thumb">
<img v-if="ch.thumbnail" :src="ch.thumbnail" class="thumb-img" />
<div v-else class="thumb-placeholder">?</div>
</div>
<div class="chapter-label">{{ ch.label }}</div>
<div v-if="!unlockedIds.has(ch.id)" class="lock-icon">🔒</div>
</div>
</div>
<button class="back-btn" @click="emit('back')">返回</button>
</div>
</div>
</template>
<style scoped>
.chapter-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.chapter-panel {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 36px 40px;
min-width: 520px;
max-width: 700px;
}
.chapter-title {
text-align: center;
font-size: 22px;
font-weight: 400;
color: #ddd;
letter-spacing: 3px;
margin-bottom: 28px;
}
.chapter-grid {
display: flex;
gap: 16px;
justify-content: center;
}
.chapter-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, transform 0.15s;
width: 150px;
}
.chapter-card:hover:not(.locked) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
}
.chapter-card.locked {
opacity: 0.4;
cursor: default;
}
.chapter-thumb {
width: 100px;
height: 56px;
background: rgba(0, 0, 0, 0.4);
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
font-size: 24px;
color: #555;
}
.chapter-label {
font-size: 13px;
color: #ccc;
text-align: center;
}
.lock-icon {
font-size: 14px;
}
.back-btn {
display: block;
margin: 24px auto 0;
padding: 10px 36px;
font-size: 14px;
color: #888;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #ccc;
}
</style>

View File

@@ -20,9 +20,18 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
const data: GameData = await resp.json()
engine.sceneManager.load(data)
engine.stateManager.init(data.variables)
store.setChapters(data.chapters || [])
const unlocked = await saveSystem.getUnlockedChapters()
store.setUnlockedChapters(unlocked)
}
function registerEvents() {
engine.setChapterUnlockHandler(async (chapterId) => {
await saveSystem.unlockChapter(chapterId)
store.addUnlockedChapter(chapterId)
})
engine.on('sceneChange', (scene) => {
store.setScene(scene)
store.clearChoices()
@@ -122,6 +131,13 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
}
}
function startChapter(chapterId: string) {
const [elA, elB] = videoEls()
if (elA && elB) engine.videoManager.attach(elA, elB)
store.setGameEnded(false)
engine.startChapter(chapterId)
}
async function saveGame(slot: number) {
const state = engine.stateManager
const currentScene = store.currentScene
@@ -163,5 +179,6 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
destroy()
})
return { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, saveGame, loadGameFromSlot, refreshSaves, engine, saveSystem }
return { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter,
saveGame, loadGameFromSlot, refreshSaves, engine, saveSystem }
}

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import type { SceneNode, Choice, QTEDefinition, Hotspot } from '@engine/types'
import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo } from '@engine/types'
export interface SlotInfo {
slot: number
@@ -25,6 +25,9 @@ export const useGameStore = defineStore('game', () => {
const videoTime = ref(0)
const hotspots = ref<Hotspot[]>([])
const isImageScene = ref(false)
const showChapterSelect = ref(false)
const chapters = ref<ChapterInfo[]>([])
const unlockedChapterIds = ref<Set<string>>(new Set())
function setScene(scene: SceneNode) {
currentScene.value = scene
@@ -93,6 +96,23 @@ export const useGameStore = defineStore('game', () => {
isImageScene.value = val
}
function setChapters(list: ChapterInfo[]) {
chapters.value = list
}
function setUnlockedChapters(ids: string[]) {
unlockedChapterIds.value = new Set(ids)
}
function addUnlockedChapter(id: string) {
unlockedChapterIds.value.add(id)
unlockedChapterIds.value = new Set(unlockedChapterIds.value)
}
function setShowChapterSelect(val: boolean) {
showChapterSelect.value = val
}
function dump() {
console.group('GameStore')
console.log('currentScene:', currentScene.value?.id)
@@ -108,11 +128,12 @@ export const useGameStore = defineStore('game', () => {
return {
currentScene, choices, gameEnded, timerTotal, timerRemaining, saves,
qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime,
hotspots, isImageScene,
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
setScene, setChoices, clearChoices, setGameEnded,
setTimer, clearTimer, setSaves,
showQTE, updateQTE, resolveQTE, setVideoTime,
setHotspots, clearHotspots, setIsImageScene,
setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect,
dump,
}
})