Files
tianshu-engine/src/App.vue

520 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import GamePlayer from '@/components/GamePlayer.vue'
import ChoicePanel from '@/components/ChoicePanel.vue'
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 PlaybackBar from '@/components/PlaybackBar.vue'
import MainMenu from '@/components/MainMenu.vue'
import AchievementToast from '@/components/AchievementToast.vue'
import AchievementPanel from '@/components/AchievementPanel.vue'
import EndingGallery from '@/components/EndingGallery.vue'
import ChapterRecap from '@/components/ChapterRecap.vue'
import AccessibilitySettings from '@/components/AccessibilitySettings.vue'
import { useGameEngine } from '@/composables/useGameEngine'
import { useGameStore } from '@/stores/gameStore'
import { useFullscreen } from '@/composables/useFullscreen'
import { useI18n, initStoryLocales } from '@/composables/useI18n'
const store = useGameStore()
const { t, currentLang } = useI18n()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
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 showChapterSelect = ref(false)
const showAchievements = ref(false)
const showEndingGallery = ref(false)
const recapChapterId = ref<string | null>(null)
const hasAutoSave = ref(false)
const currentSpeed = ref(1)
const canSkip = ref(false)
const paused = ref(false)
const promptToast = ref('')
const showPromptToast = ref(false)
const showIntro = ref(false)
const introWatched = ref(false)
const introVideoRef = ref<HTMLVideoElement | null>(null)
const menuVideoRef = ref<HTMLVideoElement | null>(null)
const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter,
skipScene, setSpeed, getSpeed, isSceneWatched,
saveGame, loadGameFromSlot, refreshSaves, saveSystem, engine } =
useGameEngine(() => [videoElA.value, videoElB.value])
async function resolveScenePath(): Promise<string> {
const params = new URLSearchParams(location.search)
const queryScene = params.get('scene')
if (queryScene) return queryScene.startsWith('/') ? queryScene : '/' + queryScene
try {
const resp = await fetch('/scenes/config.json')
if (resp.ok) {
const config = await resp.json()
if (config.sceneFile) return '/scenes/' + config.sceneFile
}
} catch { /* config.json optional */ }
return '/scenes/main.json'
}
async function init() {
const scenePath = await resolveScenePath()
await loadGame(scenePath)
const lc = store.storyLocales
if (lc.path) {
await initStoryLocales(lc.path)
}
loading.value = false
hasAutoSave.value = (await saveSystem.load(0)) !== null
if (store.introVideo) {
introWatched.value = await isSceneWatched('__intro__')
showIntro.value = true
}
}
function onIntroEnded() {
saveSystem.markWatched('__intro__')
showIntro.value = false
}
function skipIntro() {
saveSystem.markWatched('__intro__')
showIntro.value = false
}
function handleStart() {
started.value = true
store.setGameEnded(false)
applyQteParams()
start()
}
async function handleResume() {
started.value = true
applyQteParams()
await resumeAutoSave()
}
function applyQteParams() {
engine.qteSystem.timeLimitMultiplier = store.qteTimeRelax ? 1.5 : 1
engine.qteSystem.singleKeyMode = store.qteSingleKey
}
watch([() => store.qteTimeRelax, () => store.qteSingleKey], () => {
applyQteParams()
})
function onVideoReady(elA: HTMLVideoElement, elB: HTMLVideoElement) {
videoElA.value = elA
videoElB.value = elB
}
function onChoose(index: number) {
makeChoice(index)
}
function onPrompt(text: string) {
promptToast.value = text
showPromptToast.value = true
setTimeout(() => { showPromptToast.value = false }, 2500)
}
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
}
function openChapterSelect() {
showMenu.value = false
showChapterSelect.value = true
}
async function onStartChapter(chapterId: string) {
showChapterSelect.value = false
started.value = true
startChapter(chapterId)
}
function handleSkip() {
skipScene()
}
function handleSpeedChange(rate: number) {
setSpeed(rate)
currentSpeed.value = rate
}
watch(() => store.currentScene?.id, async (newId) => {
if (!newId) { canSkip.value = false; return }
const scene = store.currentScene
if (scene?.skippable === false) { canSkip.value = false; return }
canSkip.value = await isSceneWatched(newId)
})
function onGlobalKeydown(e: KeyboardEvent) {
const key = e.key
if (key === 'p' && !store.qteActive && store.pauseEnabled && started.value && !store.gameEnded) {
const activeEl = document.activeElement
if (!activeEl || activeEl.tagName === 'BODY' || activeEl === document.body) {
e.preventDefault()
togglePause()
return
}
}
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 (store.showSettings) {
store.showSettings = false
} else if (showChapterSelect.value) {
showChapterSelect.value = false
} else if (showMenu.value) {
showMenu.value = false
} else if (started.value && !store.gameEnded) {
showMenu.value = true
refreshSaves()
}
}
}
function togglePause() {
if (!started.value || store.gameEnded) return
if (paused.value) {
paused.value = false
engine.videoManager.getActiveVideoElement()?.play().catch(() => {})
} else {
paused.value = true
engine.videoManager.getActiveVideoElement()?.pause()
}
}
function onGlobalMouseMove() {
store.setInputMode('mouse')
}
onMounted(() => {
document.addEventListener('keydown', onGlobalKeydown)
document.addEventListener('mousemove', onGlobalMouseMove)
})
onUnmounted(() => {
document.removeEventListener('keydown', onGlobalKeydown)
document.removeEventListener('mousemove', onGlobalMouseMove)
})
init()
</script>
<template>
<div class="app-container">
<div v-if="loading" class="loading">{{ t('ui.loading') }}</div>
<template v-else>
<div v-if="showIntro" class="intro-overlay" @click="skipIntro">
<video ref="introVideoRef" :src="store.introVideo" class="intro-video" autoplay @ended="onIntroEnded"></video>
<button v-if="introWatched" class="intro-skip-btn" @click.stop="skipIntro">{{ t('ui.skip') }}</button>
</div>
<div v-if="store.menuVideo && (!started || store.gameEnded)" class="menu-bg">
<video ref="menuVideoRef" :src="store.menuVideo" class="menu-bg-video" autoplay loop muted></video>
</div>
<div class="game-screen" v-show="started && !store.gameEnded">
<GamePlayer v-show="!store.isImageScene" @video-ready="onVideoReady" />
<HotspotLayer
:hotspots="store.hotspots"
:is-image-scene="store.isImageScene"
:image-url="store.currentScene?.imageUrl"
:content-size="store.currentScene?.contentSize ?? null"
@click-hotspot="clickHotspot"
/>
<Subtitles
:current-time="store.videoTime"
:subtitle-url="store.currentScene?.subtitleUrl ?? null"
:subtitles="store.currentScene?.subtitles ?? null"
:lang="currentLang"
/>
<QTEOverlay
:visible="store.qteActive"
:prompt="store.qteDef?.prompt ?? ''"
:keys="store.qteDef?.keys ?? []"
:total="store.qteTotal"
:remaining="store.qteRemaining"
:result="store.qteResult"
/>
<ChoicePanel
v-if="!store.qteActive"
:choices="store.choices"
:timer-total="store.timerTotal"
:timer-remaining="store.timerRemaining"
@choose="onChoose"
@prompt="onPrompt"
/>
<Transition name="prompt-toast">
<div v-if="showPromptToast" class="prompt-toast">{{ promptToast }}</div>
</Transition>
<div v-if="started && !store.gameEnded" class="top-bar">
<LangSwitch />
<PlaybackBar
:can-skip="canSkip"
:current-speed="currentSpeed"
@skip="handleSkip"
@speed-change="handleSpeedChange"
/>
<button class="top-btn" @click="toggleFullscreen" :title="isFullscreen ? t('ui.exitFullscreen') : t('ui.fullscreen')">
{{ isFullscreen ? '⛶' : '⛶' }}
</button>
<button class="top-btn" @click="toggleMenu">{{ t('ui.menu') }}</button>
<button class="top-btn" @click="store.setShowSettings(true)">设置</button>
</div>
</div>
<MainMenu
v-if="!started || store.gameEnded"
:show-resume="!store.gameEnded && hasAutoSave"
:show-chapters="store.chapters.length > 0"
:show-achievements="store.achievementDefs.length > 0"
:show-gallery="store.endings.length > 0"
:is-game-end="store.gameEnded"
@start="handleStart"
@resume="handleResume"
@chapters="openChapterSelect"
@achievements="showAchievements = true"
@gallery="showEndingGallery = true"
@settings="store.setShowSettings(true)"
/>
<SaveLoadMenu
v-if="showMenu"
:saves="store.saves"
@save="onSave"
@load="onLoad"
@close="showMenu = false"
/>
<ChapterSelect
v-if="showChapterSelect"
:chapters="store.chapters"
:unlocked-ids="store.unlockedChapterIds"
@select="onStartChapter"
@back="showChapterSelect = false"
/>
<AchievementPanel
v-if="showAchievements"
:definitions="store.achievementDefs"
:unlocked-ids="store.unlockedAchievementIds"
@close="showAchievements = false"
/>
<EndingGallery
v-if="showEndingGallery"
:endings="store.endings"
:visited-ids="store.visitedSceneIds"
@close="showEndingGallery = false"
@select-chapter="(chId: string) => { showEndingGallery = false; recapChapterId = chId }"
/>
<ChapterRecap
v-if="recapChapterId"
:chapter="store.chapters.find(c => c.id === recapChapterId)!"
:scenes="engine.sceneManager.getScenes()"
:visited-ids="store.visitedSceneIds"
@close="recapChapterId = null"
/>
<AccessibilitySettings
v-if="store.showSettings"
@close="store.setShowSettings(false)"
/>
<div v-if="paused" class="pause-overlay" @click="togglePause">
<div class="pause-text">已暂停</div>
<div class="pause-hint">点击或按 P 继续</div>
</div>
<AchievementToast
:achievement-id="store.toastAchievementId"
:definitions="store.achievementDefs"
@done="store.clearToastAchievement()"
/>
</template>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#app {
width: 100%;
height: 100%;
}
</style>
<style scoped>
.app-container {
width: 100%;
height: 100%;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
color: #888;
}
.game-screen {
position: relative;
width: 100%;
height: 100%;
}
.top-bar {
position: absolute;
top: 16px;
right: 16px;
z-index: 20;
display: flex;
gap: 6px;
}
.top-btn {
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;
}
.top-btn:hover {
background: rgba(0, 0, 0, 0.7);
color: #fff;
}
.pause-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 150;
cursor: pointer;
}
.pause-text {
font-size: 36px;
color: #fff;
letter-spacing: 4px;
margin-bottom: 12px;
}
.pause-hint {
font-size: 14px;
color: #888;
}
.intro-overlay {
position: fixed;
inset: 0;
background: #000;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.intro-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.intro-skip-btn {
position: absolute;
bottom: 40px;
right: 40px;
padding: 10px 24px;
font-size: 14px;
color: #fff;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
cursor: pointer;
letter-spacing: 2px;
z-index: 10;
}
.intro-skip-btn:hover {
background: rgba(0, 0, 0, 0.7);
}
.menu-bg {
position: fixed;
inset: 0;
z-index: 0;
}
.menu-bg-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.prompt-toast {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 36px;
font-size: 20px;
color: #ffc107;
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 193, 7, 0.4);
border-radius: 6px;
letter-spacing: 3px;
text-align: center;
white-space: nowrap;
pointer-events: none;
z-index: 50;
}
.prompt-toast-enter-active { transition: opacity 0.3s ease; }
.prompt-toast-leave-active { transition: opacity 0.8s ease; }
.prompt-toast-enter-from,
.prompt-toast-leave-to { opacity: 0; }
</style>