545 lines
12 KiB
Vue
545 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
|
|
import { useControlInterface } from '@/composables/useControlInterface'
|
|
import { t } from '@/lib/locale'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
compact?: boolean
|
|
}>(), {
|
|
compact: false,
|
|
})
|
|
|
|
const {
|
|
activeSource,
|
|
activeSourceLabel,
|
|
commandLabel,
|
|
controlLimits,
|
|
controlInputMode,
|
|
controlInputModeLabel,
|
|
controlTuning,
|
|
commandValues,
|
|
gamepadActive,
|
|
gamepadButtons,
|
|
gamepadConnected,
|
|
gamepadIndex,
|
|
gamepadLeftStick,
|
|
gamepadMapping,
|
|
gamepadName,
|
|
gamepadRightStick,
|
|
keyboardActive,
|
|
keyboardKeys,
|
|
keyboardTurbo,
|
|
lastServerMessage,
|
|
pressedKeysLabel,
|
|
socketLabel,
|
|
socketState,
|
|
} = useControlInterface()
|
|
|
|
const keyClusters = computed(() => {
|
|
const lookup = new Map(keyboardKeys.value.map((entry) => [entry.code, entry]))
|
|
return [
|
|
lookup.get('KeyW'),
|
|
lookup.get('KeyA'),
|
|
lookup.get('KeyS'),
|
|
lookup.get('KeyD'),
|
|
lookup.get('KeyQ'),
|
|
lookup.get('KeyE'),
|
|
lookup.get('ShiftLeft'),
|
|
lookup.get('Space'),
|
|
].filter((entry): entry is NonNullable<typeof entry> => entry != null)
|
|
})
|
|
|
|
const commandBars = computed(() => [
|
|
{
|
|
label: t('controlFeedback.forward'),
|
|
value: commandValues.value.lx,
|
|
max: controlLimits.value.forward,
|
|
},
|
|
{
|
|
label: t('controlFeedback.strafe'),
|
|
value: commandValues.value.ly,
|
|
max: controlLimits.value.strafe,
|
|
},
|
|
{
|
|
label: t('controlFeedback.turn'),
|
|
value: commandValues.value.az,
|
|
max: controlLimits.value.turn,
|
|
},
|
|
])
|
|
|
|
const tuningSummary = computed(() =>
|
|
t('controlFeedback.tuningSummary', {
|
|
forward: controlTuning.value.forward.toFixed(2),
|
|
strafe: controlTuning.value.strafe.toFixed(2),
|
|
turn: controlTuning.value.turn.toFixed(2),
|
|
turbo: controlTuning.value.turbo.toFixed(2),
|
|
}),
|
|
)
|
|
|
|
const gamepadMeta = computed(() => {
|
|
if (!gamepadConnected.value) {
|
|
return t('controlFeedback.gamepadHint')
|
|
}
|
|
return t('controlFeedback.gamepadMeta', {
|
|
index: gamepadIndex.value ?? '--',
|
|
mapping: gamepadMapping.value || t('control.gamepad.unknownMapping'),
|
|
})
|
|
})
|
|
|
|
const outgoingCommandText = computed(() => t('controlFeedback.outgoingCommand', { command: commandLabel.value }))
|
|
|
|
function meterPosition(value: number, max: number) {
|
|
const normalized = Math.max(-1, Math.min(1, value / max))
|
|
return `${50 + normalized * 45}%`
|
|
}
|
|
|
|
function stickOffset(value: number) {
|
|
return `${value * 22}px`
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="feedback-shell" :class="{ compact }">
|
|
<div class="feedback-topline">
|
|
<div class="headline-stack">
|
|
<div class="source-chip" :class="activeSource">
|
|
{{ activeSourceLabel }}
|
|
</div>
|
|
<div class="input-chip">
|
|
{{ t('controlFeedback.modeChip', { mode: controlInputModeLabel }) }}
|
|
</div>
|
|
</div>
|
|
<div class="status-stack">
|
|
<span class="socket-chip" :class="socketState">{{ socketLabel }}</span>
|
|
<span class="server-text">{{ lastServerMessage }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="command-strip">
|
|
<div
|
|
v-for="bar in commandBars"
|
|
:key="bar.label"
|
|
class="command-card"
|
|
>
|
|
<div class="command-head">
|
|
<span>{{ bar.label }}</span>
|
|
<strong>{{ bar.value.toFixed(2) }}</strong>
|
|
</div>
|
|
<div class="command-meter">
|
|
<span class="center-line" />
|
|
<span class="command-dot" :style="{ left: meterPosition(bar.value, bar.max) }" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="summary">
|
|
{{ tuningSummary }}
|
|
</p>
|
|
|
|
<div class="feedback-grid" :class="{ compact }">
|
|
<section class="feedback-card">
|
|
<div class="card-head">
|
|
<div>
|
|
<p class="label">{{ t('controlFeedback.keyboard') }}</p>
|
|
<strong>{{ pressedKeysLabel }}</strong>
|
|
</div>
|
|
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
|
|
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? t('common.turbo') : t('common.selected')) : t('common.standby') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="key-grid">
|
|
<span
|
|
v-for="key in keyClusters"
|
|
:key="key.code"
|
|
class="key-chip"
|
|
:class="{ active: key.pressed, wide: key.code === 'Space' || key.code === 'ShiftLeft' }"
|
|
>
|
|
{{ key.label }}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="feedback-card">
|
|
<div class="card-head">
|
|
<div>
|
|
<p class="label">{{ t('controlFeedback.gamepad') }}</p>
|
|
<strong>{{ gamepadConnected ? gamepadName : t('controlFeedback.waitingForController') }}</strong>
|
|
</div>
|
|
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
|
|
{{
|
|
gamepadConnected
|
|
? controlInputMode === 'gamepad'
|
|
? t('common.selected')
|
|
: t('common.standby')
|
|
: t('common.offline')
|
|
}}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="subtle">
|
|
{{ gamepadMeta }}
|
|
</p>
|
|
|
|
<div class="sticks">
|
|
<div class="stick-card">
|
|
<span>{{ t('controlFeedback.leftStick') }}</span>
|
|
<div class="stick-pad">
|
|
<span class="crosshair crosshair-x" />
|
|
<span class="crosshair crosshair-y" />
|
|
<span
|
|
class="stick-dot"
|
|
:style="{
|
|
transform: `translate(${stickOffset(gamepadLeftStick.x)}, ${stickOffset(gamepadLeftStick.y)})`,
|
|
}"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stick-card">
|
|
<span>{{ t('controlFeedback.rightStick') }}</span>
|
|
<div class="stick-pad">
|
|
<span class="crosshair crosshair-x" />
|
|
<span class="crosshair crosshair-y" />
|
|
<span
|
|
class="stick-dot accent"
|
|
:style="{
|
|
transform: `translate(${stickOffset(gamepadRightStick.x)}, ${stickOffset(gamepadRightStick.y)})`,
|
|
}"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="button-grid">
|
|
<span
|
|
v-for="button in gamepadButtons"
|
|
:key="button.label"
|
|
class="button-chip"
|
|
:class="{ active: button.pressed }"
|
|
>
|
|
{{ button.label }}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<p v-if="!compact" class="summary accent">
|
|
{{ outgoingCommandText }}
|
|
</p>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.feedback-shell {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.feedback-shell.compact {
|
|
gap: 12px;
|
|
}
|
|
|
|
.feedback-topline {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: start;
|
|
}
|
|
|
|
.headline-stack {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.status-stack {
|
|
display: grid;
|
|
justify-items: end;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.source-chip,
|
|
.input-chip,
|
|
.socket-chip,
|
|
.mode-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 28px;
|
|
padding: 0 12px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.source-chip {
|
|
background: rgba(78, 224, 168, 0.16);
|
|
color: #86f0c7;
|
|
}
|
|
|
|
.source-chip.keyboard {
|
|
background: rgba(91, 122, 255, 0.18);
|
|
color: #d3dcff;
|
|
}
|
|
|
|
.source-chip.gamepad {
|
|
background: rgba(255, 176, 87, 0.18);
|
|
color: #ffd8a6;
|
|
}
|
|
|
|
.source-chip.idle {
|
|
background: rgba(133, 147, 169, 0.16);
|
|
color: #cad3e8;
|
|
}
|
|
|
|
.input-chip {
|
|
background: rgba(123, 196, 255, 0.14);
|
|
color: #dff1ff;
|
|
}
|
|
|
|
.socket-chip {
|
|
background: rgba(40, 199, 111, 0.16);
|
|
color: #7ef0b5;
|
|
}
|
|
|
|
.socket-chip.connecting,
|
|
.socket-chip.closed {
|
|
background: rgba(255, 176, 87, 0.18);
|
|
color: #ffd29b;
|
|
}
|
|
|
|
.server-text {
|
|
max-width: 320px;
|
|
color: #aeb9d2;
|
|
font-size: 12px;
|
|
text-align: right;
|
|
line-height: 1.4;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.command-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.command-card,
|
|
.feedback-card {
|
|
padding: 14px;
|
|
border-radius: 18px;
|
|
background: rgba(7, 14, 26, 0.86);
|
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
|
}
|
|
|
|
.command-head,
|
|
.card-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.command-head span,
|
|
.label {
|
|
color: #8d99b3;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.command-head strong,
|
|
.card-head strong {
|
|
color: #f6f8fc;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.command-meter {
|
|
position: relative;
|
|
height: 34px;
|
|
margin-top: 10px;
|
|
border-radius: 999px;
|
|
background: linear-gradient(90deg, rgba(255, 99, 99, 0.12), rgba(255, 255, 255, 0.05), rgba(78, 224, 168, 0.14));
|
|
border: 1px solid rgba(133, 147, 169, 0.16);
|
|
}
|
|
|
|
.center-line {
|
|
position: absolute;
|
|
top: 4px;
|
|
bottom: 4px;
|
|
left: 50%;
|
|
width: 1px;
|
|
background: rgba(222, 232, 255, 0.28);
|
|
}
|
|
|
|
.command-dot {
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 30% 30%, #fdfefe, #63e6a9 62%, #2d8e68 100%);
|
|
box-shadow: 0 0 16px rgba(99, 230, 169, 0.38);
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
.feedback-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
|
gap: 12px;
|
|
}
|
|
|
|
.feedback-grid.compact {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.subtle,
|
|
.summary {
|
|
margin: 0;
|
|
color: #8d99b3;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.summary.accent {
|
|
color: #aeb9d2;
|
|
}
|
|
|
|
.mode-chip {
|
|
background: rgba(133, 147, 169, 0.14);
|
|
color: #cad3e8;
|
|
}
|
|
|
|
.mode-chip.hot {
|
|
background: rgba(255, 176, 87, 0.18);
|
|
color: #ffd29b;
|
|
}
|
|
|
|
.key-grid,
|
|
.button-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.key-chip,
|
|
.button-chip {
|
|
min-width: 44px;
|
|
min-height: 42px;
|
|
padding: 0 12px;
|
|
border-radius: 14px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(10, 20, 37, 0.9);
|
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
|
color: #dfe7fb;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.key-chip.wide {
|
|
min-width: 88px;
|
|
}
|
|
|
|
.key-chip.active,
|
|
.button-chip.active {
|
|
background: linear-gradient(135deg, rgba(91, 122, 255, 0.28), rgba(77, 212, 172, 0.28));
|
|
border-color: rgba(123, 196, 255, 0.6);
|
|
color: #ffffff;
|
|
box-shadow: 0 8px 24px rgba(91, 122, 255, 0.22);
|
|
}
|
|
|
|
.sticks {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.stick-card {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.stick-card span {
|
|
color: #aeb9d2;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.stick-pad {
|
|
position: relative;
|
|
width: 84px;
|
|
height: 84px;
|
|
border-radius: 24px;
|
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
|
background: radial-gradient(circle at center, rgba(91, 122, 255, 0.12), rgba(4, 8, 15, 0.95));
|
|
}
|
|
|
|
.crosshair {
|
|
position: absolute;
|
|
background: rgba(222, 232, 255, 0.18);
|
|
}
|
|
|
|
.crosshair-x {
|
|
left: 14px;
|
|
right: 14px;
|
|
top: 50%;
|
|
height: 1px;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
.crosshair-y {
|
|
top: 14px;
|
|
bottom: 14px;
|
|
left: 50%;
|
|
width: 1px;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.stick-dot {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 18px;
|
|
height: 18px;
|
|
margin: -9px 0 0 -9px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 30% 30%, #f8fdff, #63e6a9 58%, #2a7e5f 100%);
|
|
box-shadow: 0 0 18px rgba(99, 230, 169, 0.35);
|
|
}
|
|
|
|
.stick-dot.accent {
|
|
background: radial-gradient(circle at 30% 30%, #fffaf4, #ffb057 58%, #b06d21 100%);
|
|
box-shadow: 0 0 18px rgba(255, 176, 87, 0.34);
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.command-strip,
|
|
.feedback-grid,
|
|
.sticks {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.status-stack {
|
|
justify-items: start;
|
|
}
|
|
|
|
.feedback-topline,
|
|
.command-head,
|
|
.card-head {
|
|
flex-direction: column;
|
|
align-items: start;
|
|
}
|
|
|
|
.server-text {
|
|
text-align: left;
|
|
}
|
|
}
|
|
</style>
|