feat: UI polish, chapter select improvements, save system enhancements, roadmap update
This commit is contained in:
33
src/App.vue
33
src/App.vue
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
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'
|
||||
@@ -97,6 +97,37 @@ watch(() => store.currentScene?.id, async (newId) => {
|
||||
canSkip.value = await isSceneWatched(newId)
|
||||
})
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
const key = e.key
|
||||
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 (showChapterSelect.value) {
|
||||
showChapterSelect.value = false
|
||||
} else if (showMenu.value) {
|
||||
showMenu.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalMouseMove() {
|
||||
store.setInputMode('mouse')
|
||||
}
|
||||
|
||||
const keyboardKeys = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Tab', ' ', 'w', 'a', 's', 'd']
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onGlobalKeydown)
|
||||
document.addEventListener('mousemove', onGlobalMouseMove)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onGlobalKeydown)
|
||||
document.removeEventListener('mousemove', onGlobalMouseMove)
|
||||
})
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import type { ChapterInfo } from '@engine/types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
chapters: ChapterInfo[]
|
||||
unlockedIds: Set<string>
|
||||
}>()
|
||||
@@ -10,20 +11,67 @@ const emit = defineEmits<{
|
||||
select: [chapterId: string]
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const focusIdx = ref(0)
|
||||
const cardRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
|
||||
function setRef(el: HTMLDivElement | null, i: number) {
|
||||
cardRefs.value[i] = el
|
||||
}
|
||||
|
||||
// when shown, focus first unlocked
|
||||
watch(() => props.chapters.length, async (len) => {
|
||||
if (len > 0) {
|
||||
const first = props.chapters.findIndex(ch => props.unlockedIds.has(ch.id))
|
||||
focusIdx.value = first >= 0 ? first : 0
|
||||
await nextTick()
|
||||
cardRefs.value[focusIdx.value]?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1
|
||||
const len = props.chapters.length
|
||||
let next = (index + dir + len) % len
|
||||
// skip locked ones
|
||||
let tries = 0
|
||||
while (!props.unlockedIds.has(props.chapters[next].id) && tries < len) {
|
||||
next = (next + dir + len) % len
|
||||
tries++
|
||||
}
|
||||
focusIdx.value = next
|
||||
cardRefs.value[next]?.focus()
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (props.unlockedIds.has(props.chapters[index].id)) {
|
||||
emit('select', props.chapters[index].id)
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
emit('back')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chapter-overlay">
|
||||
<div class="chapter-overlay" @keydown="(e: KeyboardEvent) => { if (e.key === 'Escape' || e.key === 'Backspace') { e.preventDefault(); emit('back'); } }">
|
||||
<div class="chapter-panel">
|
||||
<h2 class="chapter-title">章节选择</h2>
|
||||
|
||||
<div class="chapter-grid">
|
||||
<div
|
||||
v-for="ch in chapters"
|
||||
v-for="(ch, i) in chapters"
|
||||
:key="ch.id"
|
||||
:ref="(el: any) => setRef(el, i)"
|
||||
class="chapter-card"
|
||||
:class="{ locked: !unlockedIds.has(ch.id) }"
|
||||
:tabindex="unlockedIds.has(ch.id) ? 0 : -1"
|
||||
@click="unlockedIds.has(ch.id) && emit('select', ch.id)"
|
||||
@keydown="onKeydown($event, i)"
|
||||
>
|
||||
<div class="chapter-thumb">
|
||||
<img v-if="ch.thumbnail" :src="ch.thumbnail" class="thumb-img" />
|
||||
@@ -34,7 +82,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="back-btn" @click="emit('back')">返回</button>
|
||||
<button class="back-btn" @click="emit('back')">返回 (Esc)</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,6 +134,7 @@ const emit = defineEmits<{
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, transform 0.15s;
|
||||
width: 150px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chapter-card:hover:not(.locked) {
|
||||
@@ -94,6 +143,13 @@ const emit = defineEmits<{
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chapter-card:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chapter-card.locked {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import type { Choice } from '@engine/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -11,6 +12,9 @@ const emit = defineEmits<{
|
||||
choose: [index: number]
|
||||
}>()
|
||||
|
||||
const focusIndex = ref(0)
|
||||
const btnRefs = ref<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
function timerPercent(): number {
|
||||
if (props.timerTotal <= 0) return 0
|
||||
return (props.timerRemaining / props.timerTotal) * 100
|
||||
@@ -20,6 +24,32 @@ function timerClass(): string {
|
||||
if (props.timerRemaining <= 3) return 'danger'
|
||||
return ''
|
||||
}
|
||||
|
||||
function setRef(el: HTMLButtonElement | null, index: number) {
|
||||
btnRefs.value[index] = el
|
||||
}
|
||||
|
||||
watch(() => props.choices.length, async (len) => {
|
||||
if (len > 0) {
|
||||
focusIndex.value = 0
|
||||
await nextTick()
|
||||
btnRefs.value[0]?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1
|
||||
const next = Math.max(0, Math.min(props.choices.length - 1, index + dir))
|
||||
focusIndex.value = next
|
||||
btnRefs.value[next]?.focus()
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
emit('choose', index)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,8 +67,11 @@ function timerClass(): string {
|
||||
<button
|
||||
v-for="(choice, index) in choices"
|
||||
:key="index"
|
||||
:ref="(el: any) => setRef(el, index)"
|
||||
class="choice-btn"
|
||||
tabindex="0"
|
||||
@click="emit('choose', index)"
|
||||
@keydown="onKeydown($event, index)"
|
||||
>
|
||||
{{ choice.text }}
|
||||
</button>
|
||||
@@ -110,10 +143,17 @@ function timerClass(): string {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.choice-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.choice-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@ const maxSlots = 5
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="save-overlay" @click.self="emit('close')">
|
||||
<div class="save-overlay" @click.self="emit('close')" @keydown.escape="emit('close')">
|
||||
<div class="save-panel">
|
||||
<h2 class="save-title">存档 / 读档</h2>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
const videoTime = ref(0)
|
||||
const hotspots = ref<Hotspot[]>([])
|
||||
const isImageScene = ref(false)
|
||||
const inputMode = ref<'mouse' | 'keyboard'>('mouse')
|
||||
const showChapterSelect = ref(false)
|
||||
const chapters = ref<ChapterInfo[]>([])
|
||||
const unlockedChapterIds = ref<Set<string>>(new Set())
|
||||
@@ -104,6 +105,10 @@ export const useGameStore = defineStore('game', () => {
|
||||
isImageScene.value = val
|
||||
}
|
||||
|
||||
function setInputMode(mode: 'mouse' | 'keyboard') {
|
||||
inputMode.value = mode
|
||||
}
|
||||
|
||||
function setChapters(list: ChapterInfo[]) {
|
||||
chapters.value = list
|
||||
}
|
||||
@@ -137,10 +142,12 @@ export const useGameStore = defineStore('game', () => {
|
||||
currentScene, choices, gameEnded, timerTotal, timerRemaining, saves,
|
||||
qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime,
|
||||
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
|
||||
inputMode,
|
||||
setScene, setChoices, clearChoices, setGameEnded,
|
||||
setTimer, clearTimer, setSaves,
|
||||
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
||||
setHotspots, clearHotspots, setIsImageScene,
|
||||
setInputMode,
|
||||
setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect,
|
||||
dump,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user