This commit is contained in:
2026-10-18 19:47:31 +07:00
parent 7b8badd140
commit 0152b662a7
10 changed files with 375 additions and 37 deletions
+8 -23
View File
@@ -14,6 +14,7 @@ import {
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import ScreenConnectionPanel from './ScreenConnectionPanel.vue'
import GameControlPanel from './GameControlPanel.vue'
import {
DEFAULT_SERVER,
type GameMaterial,
@@ -209,8 +210,7 @@ const gameOptions = computed(() => {
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
</div>
</n-card>
<div v-if="bridge && isReady" class="game-grid">
<n-card title="Игра">
<n-card v-if="bridge && isReady" title="Материалы" data-testid="game-materials">
<template #header-extra>
<n-tag
role="status"
@@ -223,7 +223,7 @@ const gameOptions = computed(() => {
<div class="connection-content">
<div v-if="selectedKey" class="current-game">
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')">Сменить игру</n-button>
</div>
<template v-if="state.authenticated">
<div v-if="!selectedKey" class="game-selection">
@@ -390,36 +390,21 @@ const gameOptions = computed(() => {
</n-collapse>
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
</div>
</n-card>
<ScreenConnectionPanel
:state="state"
:is-busy="isBusy"
@action="run"
>
</n-card>
<GameControlPanel v-if="bridge && isReady" :state="state" :is-busy="isBusy" :selected-key="selectedKey" :selected-name="selectedName" @action="run">
<ScreenConnectionPanel :state="state" :is-busy="isBusy" @action="run">
<template v-if="state.authenticated">
<n-button
v-if="state.connection !== 'connected'"
:disabled="!canConnectCurrent || isBusy || state.busy"
@click="run({ action: 'connect' })"
>Подключиться</n-button
>
>Подключиться</n-button>
</template>
</ScreenConnectionPanel>
</div>
</GameControlPanel>
</template>
<style scoped>
.game-grid {
align-items: start;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
.game-grid > * {
min-width: 0;
}
.materials-list {
display: flex;
flex-direction: column;
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton, NCard, NSpace, NTag, useDialog } from 'naive-ui'
import type { GameCommand, OnlineIntent, OnlineState } from '../types/online'
import { gameCommands, gameStatuses } from '../utils/game-status'
const props = defineProps<{
state: OnlineState
isBusy: boolean
selectedKey: string | null
selectedName: string | null
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
const dialog = useDialog()
const game = computed(() => props.state.game?.key === props.selectedKey ? props.state.game : null)
const status = computed(() => game.value ? gameStatuses[game.value.status] : null)
const disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError)
const commands = {
start: { label: 'Начать игру', path: 'm8 5 12 7-12 7z' },
play: { label: 'Продолжить игру', path: 'm8 5 12 7-12 7z' },
pause: { label: 'Поставить игру на паузу', path: 'M8 5v14M16 5v14' },
finish: { label: 'Завершить игру', path: 'M5 21V3m0 1h14l-4 5 4 5H5' },
reset: { label: 'Сбросить игру', path: 'M4 5v6h6M4 11a8 8 0 1 1 2 7' },
}
function run(command: GameCommand) {
if (disabled.value || !game.value || !props.selectedKey) return
if (command !== 'reset') {
emit('action', { action: 'game-command', command, key: props.selectedKey })
return
}
const key = props.selectedKey
const id = game.value.id
dialog.warning({
title: 'Сбросить игру?',
content: `Игра «${game.value.name}» вернётся в черновик. Время начала и завершения будет очищено. Материалы и ответы сохранятся.`,
positiveText: 'Сбросить игру',
negativeText: 'Отмена',
onPositiveClick: () => {
if (disabled.value || props.selectedKey !== key || game.value?.id !== id) return
emit('action', { action: 'game-command', command, key })
},
})
}
</script>
<template>
<n-card title="Игра" data-testid="game-control">
<template #header-extra>
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'default'">
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
</n-tag>
</template>
<div class="game-grid">
<n-space vertical>
<span v-if="selectedKey">{{ game?.name || selectedName }}</span>
<span v-else>Выберите игру в блоке «Материалы».</span>
<n-alert v-if="state.gameError" type="warning" role="status">
{{ state.gameError }}{{ game ? ' Показано последнее полученное состояние.' : '' }}
</n-alert>
<n-space v-if="game && selectedKey" align="center">
<n-button
v-for="command in gameCommands[game.status]"
:key="command"
square
:disabled="disabled"
:type="command === 'finish' || command === 'reset' ? 'error' : 'default'"
:aria-label="commands[command].label"
:title="commands[command].label"
@click="run(command)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path :d="commands[command].path" />
</svg>
</n-button>
</n-space>
</n-space>
<slot />
</div>
</n-card>
</template>
<style scoped>
.game-grid {
align-items: start;
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
.game-grid > * {
min-width: 0;
}
</style>
+22 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NCard, NSpace, NTag } from 'naive-ui'
import { NButton, NSpace, NTag } from 'naive-ui'
import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ state: OnlineState; isBusy: boolean }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
@@ -8,8 +8,9 @@ function run(intent: OnlineIntent) {
}
</script>
<template>
<n-card title="Экран игры">
<template #header-extra>
<section class="screen-connection" aria-label="Экран игры">
<div class="screen-heading">
<span>Экран игры</span>
<n-tag
role="status"
data-testid="game-connection-status"
@@ -17,7 +18,7 @@ function run(intent: OnlineIntent) {
>
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
</n-tag>
</template>
</div>
<slot />
<n-space v-if="state.screenId" align="center">
<span>Экран №{{ state.screenId }}</span>
@@ -49,5 +50,21 @@ function run(intent: OnlineIntent) {
>Назначить звук этому экрану</n-button
>
</n-space>
</n-card>
</section>
</template>
<style scoped>
.screen-connection {
display: flex;
flex-direction: column;
gap: 12px;
}
.screen-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
}
</style>
+2
View File
@@ -7,6 +7,8 @@ export function useOnlineConnection() {
const message = useMessage()
const isReady = ref(false)
const state = ref<OnlineState>({
game: null,
gameError: '',
server: DEFAULT_SERVER,
currentGameId: null,
materials: [],
+5
View File
@@ -1,6 +1,8 @@
import type { ScreenStep } from './screen.js'
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
export type GameStatus = 'draft' | 'run' | 'pause' | 'finish'
export type GameCommand = 'start' | 'pause' | 'play' | 'finish' | 'reset'
export interface MusicPlaylist {
id: number
@@ -17,6 +19,7 @@ export interface GameMaterial {
}
export type OnlineIntent =
| { action: 'game-command'; command: GameCommand; key: string }
| { action: 'download-material'; key: string; assetId: string }
| { action: 'playlists' }
| { action: 'playlist-create'; name: string }
@@ -33,6 +36,8 @@ export type OnlineIntent =
| { action: 'select-game' | 'download' | 'check'; gameId: number }
| { action: 'remove'; key: string }
export interface OnlineState {
game: { key: string; id: number; name: string; status: GameStatus } | null
gameError: string
server: string
currentGameId: number | null
materials: GameMaterial[]
+15
View File
@@ -0,0 +1,15 @@
import type { GameCommand, GameStatus } from '../types/online.js'
export const gameStatuses = {
draft: { label: 'Черновик', type: 'default' },
run: { label: 'Идёт', type: 'success' },
pause: { label: 'Пауза', type: 'warning' },
finish: { label: 'Завершена', type: 'info' },
} as const
export const gameCommands: Record<GameStatus, GameCommand[]> = {
draft: ['start', 'finish'],
run: ['pause', 'finish', 'reset'],
pause: ['play', 'finish', 'reset'],
finish: ['reset'],
}