Kofferdetektiv iPad: zwei feste Joysticks (immer 50% sichtbar)

Statt schwebendem Links-Joystick + Rechts-Wisch jetzt zwei feste,
halbtransparente Joysticks:
- links = Umsehen (Auge 👁), ratenbasiertes Drehen/Neigen in update()
- rechts = Bewegen (Fuß 🦶)
Touch wird dem naeheren Joystick im Fangradius zugewiesen (assign/stickVec).
Interaktions-Button (🧳) nach unten-mittig verschoben (kollidierte sonst
mit dem rechten Bewegungs-Joystick). Joysticks nur auf Touch sichtbar
(body.kd-touch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 13:57:20 +02:00
parent 8ec005f889
commit 11c48817de
2 changed files with 56 additions and 33 deletions
+41 -25
View File
@@ -393,7 +393,8 @@ const World = {
fundMeshes: {}, // Fundsachen am Boden (Badge-Sammeln), je Spawn-Index
player: { x: 0, z: 24, yaw: 0, speed: 4.2 },
keys: {}, look: { lx: 0, ly: 0 }, pitch: 0,
joy: { active: false, dx: 0, dy: 0 },
joy: { active: false, dx: 0, dy: 0 }, // rechter Joystick: Bewegen (Fuß)
lookJoy: { dx: 0, dy: 0 }, // linker Joystick: Umsehen (Auge)
moving: false, locked: false,
jumpY: 0, vy: 0,
animated: [], // {update(dt,t)} — Bänder, Roboter, Blinklichter
@@ -2757,41 +2758,46 @@ const World = {
}
});
// Touch: linke Hälfte Joystick, rechte Hälfte Umschauen
const joyEl = $('joystick'), knob = $('joy-knob');
const joyPos = t => {
const r = joyEl.getBoundingClientRect();
const dx = (t.clientX - (r.left + r.width / 2)) / (r.width / 2);
const dy = (t.clientY - (r.top + r.height / 2)) / (r.height / 2);
// Touch: zwei feste, immer sichtbare Joysticks.
// links = Umsehen (Auge 👁) → this.lookJoy (ratenbasiertes Drehen in update())
// rechts = Bewegen (Fuß 🦶) → this.joy
if (IS_TOUCH) document.body.classList.add('kd-touch');
const moveEl = $('joystick'), moveKnob = $('joy-knob');
const lookEl = $('lookstick'), lookKnob = $('look-knob');
const KNOB_TRAVEL = 34; // px, wie weit der Knopf maximal auswandert
const CATCH_R = 160; // px Fangradius um den Joystick-Mittelpunkt
const centerOf = el => { const r = el.getBoundingClientRect(); return { cx: r.left + r.width / 2, cy: r.top + r.height / 2, hw: r.width / 2, hh: r.height / 2 }; };
// Normalisierter Vektor (1..1) eines Touches relativ zum festen Joystick-Zentrum.
const stickVec = (el, t) => {
const c = centerOf(el);
const dx = (t.clientX - c.cx) / c.hw, dy = (t.clientY - c.cy) / c.hh;
const len = Math.hypot(dx, dy) || 1, cl = Math.min(1, len);
return { dx: dx / len * cl, dy: dy / len * cl };
};
// Weist einen neuen Touch dem näheren freien Joystick zu (nur im Fangradius).
const assign = t => {
const dist = el => { const c = centerOf(el); return Math.hypot(t.clientX - c.cx, t.clientY - c.cy); };
const dM = dist(moveEl), dL = dist(lookEl);
if (dM <= dL) return dM < CATCH_R ? 'move' : null;
return dL < CATCH_R ? 'look' : null;
};
let joyTouch = null, lookTouch = null;
const applyMove = t => { const v = stickVec(moveEl, t); this.joy.dx = v.dx; this.joy.dy = v.dy; moveKnob.style.transform = 'translate(' + v.dx * KNOB_TRAVEL + 'px,' + v.dy * KNOB_TRAVEL + 'px)'; };
const applyLook = t => { const v = stickVec(lookEl, t); this.lookJoy.dx = v.dx; this.lookJoy.dy = v.dy; lookKnob.style.transform = 'translate(' + v.dx * KNOB_TRAVEL + 'px,' + v.dy * KNOB_TRAVEL + 'px)'; };
canvas.parentElement.addEventListener('touchstart', e => {
if (UI.overlayOpen()) return;
if (e.target.closest && e.target.closest('#btn-touch-act, #btn-map, #minimap-wrap')) return;
for (const t of e.changedTouches) {
if (t.clientX < innerWidth / 2 && joyTouch === null) {
joyTouch = t.identifier; this.joy.active = true;
joyEl.style.left = (t.clientX - 60) + 'px'; joyEl.style.top = (t.clientY - 60) + 'px';
joyEl.classList.add('visible');
const p = joyPos(t); this.joy.dx = p.dx; this.joy.dy = p.dy;
} else if (lookTouch === null) {
lookTouch = t.identifier; this.look.lx = t.clientX; this.look.ly = t.clientY;
}
const which = assign(t);
if (which === 'move' && joyTouch === null) { joyTouch = t.identifier; this.joy.active = true; applyMove(t); }
else if (which === 'look' && lookTouch === null) { lookTouch = t.identifier; applyLook(t); }
}
e.preventDefault();
}, { passive: false });
canvas.parentElement.addEventListener('touchmove', e => {
for (const t of e.changedTouches) {
if (t.identifier === joyTouch) {
const p = joyPos(t); this.joy.dx = p.dx; this.joy.dy = p.dy;
knob.style.transform = 'translate(' + p.dx * 34 + 'px,' + p.dy * 34 + 'px)';
} else if (t.identifier === lookTouch) {
this.player.yaw -= (t.clientX - this.look.lx) * 0.005;
this.pitch = Math.max(-0.9, Math.min(0.9, this.pitch - (t.clientY - this.look.ly) * 0.004));
this.look.lx = t.clientX; this.look.ly = t.clientY;
}
if (t.identifier === joyTouch) applyMove(t);
else if (t.identifier === lookTouch) applyLook(t);
}
e.preventDefault();
}, { passive: false });
@@ -2799,9 +2805,12 @@ const World = {
for (const t of e.changedTouches) {
if (t.identifier === joyTouch) {
joyTouch = null; this.joy.active = false; this.joy.dx = this.joy.dy = 0;
joyEl.classList.remove('visible'); knob.style.transform = '';
moveKnob.style.transform = '';
}
if (t.identifier === lookTouch) {
lookTouch = null; this.lookJoy.dx = this.lookJoy.dy = 0;
lookKnob.style.transform = '';
}
if (t.identifier === lookTouch) lookTouch = null;
}
};
canvas.parentElement.addEventListener('touchend', endTouch);
@@ -2878,6 +2887,13 @@ const World = {
if (!this.blocked(p.x + vx, p.z)) p.x += vx;
if (!this.blocked(p.x, p.z + vz)) p.z += vz;
}
// Linker Joystick: ratenbasiertes Umsehen (Auslenkung = Dreh-/Neige-Tempo).
if (this.lookJoy.dx || this.lookJoy.dy) {
const LOOK_RATE = 2.4; // rad/s bei Vollausschlag
p.yaw -= this.lookJoy.dx * LOOK_RATE * dt;
this.pitch = Math.max(-0.9, Math.min(0.9, this.pitch - this.lookJoy.dy * LOOK_RATE * dt));
}
if (this.mode === 'glb') this.checkPortals();
Sound.setWalking(this.moving && this.jumpY <= 0 && !UI.overlayOpen(),
this.keys['ShiftLeft'] || this.keys['ShiftRight']