Fix iOS gyro: explicit Enable Motion gate; spawn ghosts near center, reveal on spawn

This commit is contained in:
2026-06-18 08:39:37 +10:00
parent bf719a1dda
commit 0da74fb2dc
+75 -20
View File
@@ -159,10 +159,27 @@ const hunt = {
await this.initCamera(); await this.initCamera();
this.initScene(); this.initScene();
this.bindControls(); this.bindControls();
this.maybePromptMotion();
this.spawnNext(); this.spawnNext();
this.loop(); this.loop();
}, },
// On iOS, surface an explicit "Enable Motion" button. Tapping it is the clean
// user gesture iOS needs to show its Motion & Orientation Access prompt.
maybePromptMotion() {
const overlay = $('#motion-gate');
if (!this.gyroNeedsPermission()) { overlay.classList.add('hidden'); return; }
overlay.classList.remove('hidden');
const btn = $('#motion-enable');
const onTap = async () => {
const ok = await this.requestGyro();
overlay.classList.add('hidden');
if (!ok) this.toast('Motion blocked — enable it in Settings Safari', 2200);
};
btn.onclick = onTap;
this._motionEls = { overlay, btn };
},
async initCamera() { async initCamera() {
this.video = $('#ar-video'); this.video = $('#ar-video');
try { try {
@@ -196,18 +213,40 @@ const hunt = {
}; };
addEventListener('resize', this._onResize); addEventListener('resize', this._onResize);
// Gyro look (iOS Safari needs a permission gesture; we request on first blast/scan tap) // Gyro look. iOS Safari requires DeviceOrientationEvent.requestPermission()
// from an explicit user gesture (handled by the "Enable Motion" button in
// start()). Other browsers can attach the listener immediately.
this._onOrient = (e) => { this._onOrient = (e) => {
if (e.alpha == null) return; if (e.alpha == null) return;
this.orientation = { alpha: e.alpha, beta: e.beta, gamma: e.gamma, active: true }; this.orientation = { alpha: e.alpha, beta: e.beta, gamma: e.gamma, active: true };
}; };
addEventListener('deviceorientation', this._onOrient); if (!this.gyroNeedsPermission()) {
addEventListener('deviceorientation', this._onOrient);
this._gyroAttached = true;
}
}, },
async requestGyro() { gyroNeedsPermission() {
const DOE = window.DeviceOrientationEvent; const DOE = window.DeviceOrientationEvent;
if (DOE && typeof DOE.requestPermission === 'function') { return !!(DOE && typeof DOE.requestPermission === 'function');
try { await DOE.requestPermission(); } catch { /* ignore */ } },
// Called from a clean tap (the Enable Motion overlay). On iOS this triggers
// the system "Motion & Orientation Access" prompt; once granted we attach the
// listener. Safe to call more than once.
async requestGyro() {
if (this._gyroAttached) return true;
const DOE = window.DeviceOrientationEvent;
try {
if (DOE && typeof DOE.requestPermission === 'function') {
const res = await DOE.requestPermission();
if (res !== 'granted') return false;
}
addEventListener('deviceorientation', this._onOrient);
this._gyroAttached = true;
return true;
} catch {
return false;
} }
}, },
@@ -225,9 +264,8 @@ const hunt = {
}); });
const blast = $('#btn-blast'); const blast = $('#btn-blast');
const startBlast = async (e) => { const startBlast = (e) => {
e.preventDefault(); e.preventDefault();
await this.requestGyro();
this.blasting = true; this.blasting = true;
}; };
const endBlast = () => { this.blasting = false; }; const endBlast = () => { this.blasting = false; };
@@ -278,32 +316,45 @@ const hunt = {
} }
group.add(mesh); group.add(mesh);
// place at random yaw around the player, slight height + distance // Place in a forward-facing arc so ghosts are findable without perfect
const yaw = Math.random() * Math.PI * 2; // aiming: yaw within ±60° of straight ahead, modest pitch, closer in.
const dist = 4 + Math.random() * 2; const yaw = (Math.random() - 0.5) * (Math.PI * 2 / 3);
group.position.set(Math.sin(yaw) * dist, (Math.random() - 0.4) * 1.5, -Math.cos(yaw) * dist); const pitch = (Math.random() - 0.5) * 0.5;
const dist = 3 + Math.random() * 1.5;
group.position.set(
Math.sin(yaw) * dist,
Math.sin(pitch) * dist,
-Math.cos(yaw) * dist
);
group.userData = { group.userData = {
...group.userData, ...group.userData,
data, isBoss, data, isBoss,
hp: data.health, maxHp: data.health, hp: data.health, maxHp: data.health,
bobPhase: Math.random() * Math.PI * 2, bobPhase: Math.random() * Math.PI * 2,
revealed: true, // visible on spawn; the wheel now aggros/tints rather than uncloaks
}; };
this.scene.add(group); this.scene.add(group);
this.ghosts.push(group); this.ghosts.push(group);
}, },
scanGloom(type) { scanGloom(type) {
// "deglooming": reveal/aggro the nearest ghost matching this color. // Ghosts are visible on spawn now, so the wheel acts as a "lure": scanning a
// Mirrors the source loop: scan a color → uncover a gloom spot of that type. // colour pulls the nearest matching ghost toward your aim and charges gloom.
const match = this.ghosts.find((g) => g.userData.data.type === type && !g.userData.revealed); // Mismatched colour risks a jumpscare.
if (match) { const matches = this.ghosts.filter((g) => g.userData.data.type === type);
match.userData.revealed = true; if (matches.length) {
this.toast(`${type.toUpperCase()} gloom uncovered`, 700); // pull the nearest match into a forward, easy-to-aim position
const g = matches[0];
const dist = 3;
g.position.set((Math.random() - 0.5) * 1.2, (Math.random() - 0.3) * 0.8, -dist);
g.userData.lured = true;
this.gloom += 5; this.updateGloom();
this.toast(`${type.toUpperCase()} ghost lured in`, 800);
} else { } else {
// small chance of a Nagging-Nathan-style jumpscare on an empty scan // no ghost of that colour present — small chance of a jumpscare
if (Math.random() < 0.12) this.jumpscare(); if (Math.random() < 0.12) this.jumpscare();
else { this.gloom += 5; this.updateGloom(); this.toast('+5 gloom', 600); } else { this.gloom += 2; this.updateGloom(); this.toast('+2 gloom', 600); }
} }
}, },
@@ -482,7 +533,11 @@ const hunt = {
if (this.raf) cancelAnimationFrame(this.raf); if (this.raf) cancelAnimationFrame(this.raf);
if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; } if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
if (this._onResize) removeEventListener('resize', this._onResize); if (this._onResize) removeEventListener('resize', this._onResize);
if (this._onOrient) removeEventListener('deviceorientation', this._onOrient); if (this._onOrient && this._gyroAttached) {
removeEventListener('deviceorientation', this._onOrient);
this._gyroAttached = false;
}
if (this._motionEls) { this._motionEls.overlay.classList.add('hidden'); }
if (this._blastEls) { if (this._blastEls) {
removeEventListener('touchend', this._blastEls.endBlast); removeEventListener('touchend', this._blastEls.endBlast);
removeEventListener('mouseup', this._blastEls.endBlast); removeEventListener('mouseup', this._blastEls.endBlast);