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();
this.initScene();
this.bindControls();
this.maybePromptMotion();
this.spawnNext();
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() {
this.video = $('#ar-video');
try {
@@ -196,18 +213,40 @@ const hunt = {
};
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) => {
if (e.alpha == null) return;
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;
if (DOE && typeof DOE.requestPermission === 'function') {
try { await DOE.requestPermission(); } catch { /* ignore */ }
return !!(DOE && typeof DOE.requestPermission === 'function');
},
// 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 startBlast = async (e) => {
const startBlast = (e) => {
e.preventDefault();
await this.requestGyro();
this.blasting = true;
};
const endBlast = () => { this.blasting = false; };
@@ -278,32 +316,45 @@ const hunt = {
}
group.add(mesh);
// place at random yaw around the player, slight height + distance
const yaw = Math.random() * Math.PI * 2;
const dist = 4 + Math.random() * 2;
group.position.set(Math.sin(yaw) * dist, (Math.random() - 0.4) * 1.5, -Math.cos(yaw) * dist);
// Place in a forward-facing arc so ghosts are findable without perfect
// aiming: yaw within ±60° of straight ahead, modest pitch, closer in.
const yaw = (Math.random() - 0.5) * (Math.PI * 2 / 3);
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,
data, isBoss,
hp: data.health, maxHp: data.health,
bobPhase: Math.random() * Math.PI * 2,
revealed: true, // visible on spawn; the wheel now aggros/tints rather than uncloaks
};
this.scene.add(group);
this.ghosts.push(group);
},
scanGloom(type) {
// "deglooming": reveal/aggro the nearest ghost matching this color.
// Mirrors the source loop: scan a color → uncover a gloom spot of that type.
const match = this.ghosts.find((g) => g.userData.data.type === type && !g.userData.revealed);
if (match) {
match.userData.revealed = true;
this.toast(`${type.toUpperCase()} gloom uncovered`, 700);
// Ghosts are visible on spawn now, so the wheel acts as a "lure": scanning a
// colour pulls the nearest matching ghost toward your aim and charges gloom.
// Mismatched colour risks a jumpscare.
const matches = this.ghosts.filter((g) => g.userData.data.type === type);
if (matches.length) {
// 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 {
// 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();
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.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
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) {
removeEventListener('touchend', this._blastEls.endBlast);
removeEventListener('mouseup', this._blastEls.endBlast);