feat: P1 core - seamless video switching, conditional branches, save/load

- VideoManager: A/B dual-buffered video with crossfade transitions and candidate preloading
- Engine: condition-based choice filtering, ChoiceSystem timer, resumeScene for save/load
- SceneManager: getCandidateUrls for preloading next scenes
- SaveSystem: Dexie.js IndexedDB multi-slot save/load
- ChoiceSystem: timed choices with countdown and auto-default on timeout
- GamePlayer: dual video elements with crossfade CSS
- ChoicePanel: timer progress bar and countdown text
- SaveLoadMenu: save/load UI component
- App.vue: menu trigger, dual video refs, save/load integration
- gameStore: timer state, saves list
- demo.json: conditional choice example (secret ending, requires trust >= 80)
- ROADMAP: mark P1 as completed
This commit is contained in:
2026-06-07 16:48:52 +08:00
parent 42181fe185
commit 937e45c203
16 changed files with 763 additions and 71 deletions

View File

@@ -2,15 +2,19 @@
import { ref } from 'vue'
import GamePlayer from '@/components/GamePlayer.vue'
import ChoicePanel from '@/components/ChoicePanel.vue'
import SaveLoadMenu from '@/components/SaveLoadMenu.vue'
import { useGameEngine } from '@/composables/useGameEngine'
import { useGameStore } from '@/stores/gameStore'
const store = useGameStore()
const videoElRef = ref<HTMLVideoElement | null>(null)
const videoElA = ref<HTMLVideoElement | null>(null)
const videoElB = ref<HTMLVideoElement | null>(null)
const loading = ref(true)
const started = ref(false)
const showMenu = ref(false)
const { loadGame, start, makeChoice } = useGameEngine(() => videoElRef.value)
const { loadGame, start, makeChoice, saveGame, loadGameFromSlot, refreshSaves } =
useGameEngine(() => [videoElA.value, videoElB.value])
async function init() {
await loadGame('/scenes/demo.json')
@@ -22,14 +26,31 @@ function handleStart() {
start()
}
function onVideoReady(el: HTMLVideoElement) {
videoElRef.value = el
function onVideoReady(elA: HTMLVideoElement, elB: HTMLVideoElement) {
videoElA.value = elA
videoElB.value = elB
}
function onChoose(index: number) {
makeChoice(index)
}
function toggleMenu() {
showMenu.value = !showMenu.value
if (showMenu.value) {
refreshSaves()
}
}
async function onSave(slot: number) {
await saveGame(slot)
}
async function onLoad(slot: number) {
await loadGameFromSlot(slot)
showMenu.value = false
}
init()
</script>
@@ -39,7 +60,15 @@ init()
<template v-else>
<div class="game-screen">
<GamePlayer @video-ready="onVideoReady" />
<ChoicePanel :choices="store.choices" @choose="onChoose" />
<ChoicePanel
:choices="store.choices"
:timer-total="store.timerTotal"
:timer-remaining="store.timerRemaining"
@choose="onChoose"
/>
<button v-if="started && !store.gameEnded" class="menu-trigger" @click="toggleMenu">
菜单
</button>
</div>
<div v-if="!started" class="start-overlay">
<button class="start-btn" @click="handleStart">开始游戏</button>
@@ -47,6 +76,13 @@ init()
<div v-if="store.gameEnded" class="game-end-overlay">
<div class="game-end-text">游戏结束</div>
</div>
<SaveLoadMenu
v-if="showMenu"
:saves="store.saves"
@save="onSave"
@load="onLoad"
@close="showMenu = false"
/>
</template>
</div>
</template>
@@ -94,6 +130,26 @@ html, body {
height: 100%;
}
.menu-trigger {
position: absolute;
top: 16px;
right: 16px;
z-index: 20;
padding: 8px 16px;
font-size: 13px;
color: #aaa;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.menu-trigger:hover {
background: rgba(0, 0, 0, 0.7);
color: #fff;
}
.game-end-overlay {
position: fixed;
inset: 0;