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:
@@ -3,15 +3,35 @@ import type { Choice } from '@engine/types'
|
||||
|
||||
const props = defineProps<{
|
||||
choices: Choice[]
|
||||
timerTotal: number
|
||||
timerRemaining: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
choose: [index: number]
|
||||
}>()
|
||||
|
||||
function timerPercent(): number {
|
||||
if (props.timerTotal <= 0) return 0
|
||||
return (props.timerRemaining / props.timerTotal) * 100
|
||||
}
|
||||
|
||||
function timerClass(): string {
|
||||
if (props.timerRemaining <= 3) return 'danger'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="choice-panel" v-if="choices.length > 0">
|
||||
<div class="timer-bar" v-if="timerTotal > 0">
|
||||
<div
|
||||
class="timer-fill"
|
||||
:class="timerClass()"
|
||||
:style="{ width: timerPercent() + '%' }"
|
||||
></div>
|
||||
<span class="timer-text">{{ timerRemaining.toFixed(1) }}s</span>
|
||||
</div>
|
||||
<div class="choice-prompt">做出你的选择</div>
|
||||
<div class="choice-list">
|
||||
<button
|
||||
@@ -33,10 +53,38 @@ const emit = defineEmits<{
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
|
||||
padding: 40px 20px 30px;
|
||||
padding: 20px 20px 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.timer-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timer-fill {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transition: width 0.1s linear;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.timer-fill.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.timer-text {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
right: 0;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.choice-prompt {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -2,39 +2,40 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
videoReady: [el: HTMLVideoElement]
|
||||
videoReady: [elA: HTMLVideoElement, elB: HTMLVideoElement]
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const videoARef = ref<HTMLVideoElement | null>(null)
|
||||
const videoBRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (videoRef.value) {
|
||||
emit('videoReady', videoRef.value)
|
||||
if (videoARef.value && videoBRef.value) {
|
||||
emit('videoReady', videoARef.value, videoBRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ videoRef })
|
||||
defineExpose({ videoARef, videoBRef })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-player">
|
||||
<video ref="videoRef" class="player-video" preload="auto"></video>
|
||||
<video ref="videoARef" class="player-video" preload="auto"></video>
|
||||
<video ref="videoBRef" class="player-video" preload="auto"></video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
will-change: opacity;
|
||||
}
|
||||
</style>
|
||||
|
||||
157
src/components/SaveLoadMenu.vue
Normal file
157
src/components/SaveLoadMenu.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { SlotInfo } from '@/stores/gameStore'
|
||||
|
||||
const props = defineProps<{
|
||||
saves: SlotInfo[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [slot: number]
|
||||
load: [slot: number]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const maxSlots = 5
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="save-overlay" @click.self="emit('close')">
|
||||
<div class="save-panel">
|
||||
<h2 class="save-title">存档 / 读档</h2>
|
||||
|
||||
<div class="slot-list">
|
||||
<div
|
||||
v-for="slot in maxSlots"
|
||||
:key="slot"
|
||||
class="save-slot"
|
||||
>
|
||||
<div class="slot-label">存档 {{ slot }}</div>
|
||||
<div class="slot-info" v-if="saves.find(s => s.slot === slot)">
|
||||
{{ saves.find(s => s.slot === slot)!.sceneLabel }}
|
||||
</div>
|
||||
<div class="slot-info empty" v-else>空</div>
|
||||
<div class="slot-actions">
|
||||
<button class="slot-btn save-btn" @click="emit('save', slot)">保存</button>
|
||||
<button
|
||||
class="slot-btn load-btn"
|
||||
:disabled="!saves.find(s => s.slot === slot)"
|
||||
@click="emit('load', slot)"
|
||||
>
|
||||
读取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="close-btn" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.save-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.save-panel {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.save-title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 24px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.slot-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.save-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.slot-label {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slot-info {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slot-info.empty {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.slot-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slot-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ddd;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slot-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.slot-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: block;
|
||||
margin: 24px auto 0;
|
||||
padding: 10px 32px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #ccc;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user