215 lines
4.9 KiB
Vue
215 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import type { Choice } from '@engine/types'
|
|
import { useI18n } from '@/composables/useI18n'
|
|
import { useGameStore } from '@/stores/gameStore'
|
|
|
|
const props = defineProps<{
|
|
choices: Choice[]
|
|
timerTotal: number
|
|
timerRemaining: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
choose: [index: number]
|
|
prompt: [text: string]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const store = useGameStore()
|
|
|
|
function choiceText(choice: Choice): string {
|
|
if (choice.textKey) {
|
|
const translated = t(choice.textKey)
|
|
if (translated !== choice.textKey) return translated
|
|
}
|
|
return choice.text.trim() || choice.textKey || ''
|
|
}
|
|
const focusIndex = ref(0)
|
|
const btnRefs = ref<(HTMLButtonElement | null)[]>([])
|
|
const choiceEnabled = ref(!store.antiMistap)
|
|
let enableTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
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 ''
|
|
}
|
|
|
|
function setRef(el: HTMLButtonElement | null, index: number) {
|
|
btnRefs.value[index] = el
|
|
}
|
|
|
|
watch(() => props.choices.length, async (len) => {
|
|
if (len > 0) {
|
|
if (store.antiMistap) {
|
|
choiceEnabled.value = false
|
|
if (enableTimer) clearTimeout(enableTimer)
|
|
enableTimer = setTimeout(() => { choiceEnabled.value = true }, 500)
|
|
} else {
|
|
choiceEnabled.value = true
|
|
}
|
|
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()
|
|
handleChoose(index)
|
|
}
|
|
}
|
|
|
|
function handleChoose(index: number) {
|
|
if (!choiceEnabled.value) return
|
|
const choice = props.choices[index]
|
|
if (choice?.prompt) {
|
|
emit('prompt', t(choice.promptKey || choice.prompt))
|
|
}
|
|
emit('choose', index)
|
|
}
|
|
</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">{{ t('ui.choose') }}</div>
|
|
<div class="choice-list" :class="{ disabled: !choiceEnabled }">
|
|
<button
|
|
v-for="(choice, index) in choices"
|
|
:key="index"
|
|
:ref="(el: any) => setRef(el, index)"
|
|
:class="['choice-btn', { 'has-prompt': !!choice.prompt }]"
|
|
tabindex="0"
|
|
@click="handleChoose(index)"
|
|
@keydown="onKeydown($event, index)"
|
|
>
|
|
{{ choiceText(choice) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.choice-panel {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
|
|
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;
|
|
text-align: center;
|
|
margin-bottom: 16px;
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
.choice-list {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.choice-list.disabled {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.choice-btn {
|
|
position: relative;
|
|
flex: 1;
|
|
min-width: 120px;
|
|
padding: 14px 24px;
|
|
font-size: 16px;
|
|
color: #fff;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: background 0.2s, border-color 0.2s, box-shadow 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);
|
|
}
|
|
|
|
.choice-btn.has-prompt {
|
|
border-left: 3px solid #ffc107;
|
|
border-color: rgba(255, 193, 7, 0.4);
|
|
border-left-color: #ffc107;
|
|
}
|
|
|
|
.choice-btn.has-prompt:hover {
|
|
border-color: rgba(255, 193, 7, 0.7);
|
|
box-shadow: 0 0 12px rgba(255, 193, 7, 0.25);
|
|
}
|
|
|
|
.choice-btn.has-prompt:focus-visible {
|
|
border-color: rgba(255, 193, 7, 0.7);
|
|
box-shadow: 0 0 12px rgba(255, 193, 7, 0.3);
|
|
}
|
|
</style>
|