Files
evening_detective_frontend/src/components/GameMapPanel.vue
T
2026-10-05 01:36:30 +07:00

660 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NButton, NFlex, NInput, NInputNumber, NSelect, NSpace, NText } from 'naive-ui'
import { computed, onBeforeUnmount, ref } from 'vue'
import type {
Application,
MapItem,
ResultsQuestion,
ScreenCommand,
ScreenMode,
ScreenPlan,
ScreenPlanStep,
ScreenState,
Team,
TeamResult,
} from '@/api/generated/crabs/evening_detective_server'
import ScreenPreview from '@/components/ScreenPreview.vue'
import UiBlock from '@/components/UiBlock.vue'
const props = defineProps<{
mapItems: MapItem[]
teams: Team[]
screens: ScreenState[]
previewQuestions: ResultsQuestion[]
previewTeams: TeamResult[]
screenPlan?: ScreenPlan
screenPlanDraft: ScreenPlanStep[]
screenPlanDirty: boolean
screenPlanSaving: boolean
screenPlanMode: 'use' | 'edit'
screenVolume: number
screenPosition: number
}>()
const GRID_SIZE = 20
const emit = defineEmits<{
saveItem: [item: MapItem, hidden?: boolean]
dragging: [value: boolean]
giveApplication: [team: Team, application: Application]
addStep: []
edit: []
cancel: []
removeStep: [index: number]
moveStep: [index: number, offset: number]
dirty: []
clearPlan: []
save: []
setStep: [index: number]
command: [mode: ScreenMode | undefined, command: ScreenCommand]
'update:screenVolume': [value: number | null]
'update:screenPosition': [value: number | null]
}>()
const mapScale = ref(1)
const mapOffset = ref({ x: 0, y: 0 })
const mapField = ref<HTMLElement>()
const previewScreen = computed(
() => props.screens.find((screen) => screen.status === 'connected') || props.screens[0],
)
const teamsNeedingApplications = computed(
() =>
new Map(props.teams.filter((team) => team.applications?.length).map((team) => [team.ID, team])),
)
const screenPlanModeOptions = [
{ label: 'Обложка', value: 'SCREEN_MODE_COVER' },
{ label: 'Введение', value: 'SCREEN_MODE_INTRO' },
{ label: 'Успехов', value: 'SCREEN_MODE_INTRO_DONE' },
{ label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' },
{ label: 'Спасибо за игру', value: 'SCREEN_MODE_CONCLUSION_DONE' },
{ label: 'Результаты', value: 'SCREEN_MODE_RESULTS' },
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' },
]
let dragState:
{ item: MapItem; offsetX: number; offsetY: number } | undefined
let panState: { startX: number; startY: number; offsetX: number; offsetY: number } | undefined
function getMapCoordinate(event: PointerEvent) {
const field = mapField.value
if (!field) return
const rect = field.getBoundingClientRect()
return {
x: (event.clientX - rect.left - field.clientLeft - mapOffset.value.x) / mapScale.value,
y: (event.clientY - rect.top - field.clientTop - mapOffset.value.y) / mapScale.value,
}
}
function startMapDrag(item: MapItem, event: PointerEvent) {
const point = getMapCoordinate(event)
if (!point) return
dragState = {
item,
offsetX: point.x - (item.x || 0),
offsetY: point.y - (item.y || 0),
}
emit('dragging', true)
window.addEventListener('pointermove', moveMapDrag)
window.addEventListener('pointerup', endMapDrag, { once: true })
window.addEventListener('pointercancel', endMapDrag, { once: true })
}
function moveMapDrag(event: PointerEvent) {
if (!dragState) return
const point = getMapCoordinate(event)
if (!point) return
dragState.item.x = Math.round((point.x - dragState.offsetX) / GRID_SIZE) * GRID_SIZE
dragState.item.y = Math.round((point.y - dragState.offsetY) / GRID_SIZE) * GRID_SIZE
}
function endMapDrag() {
if (!dragState) return
const item = dragState.item
dragState = undefined
window.removeEventListener('pointermove', moveMapDrag)
window.removeEventListener('pointerup', endMapDrag)
window.removeEventListener('pointercancel', endMapDrag)
emit('dragging', false)
emit('saveItem', item)
}
function toggleMapItem(item: MapItem) {
item.hidden = !item.hidden
emit('saveItem', item, item.hidden)
}
function changeMapScale(delta: number) {
mapScale.value = Math.min(2, Math.max(0.5, Number((mapScale.value + delta).toFixed(2))))
}
function resetMapView() {
mapScale.value = 1
mapOffset.value = { x: 0, y: 0 }
}
function startMapPan(event: PointerEvent) {
if (event.button !== 0) return
panState = {
startX: event.clientX,
startY: event.clientY,
offsetX: mapOffset.value.x,
offsetY: mapOffset.value.y,
}
window.addEventListener('pointermove', moveMapPan)
window.addEventListener('pointerup', endMapPan, { once: true })
}
function moveMapPan(event: PointerEvent) {
if (!panState) return
mapOffset.value = {
x: panState.offsetX + event.clientX - panState.startX,
y: panState.offsetY + event.clientY - panState.startY,
}
}
function endMapPan() {
panState = undefined
window.removeEventListener('pointermove', moveMapPan)
}
function zoomMap(event: WheelEvent) {
event.preventDefault()
changeMapScale(event.deltaY < 0 ? 0.1 : -0.1)
}
onBeforeUnmount(() => {
if (dragState) emit('dragging', false)
window.removeEventListener('pointermove', moveMapDrag)
window.removeEventListener('pointerup', endMapDrag)
window.removeEventListener('pointercancel', endMapDrag)
window.removeEventListener('pointermove', moveMapPan)
window.removeEventListener('pointerup', endMapPan)
})
</script>
<template>
<div class="map-preview-layout">
<UiBlock>
<n-flex align="center" justify="space-between" class="screen-header">
<p class="screens-title">Карта помещения</p>
<n-space>
<n-button size="small" @click="changeMapScale(-0.1)">−</n-button>
<n-text>{{ Math.round(mapScale * 100) }}%</n-text>
<n-button size="small" @click="changeMapScale(0.1)">+</n-button>
<n-button size="small" @click="resetMapView">Сбросить</n-button>
</n-space>
</n-flex>
<div ref="mapField" class="map-field" @pointerdown="startMapPan" @wheel="zoomMap">
<div
class="map-canvas"
:style="{ transform: `translate(${mapOffset.x}px, ${mapOffset.y}px) scale(${mapScale})` }"
>
<div
v-for="item in mapItems"
v-show="!item.hidden"
:key="`${item.kind}-${item.itemID}`"
class="map-item"
:class="[
item.kind === 'screen'
? 'map-screen'
: item.kind === 'organizer_desk'
? 'map-organizer-desk'
: 'map-team',
{
'map-team-needs-applications':
item.kind === 'team' && teamsNeedingApplications.has(item.itemID),
},
]"
:style="{ left: `${item.x || 0}px`, top: `${item.y || 0}px` }"
@pointerdown.stop="item.kind !== 'team' && startMapDrag(item, $event)"
>
<div
v-if="item.kind === 'team'"
class="map-team-handle"
@pointerdown.stop="startMapDrag(item, $event)"
/>
<span>{{ item.label }}</span>
<n-space
v-if="item.kind === 'team' && teamsNeedingApplications.has(item.itemID)"
class="map-team-applications"
vertical
>
<n-button
v-for="application in teamsNeedingApplications.get(item.itemID)?.applications"
:key="application.name"
size="small"
@click="
emit('giveApplication', teamsNeedingApplications.get(item.itemID)!, application)
"
>
Выдать: {{ application.name }}
</n-button>
</n-space>
<small v-if="item.kind === 'screen'"
>{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small
>
<button
class="map-item-hide"
type="button"
@pointerdown.stop
@click.stop="toggleMapItem(item)"
>
×
</button>
</div>
</div>
</div>
<n-space v-if="mapItems.some((item) => item.hidden)" class="map-hidden-items">
<n-button
v-for="item in mapItems.filter((item) => item.hidden)"
:key="`hidden-${item.kind}-${item.itemID}`"
size="small"
@click="toggleMapItem(item)"
>
Добавить {{ item.label }}
</n-button>
</n-space>
</UiBlock>
<UiBlock tag="aside" class="screen-preview-panel">
<p class="screens-title">Превью экрана</p>
<ScreenPreview
v-if="previewScreen?.status === 'connected'"
:state="previewScreen"
:questions="previewQuestions"
:teams="previewTeams"
/>
<div v-else class="screen-preview-placeholder">Экран не подключён</div>
<div class="screen-plan">
<n-flex align="center" justify="space-between" class="screen-header">
<p class="screens-title">План экранов</p>
<n-space>
<n-button
v-if="!screenPlan && screenPlanMode === 'use'"
size="small"
@click="emit('addStep')"
>
Создать план
</n-button>
<n-button
v-if="screenPlan && screenPlanMode === 'use'"
size="small"
@click="emit('edit')"
>
Редактировать
</n-button>
<template v-if="screenPlanMode === 'edit'">
<n-button size="small" :disabled="screenPlanSaving" @click="emit('cancel')">
Отменить
</n-button>
<n-button
size="small"
type="primary"
:loading="screenPlanSaving"
:disabled="screenPlanSaving"
@click="emit('save')"
>
Сохранить план
</n-button>
</template>
</n-space>
</n-flex>
<p v-if="!screenPlan && screenPlanMode === 'use'" class="screen-empty">
План экранов ещё не создан.
</p>
<template v-else-if="screenPlanMode === 'edit'">
<n-space
v-for="(step, index) in screenPlanDraft"
:key="index"
class="screen-plan-step"
align="center"
justify="space-between"
wrap
>
<n-space align="center" wrap>
<n-text>{{ index + 1 }}</n-text>
<n-select
v-model:value="step.mode"
:options="screenPlanModeOptions"
:disabled="screenPlanSaving"
size="small"
style="width: 180px"
@update:value="emit('dirty')"
/>
<n-input
v-if="step.mode === 'SCREEN_MODE_TEXT'"
v-model:value="step.text"
:disabled="screenPlanSaving"
size="small"
placeholder="Текст экрана"
style="min-width: 240px"
@update:value="emit('dirty')"
/>
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
<n-input
v-model:value="step.timerLabel"
:disabled="screenPlanSaving"
size="small"
placeholder="Надпись таймера"
@update:value="emit('dirty')"
/>
<n-input-number
v-model:value="step.timerDurationSeconds"
:min="1"
:max="86400"
:disabled="screenPlanSaving"
size="small"
placeholder="Секунды"
@update:value="emit('dirty')"
/>
</template>
</n-space>
<n-space class="screen-plan-actions">
<n-button
size="small"
:disabled="screenPlanSaving || index === 0"
@click="emit('moveStep', index, -1)"
>
↑
</n-button>
<n-button
size="small"
:disabled="screenPlanSaving || index === screenPlanDraft.length - 1"
@click="emit('moveStep', index, 1)"
>
↓
</n-button>
<n-button
size="small"
type="error"
ghost
:disabled="screenPlanSaving"
@click="emit('removeStep', index)"
>
Удалить
</n-button>
</n-space>
</n-space>
<n-space>
<n-button size="small" :disabled="screenPlanSaving" @click="emit('addStep')">
Добавить шаг
</n-button>
<n-button
v-if="screenPlanDraft.length"
size="small"
:disabled="screenPlanSaving"
@click="emit('clearPlan')"
>
Удалить план
</n-button>
</n-space>
</template>
<template v-else-if="screenPlan">
<n-space
v-for="(step, index) in screenPlan.steps || []"
:key="index"
class="screen-plan-step"
align="center"
justify="space-between"
wrap
>
<n-space align="center">
<n-text>{{ index + 1 }}</n-text>
<n-text>
{{ screenPlanModeOptions.find((option) => option.value === step.mode)?.label }}
<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template>
<template v-else-if="step.mode === 'SCREEN_MODE_TIMER'">
: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)
</template>
</n-text>
</n-space>
<n-space class="screen-plan-actions">
<n-button
size="small"
:type="screenPlan.currentStep === index ? 'primary' : 'default'"
:disabled="screenPlan.currentStep === index"
@click="emit('setStep', index)"
>
Показать
</n-button>
<template v-if="screenPlan.currentStep === index">
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_START')"
>
Старт
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_PAUSE')"
>
Пауза
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESUME')"
>
Продолжить
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESET')"
>
Сбросить
</n-button>
</template>
<template
v-else-if="
step.mode === 'SCREEN_MODE_INTRO' || step.mode === 'SCREEN_MODE_CONCLUSION'
"
>
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PLAY')">
Запуск
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_PAUSE')"
>
Пауза
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_RESUME')"
>
Продолжить
</n-button>
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_STOP')">
Стоп
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_REPLAY')"
>
Повтор
</n-button>
<n-input-number
:value="screenVolume"
:min="0"
:max="100"
:step="5"
size="small"
@update:value="emit('update:screenVolume', $event)"
/>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_VOLUME')"
>
Громкость
</n-button>
<n-input-number
:value="screenPosition"
:min="0"
:step="5"
size="small"
@update:value="emit('update:screenPosition', $event)"
/>
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_SEEK')">
Перемотать
</n-button>
</template>
</template>
</n-space>
</n-space>
</template>
</div>
</UiBlock>
</div>
</template>
<style scoped>
.map-preview-layout {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
}
.screens-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.screen-header {
margin-bottom: 8px;
}
.screen-preview-placeholder {
align-items: center;
aspect-ratio: 16 / 9;
background-color: #111;
color: #aaa;
display: flex;
justify-content: center;
text-align: center;
}
.screen-preview-panel > .screens-title {
margin-bottom: 8px;
}
.screen-plan {
margin-top: 16px;
}
.screen-plan-step {
border-top: 1px solid rgb(255 255 255 / 10%);
padding: 8px 0;
}
.map-field {
aspect-ratio: 1;
background-color: #151515;
border: 1px dashed rgb(255 255 255 / 20%);
cursor: grab;
height: auto;
min-height: 200px;
overflow: hidden;
position: relative;
resize: vertical;
touch-action: none;
}
.map-field:active {
cursor: grabbing;
}
.map-canvas {
background-image:
linear-gradient(rgb(255 255 255 / 8%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 8%) 1px, transparent 1px);
background-size: 20px 20px;
height: 1000px;
position: absolute;
transform-origin: 0 0;
width: 2000px;
}
.map-item {
border-radius: 6px;
cursor: grab;
min-width: 90px;
padding: 8px 24px 8px 8px;
position: absolute;
user-select: none;
}
.map-item:active {
cursor: grabbing;
}
.map-screen {
background-color: #355b75;
}
.map-organizer-desk {
background-color: #48633a;
}
.map-team {
background-color: #222;
}
.map-team,
.map-team:active {
cursor: default;
}
.map-team-handle {
background-color: #48633a;
border-radius: 6px 6px 0 0;
cursor: grab;
margin: -8px -24px 8px -8px;
padding: 8px 24px 8px 8px;
}
.map-team-handle:active {
cursor: grabbing;
}
.map-team-applications {
margin-top: 8px;
}
.map-team-needs-applications {
outline: 2px solid gold;
}
.map-item small {
display: block;
opacity: 0.75;
}
.map-item-hide {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font-size: 16px;
position: absolute;
right: 5px;
top: 5px;
}
.map-team .map-item-hide {
line-height: 16px;
padding: 0 2px;
top: 0;
}
.map-hidden-items {
margin-top: 16px;
}
@media (width < 1024px) {
.map-preview-layout {
grid-template-columns: 1fr;
}
}
</style>