feat: chapter select system, multi-chapter support, scene manager refactor, and docs update
This commit is contained in:
56
src/App.vue
56
src/App.vue
@@ -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>
|
||||
|
||||
151
src/components/ChapterSelect.vue
Normal file
151
src/components/ChapterSelect.vue
Normal 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>
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user