flugzeug von hinten

This commit is contained in:
itsrubberduck
2026-02-20 23:59:09 +01:00
parent b83ccf2c7c
commit 6f73f37caf
3 changed files with 130 additions and 53 deletions

View File

@@ -5,6 +5,7 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
const props = defineProps<{
pitch: number
@@ -15,6 +16,7 @@ const props = defineProps<{
}>()
const containerRef = ref<HTMLDivElement | null>(null)
const modelUrls = ['/models/airplane-user.glb', '/models/Airplane.glb', '/models/a320.glb']
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
@@ -23,6 +25,7 @@ let aircraft: THREE.Group | null = null
let cloudPlane: THREE.Mesh | null = null
let animFrameId: number | null = null
let resizeObserver: ResizeObserver | null = null
let disposed = false
// Reactive targets for smooth interpolation
let targetPitchRad = 0
@@ -90,6 +93,49 @@ function buildAircraft(): THREE.Group {
return group
}
function centerAndScaleModel(model: THREE.Object3D, targetSize = 5) {
const box = new THREE.Box3().setFromObject(model)
const size = new THREE.Vector3()
box.getSize(size)
const maxDim = Math.max(size.x, size.y, size.z)
if (maxDim > 0) {
const scale = targetSize / maxDim
model.scale.setScalar(scale)
}
const centeredBox = new THREE.Box3().setFromObject(model)
const center = new THREE.Vector3()
centeredBox.getCenter(center)
model.position.sub(center)
// glTF is usually +Z forward; our scene uses -Z as nose-forward.
model.rotation.y = Math.PI
}
async function loadAircraftModel() {
if (!aircraft || disposed) return
const loader = new GLTFLoader()
for (const url of modelUrls) {
try {
const gltf = await loader.loadAsync(url)
if (!aircraft || disposed) return
const model = gltf.scene
centerAndScaleModel(model, 5)
aircraft.clear()
aircraft.add(model)
return
} catch {
// Try next model URL
}
}
if (!aircraft || disposed) return
aircraft.clear()
aircraft.add(buildAircraft())
}
function initScene() {
const container = containerRef.value
if (!container) return
@@ -116,8 +162,8 @@ function initScene() {
directionalLight.position.set(5, 10, 5)
scene.add(directionalLight)
// Aircraft
aircraft = buildAircraft()
// Aircraft (root group; model is loaded asynchronously)
aircraft = new THREE.Group()
scene.add(aircraft)
// Cloud plane
@@ -187,12 +233,15 @@ watch(
)
onMounted(() => {
disposed = false
initScene()
updateTargets()
loadAircraftModel()
animate()
})
onBeforeUnmount(() => {
disposed = true
// Cancel animation frame
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)

View File

@@ -128,11 +128,11 @@
</div>
</div>
<div class="flex items-center gap-3">
<!-- Session Code for touchscreen connection -->
<div v-if="sync.sessionCode.value" class="flex items-center gap-2 rounded-lg border border-cyan-400/20 bg-cyan-500/10 px-3 py-1.5">
<!-- Touch input connection status -->
<div v-if="sync.isConnected.value" class="flex items-center gap-2 rounded-lg border border-cyan-400/20 bg-cyan-500/10 px-3 py-1.5">
<v-icon icon="mdi-gamepad-variant" size="16" class="text-cyan-400/70" />
<span class="text-xs text-white/50">Input:</span>
<code class="text-sm font-mono font-bold text-cyan-300 tracking-widest">{{ sync.sessionCode.value }}</code>
<span class="text-xs font-semibold text-cyan-200">Direkt verbunden</span>
<v-icon
v-if="sync.isConnected.value"
icon="mdi-wifi"

View File

@@ -28,36 +28,23 @@
<v-icon icon="mdi-wifi" size="36" class="text-cyan-300" />
</div>
<div>
<h2 class="text-xl font-semibold mb-2">Verbindung herstellen</h2>
<h2 class="text-xl font-semibold mb-2">Verbinde mit FlightLab</h2>
<p class="text-sm text-white/50">
Gib den 4-stelligen Code ein, der auf dem PFD-Bildschirm angezeigt wird.
Kein Pairing-Code nötig. Diese Steuerung verbindet sich direkt mit der aktiven Instanz.
</p>
<p v-if="connectionError" class="mt-2 text-sm text-red-300/90">{{ connectionError }}</p>
</div>
<div class="space-y-3">
<v-text-field
v-model="sessionCodeInput"
variant="outlined"
density="compact"
placeholder="CODE"
maxlength="4"
class="session-code-input"
:error-messages="connectionError"
hide-details="auto"
@keydown.enter="connectToSession"
/>
<v-btn
color="primary"
variant="flat"
size="large"
block
class="rounded-xl font-semibold"
:loading="isConnecting"
:disabled="sessionCodeInput.length < 4"
@click="connectToSession"
>
Verbinden
</v-btn>
</div>
<v-btn
color="primary"
variant="flat"
size="large"
block
class="rounded-xl font-semibold"
:loading="isConnecting"
@click="connectToSession"
>
Erneut verbinden
</v-btn>
</div>
</div>
@@ -65,30 +52,46 @@
<div v-else class="flex-1 flex gap-2 p-3">
<!-- Throttle (left side, vertical slider) -->
<div class="w-20 flex flex-col items-center gap-2">
<span class="text-[10px] uppercase tracking-widest text-white/30">Thrust (max 70%)</span>
<span class="text-[10px] uppercase tracking-widest text-white/30">Thrust</span>
<div
ref="throttleTrack"
class="flex-1 w-16 rounded-2xl border border-white/10 bg-[#0b1328]/90 relative overflow-hidden cursor-pointer"
class="flex-1 w-16 rounded-2xl border bg-[#0b1328]/90 relative overflow-hidden cursor-pointer transition-all duration-150"
:class="isThrottleOverLimit ? 'border-red-400/70 shadow-[0_0_0_1px_rgba(248,113,113,0.35)]' : 'border-white/10'"
@pointerdown="onThrottlePointerDown"
@pointermove="onThrottlePointerMove"
@pointerup="onThrottlePointerUp"
@pointercancel="onThrottlePointerUp"
>
<!-- Recommended max marker (70%) -->
<div
class="absolute left-0 right-0 border-t border-cyan-200/40"
:style="{ bottom: `${RECOMMENDED_THRUST * 100}%` }"
/>
<!-- Fill -->
<div
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-amber-500/80 to-amber-400/40 transition-[height] duration-75"
class="absolute bottom-0 left-0 right-0 transition-[height] duration-75"
:class="isThrottleOverLimit ? 'bg-gradient-to-t from-red-600/90 to-red-400/80 throttle-overlimit' : 'bg-gradient-to-t from-amber-500/80 to-amber-400/40'"
:style="{ height: `${throttleUiPercent}%` }"
/>
<!-- Handle -->
<div
class="absolute left-1/2 -translate-x-1/2 w-12 h-3 rounded-full bg-white/80 border border-white/30 shadow-lg"
class="absolute left-1/2 -translate-x-1/2 w-12 h-3 rounded-full border shadow-lg transition-colors duration-150"
:class="isThrottleOverLimit ? 'bg-red-200 border-red-300/90' : 'bg-white/80 border-white/30'"
:style="{ bottom: `calc(${throttleUiPercent}% - 6px)` }"
/>
<!-- Label -->
<div class="absolute bottom-2 left-0 right-0 text-center">
<span class="text-xs font-mono font-bold text-white/70">{{ Math.round(throttle * 100) }}%</span>
<span class="text-xs font-mono font-bold" :class="isThrottleOverLimit ? 'text-red-100' : 'text-white/70'">{{ Math.round(throttle * 100) }}%</span>
</div>
<div class="absolute top-1 left-0 right-0 text-center">
<span class="text-[10px] font-mono text-cyan-100/65">70%</span>
</div>
</div>
<p v-if="isThrottleOverLimit" class="text-[10px] font-semibold uppercase tracking-wide text-red-300 throttle-warning-text">
Zu viel Schub
</p>
<p v-else class="text-[10px] text-white/35">Empfohlen bis 70%</p>
</div>
<!-- Sidestick (center, 2D pad) -->
@@ -144,17 +147,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
definePageMeta({ layout: false })
useHead({ title: 'FlightLab - Stick Input' })
const sync = useFlightLabSync()
const MAX_TRAINING_THROTTLE = 0.7
const RECOMMENDED_THRUST = 0.7
// --- Connection state ---
const sessionCodeInput = ref('')
const isConnecting = ref(false)
const isConnected = ref(false)
const connectionError = ref('')
@@ -162,10 +164,11 @@ const connectionError = ref('')
// --- Stick state ---
const stickX = ref(0) // -1 (left) to +1 (right) = roll
const stickY = ref(0) // -1 (forward/push/nose down) to +1 (back/pull/nose up)
const throttle = ref(0) // 0 (idle) to 0.7 (training max)
const throttle = ref(0) // 0 (idle) to 1 (TOGA)
const stickActive = ref(false)
const throttleActive = ref(false)
const throttleUiPercent = computed(() => (throttle.value / MAX_TRAINING_THROTTLE) * 100)
const throttleUiPercent = computed(() => throttle.value * 100)
const isThrottleOverLimit = computed(() => throttle.value > RECOMMENDED_THRUST)
// --- Refs ---
const stickPad = ref<HTMLElement | null>(null)
@@ -175,14 +178,19 @@ const throttleTrack = ref<HTMLElement | null>(null)
let sendInterval: ReturnType<typeof setInterval> | null = null
async function connectToSession() {
if (sessionCodeInput.value.length < 4) return
if (isConnecting.value) return
isConnecting.value = true
connectionError.value = ''
try {
await sync.joinSession(sessionCodeInput.value, 'participant')
await sync.joinGlobalSession('participant', 'learn-pfd')
isConnected.value = true
if (sendInterval) {
clearInterval(sendInterval)
sendInterval = null
}
// Start sending stick input at 30Hz
sendInterval = setInterval(() => {
sync.sendStickInput({
@@ -192,7 +200,8 @@ async function connectToSession() {
})
}, 33)
} catch (e) {
connectionError.value = 'Verbindung fehlgeschlagen. Code korrekt?'
isConnected.value = false
connectionError.value = 'Verbindung fehlgeschlagen. Bitte erneut versuchen.'
console.error('[stick-input] Connection failed:', e)
} finally {
isConnecting.value = false
@@ -255,10 +264,14 @@ function updateThrottlePosition(e: PointerEvent) {
const rect = track.getBoundingClientRect()
// Bottom = 0, top = 1
const rawThrottle = 1 - (e.clientY - rect.top) / rect.height
throttle.value = Math.max(0, Math.min(MAX_TRAINING_THROTTLE, rawThrottle))
throttle.value = Math.max(0, Math.min(1, rawThrottle))
}
// --- Cleanup ---
onMounted(() => {
connectToSession()
})
onBeforeUnmount(() => {
if (sendInterval) {
clearInterval(sendInterval)
@@ -269,12 +282,27 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.session-code-input :deep(input) {
text-align: center;
font-family: monospace;
font-size: 24px;
font-weight: bold;
letter-spacing: 0.3em;
text-transform: uppercase;
.throttle-overlimit {
animation: throttle-alert 0.7s ease-in-out infinite;
}
.throttle-warning-text {
animation: throttle-text-alert 0.7s ease-in-out infinite;
}
@keyframes throttle-alert {
0%, 100% {
filter: brightness(1);
opacity: 0.85;
}
50% {
filter: brightness(1.3);
opacity: 1;
}
}
@keyframes throttle-text-alert {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
</style>