openvibers/breakroomsimulator Commits
Centralize UI pointer-lock focus; seal exit-door bay and entrance transom (Bug30)
Changes
@@@ -2,6 +2,7 @@// All changes go through the server (admin-validated) and broadcast live.import * as THREE from 'three';import { net } from './net.js';++import { uiFocus } from './input.js';import { PROP_KINDS } from './props.js';export function initEditor({ scene, camera, props, toast }) {@@@ -56,7 +57,7 @@ export function initEditor({ scene, camera, props, toast }) {ed.active = on;panel.classList.toggle('hidden', !on);btn.style.background = on ? '#ff9900' : '';-- document.exitPointerLock?.();++ uiFocus('editor', on);if (!on) { stopPlacing(); select(null); }toast(on ? '🛠️ Editor on — changes save to the server for everyone' : 'Editor off', 1800);}@@@ -8,11 +8,35 @@ export const input = {isTouch: false,locked: false,chatOpen: false,++ uiOpen: false, // any UI panel (inventory/settings/editor) openonAction: null, // set by mainonTap: null, // world tap (for board clicks on mobile)};++// ---------- UI focus manager ----------++// Any open panel releases the pointer so the cursor can click UI; closing the++// last panel re-locks mouse-look if the player had it before (works from++// keydown/click handlers, which carry the user gesture relock needs).++let lockEl = null;++const uiStack = new Set();++let relock = false;++export function uiFocus(id, open) {++ const had = uiStack.size > 0;++ open ? uiStack.add(id) : uiStack.delete(id);++ input.uiOpen = uiStack.size > 0;++ if (input.uiOpen && !had) {++ relock = input.locked;++ document.exitPointerLock?.();++ } else if (!input.uiOpen && had) {++ if (relock && !input.isTouch) {++ try { lockEl?.requestPointerLock?.()?.catch?.(() => {}); } catch {}++ }++ relock = false;++ }++}++export function initInput(canvas) {++ lockEl = canvas;input.isTouch = navigator.maxTouchPoints > 0 || 'ontouchstart' in window|| matchMedia('(any-pointer: coarse)').matches;if (input.isTouch) document.body.classList.add('touch');@@@ -34,7 +58,7 @@ export function initInput(canvas) {// ---------- mouse (also active on hybrid touch devices) ----------canvas.addEventListener('click', (e) => {-- if (Date.now() - (input.lastTouch || 0) < 600 || input.chatOpen) return;++ if (Date.now() - (input.lastTouch || 0) < 600 || input.chatOpen || input.uiOpen) return;if (!input.locked) canvas.requestPointerLock?.();else if (input.onTap) input.onTap(innerWidth / 2, innerHeight / 2); // crosshair click});@@@ -2,6 +2,7 @@// number keys to select, consumables / melee props / clothes. Persists via server.import { net } from './net.js';import { beep } from './minigames.js';++import { uiFocus } from './input.js';export const ITEMS = {chips: { name: 'Chips', icon: '🍟', type: 'food' },@@@ -172,6 +173,7 @@ export function initInventory({ me, onEquip, onWear, toast }) {function toggle(open) {inv.open = open ?? !inv.open;invEl.classList.toggle('hidden', !inv.open);++ uiFocus('inv', inv.open); // free the cursor while the panel is upif (inv.open) renderInv();return inv.open;}@@@ -3,7 +3,7 @@ import * as THREE from 'three';import { buildWorld, resolveCollisions, W } from './world.js';import { makeAvatar, buildHeldMesh, SKINS, SHIRTS, HAIRS, HATS } from './avatar.js';import { initInventory, ITEMS } from './inventory.js';--import { input, initInput, keyMove } from './input.js';++import { input, initInput, keyMove, uiFocus } from './input.js';import { net, api, connect } from './net.js';import { initMinigames, beep } from './minigames.js';import { initProps } from './props.js';@@@ -352,6 +352,11 @@ function start(token, user) {addEventListener('keydown', (e) => {if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }else if (e.code === 'Escape' && input.chatOpen) closeChat();++ else if (e.code === 'Escape' && started) {++ // Escape closes the top UI panel (inventory/settings) like any game menu++ if (invApi.state.open) invApi.toggle(false);++ else if (!$('settings').classList.contains('hidden')) { $('settings').classList.add('hidden'); uiFocus('settings', false); }++ }});$('btn-chat').addEventListener('touchstart', (e) => { e.preventDefault(); input.chatOpen ? closeChat() : openChat(); }, { passive: false });@@@ -474,6 +479,7 @@ function start(token, user) {}$('btn-settings').onclick = () => {$('settings').classList.remove('hidden');++ uiFocus('settings', true);buildApRows();document.querySelectorAll('.vest').forEach(b => b.classList.toggle('sel', b.dataset.vest === me.vest));const s = me.stats;@@@ -495,7 +501,7 @@ function start(token, user) {$('set-inverty').onchange = (e) => { cfg.invertY = e.target.checked; saveCfg(); };camera.fov = cfg.fov; camera.updateProjectionMatrix();refreshCfgUI();-- $('btn-close-settings').onclick = () => $('settings').classList.add('hidden');++ $('btn-close-settings').onclick = () => { $('settings').classList.add('hidden'); uiFocus('settings', false); };$('btn-logout').onclick = () => { localStorage.clear(); location.reload(); };document.querySelectorAll('.vest').forEach(b => b.onclick = () => {me.vest = b.dataset.vest;@@@ -868,6 +874,7 @@ function start(token, user) {};addEventListener('wheel', (e) => {++ if (input.uiOpen) return; // scrolling a panel shouldn't zoom/cycleif (fp) {if (cfg.wheel === 'hotbar') { // cycle hotbarconst s = invApi.state;@@@ -130,6 +130,9 @@ function shell(scene) {collide(-21.65, -17.1, 16.7, .6); collide(-10.85, -17.1, 1.7, .6);collide(1, -17.1, 22, .6); collide(27.5, -17.1, 21, .6);serviceHallway(scene);++ // full-height backing panel in the exit-door bay — the gap above/around the++ // 2.4 m door slab looked straight through into the lit hallway (Bug30)++ scene.add(blocker(box(1.8, 6.2, .14, M.wallDark, -12.5, 3.1, -17.02)));exitDoor(scene, -12.6, -16.85, 0);// south (z=17): long glass run with parking outside (photos 1/2/4) and a second// set of entry doors near the market end (panorama), wall far east@@@ -1212,6 +1215,7 @@ function entrance(scene) {scene.add(box(.3, 6.2, 1.2, M.wall, -38, 3.1, -2.6));scene.add(box(.3, 6.2, 1.2, M.wall, -38, 3.1, 2.6));scene.add(box(.3, 1.6, 6, M.wall, -38, 5.4, 0));++ scene.add(box(.07, 1.9, 4.1, M.glass, -37.9, 3.72, 0)); // transom over the leavesconst signM = new THREE.MeshBasicMaterial({ map: emergencySignTexture() });for (const s of [-1, 1]) {const leaf = box(.07, 2.6, 1.9, M.glass, -37.9, 1.5, s * 1.0);
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
// All changes go through the server (admin-validated) and broadcast live.
// All changes go through the server (admin-validated) and broadcast live.
import * as THREE from 'three';
import * as THREE from 'three';
import { net } from './net.js';
import { net } from './net.js';
+import { uiFocus } from './input.js';
import { PROP_KINDS } from './props.js';
import { PROP_KINDS } from './props.js';
export function initEditor({ scene, camera, props, toast }) {
export function initEditor({ scene, camera, props, toast }) {
@@ -56,7 +57,7 @@ export function initEditor({ scene, camera, props, toast }) {
@@ -56,7 +57,7 @@ export function initEditor({ scene, camera, props, toast }) {
ed.active = on;
ed.active = on;
panel.classList.toggle('hidden', !on);
panel.classList.toggle('hidden', !on);
btn.style.background = on ? '#ff9900' : '';
btn.style.background = on ? '#ff9900' : '';
- document.exitPointerLock?.();
+ uiFocus('editor', on);
if (!on) { stopPlacing(); select(null); }
if (!on) { stopPlacing(); select(null); }
toast(on ? '🛠️ Editor on — changes save to the server for everyone' : 'Editor off', 1800);
toast(on ? '🛠️ Editor on — changes save to the server for everyone' : 'Editor off', 1800);
}
}
@@ -8,11 +8,35 @@ export const input = {
@@ -8,11 +8,35 @@ export const input = {
isTouch: false,
isTouch: false,
locked: false,
locked: false,
chatOpen: false,
chatOpen: false,
+ uiOpen: false, // any UI panel (inventory/settings/editor) open
onAction: null, // set by main
onAction: null, // set by main
onTap: null, // world tap (for board clicks on mobile)
onTap: null, // world tap (for board clicks on mobile)
};
};
+// ---------- UI focus manager ----------
+// Any open panel releases the pointer so the cursor can click UI; closing the
+// last panel re-locks mouse-look if the player had it before (works from
+// keydown/click handlers, which carry the user gesture relock needs).
+let lockEl = null;
+const uiStack = new Set();
+let relock = false;
+export function uiFocus(id, open) {
+ const had = uiStack.size > 0;
+ open ? uiStack.add(id) : uiStack.delete(id);
+ input.uiOpen = uiStack.size > 0;
+ if (input.uiOpen && !had) {
+ relock = input.locked;
+ document.exitPointerLock?.();
+ } else if (!input.uiOpen && had) {
+ if (relock && !input.isTouch) {
+ try { lockEl?.requestPointerLock?.()?.catch?.(() => {}); } catch {}
+ }
+ relock = false;
+ }
+}
+
export function initInput(canvas) {
export function initInput(canvas) {
+ lockEl = canvas;
input.isTouch = navigator.maxTouchPoints > 0 || 'ontouchstart' in window
input.isTouch = navigator.maxTouchPoints > 0 || 'ontouchstart' in window
|| matchMedia('(any-pointer: coarse)').matches;
|| matchMedia('(any-pointer: coarse)').matches;
if (input.isTouch) document.body.classList.add('touch');
if (input.isTouch) document.body.classList.add('touch');
@@ -34,7 +58,7 @@ export function initInput(canvas) {
@@ -34,7 +58,7 @@ export function initInput(canvas) {
// ---------- mouse (also active on hybrid touch devices) ----------
// ---------- mouse (also active on hybrid touch devices) ----------
canvas.addEventListener('click', (e) => {
canvas.addEventListener('click', (e) => {
- if (Date.now() - (input.lastTouch || 0) < 600 || input.chatOpen) return;
+ if (Date.now() - (input.lastTouch || 0) < 600 || input.chatOpen || input.uiOpen) return;
if (!input.locked) canvas.requestPointerLock?.();
if (!input.locked) canvas.requestPointerLock?.();
else if (input.onTap) input.onTap(innerWidth / 2, innerHeight / 2); // crosshair click
else if (input.onTap) input.onTap(innerWidth / 2, innerHeight / 2); // crosshair click
});
});
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
// number keys to select, consumables / melee props / clothes. Persists via server.
// number keys to select, consumables / melee props / clothes. Persists via server.
import { net } from './net.js';
import { net } from './net.js';
import { beep } from './minigames.js';
import { beep } from './minigames.js';
+import { uiFocus } from './input.js';
export const ITEMS = {
export const ITEMS = {
chips: { name: 'Chips', icon: '🍟', type: 'food' },
chips: { name: 'Chips', icon: '🍟', type: 'food' },
@@ -172,6 +173,7 @@ export function initInventory({ me, onEquip, onWear, toast }) {
@@ -172,6 +173,7 @@ export function initInventory({ me, onEquip, onWear, toast }) {
function toggle(open) {
function toggle(open) {
inv.open = open ?? !inv.open;
inv.open = open ?? !inv.open;
invEl.classList.toggle('hidden', !inv.open);
invEl.classList.toggle('hidden', !inv.open);
+ uiFocus('inv', inv.open); // free the cursor while the panel is up
if (inv.open) renderInv();
if (inv.open) renderInv();
return inv.open;
return inv.open;
}
}
@@ -3,7 +3,7 @@ import * as THREE from 'three';
@@ -3,7 +3,7 @@ import * as THREE from 'three';
import { buildWorld, resolveCollisions, W } from './world.js';
import { buildWorld, resolveCollisions, W } from './world.js';
import { makeAvatar, buildHeldMesh, SKINS, SHIRTS, HAIRS, HATS } from './avatar.js';
import { makeAvatar, buildHeldMesh, SKINS, SHIRTS, HAIRS, HATS } from './avatar.js';
import { initInventory, ITEMS } from './inventory.js';
import { initInventory, ITEMS } from './inventory.js';
-import { input, initInput, keyMove } from './input.js';
+import { input, initInput, keyMove, uiFocus } from './input.js';
import { net, api, connect } from './net.js';
import { net, api, connect } from './net.js';
import { initMinigames, beep } from './minigames.js';
import { initMinigames, beep } from './minigames.js';
import { initProps } from './props.js';
import { initProps } from './props.js';
@@ -352,6 +352,11 @@ function start(token, user) {
@@ -352,6 +352,11 @@ function start(token, user) {
addEventListener('keydown', (e) => {
addEventListener('keydown', (e) => {
if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }
if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }
else if (e.code === 'Escape' && input.chatOpen) closeChat();
else if (e.code === 'Escape' && input.chatOpen) closeChat();
+ else if (e.code === 'Escape' && started) {
+ // Escape closes the top UI panel (inventory/settings) like any game menu
+ if (invApi.state.open) invApi.toggle(false);
+ else if (!$('settings').classList.contains('hidden')) { $('settings').classList.add('hidden'); uiFocus('settings', false); }
+ }
});
});
$('btn-chat').addEventListener('touchstart', (e) => { e.preventDefault(); input.chatOpen ? closeChat() : openChat(); }, { passive: false });
$('btn-chat').addEventListener('touchstart', (e) => { e.preventDefault(); input.chatOpen ? closeChat() : openChat(); }, { passive: false });
@@ -474,6 +479,7 @@ function start(token, user) {
@@ -474,6 +479,7 @@ function start(token, user) {
}
}
$('btn-settings').onclick = () => {
$('btn-settings').onclick = () => {
$('settings').classList.remove('hidden');
$('settings').classList.remove('hidden');
+ uiFocus('settings', true);
buildApRows();
buildApRows();
document.querySelectorAll('.vest').forEach(b => b.classList.toggle('sel', b.dataset.vest === me.vest));
document.querySelectorAll('.vest').forEach(b => b.classList.toggle('sel', b.dataset.vest === me.vest));
const s = me.stats;
const s = me.stats;
@@ -495,7 +501,7 @@ function start(token, user) {
@@ -495,7 +501,7 @@ function start(token, user) {
$('set-inverty').onchange = (e) => { cfg.invertY = e.target.checked; saveCfg(); };
$('set-inverty').onchange = (e) => { cfg.invertY = e.target.checked; saveCfg(); };
camera.fov = cfg.fov; camera.updateProjectionMatrix();
camera.fov = cfg.fov; camera.updateProjectionMatrix();
refreshCfgUI();
refreshCfgUI();
- $('btn-close-settings').onclick = () => $('settings').classList.add('hidden');
+ $('btn-close-settings').onclick = () => { $('settings').classList.add('hidden'); uiFocus('settings', false); };
$('btn-logout').onclick = () => { localStorage.clear(); location.reload(); };
$('btn-logout').onclick = () => { localStorage.clear(); location.reload(); };
document.querySelectorAll('.vest').forEach(b => b.onclick = () => {
document.querySelectorAll('.vest').forEach(b => b.onclick = () => {
me.vest = b.dataset.vest;
me.vest = b.dataset.vest;
@@ -868,6 +874,7 @@ function start(token, user) {
@@ -868,6 +874,7 @@ function start(token, user) {
};
};
addEventListener('wheel', (e) => {
addEventListener('wheel', (e) => {
+ if (input.uiOpen) return; // scrolling a panel shouldn't zoom/cycle
if (fp) {
if (fp) {
if (cfg.wheel === 'hotbar') { // cycle hotbar
if (cfg.wheel === 'hotbar') { // cycle hotbar
const s = invApi.state;
const s = invApi.state;
@@ -130,6 +130,9 @@ function shell(scene) {
@@ -130,6 +130,9 @@ function shell(scene) {
collide(-21.65, -17.1, 16.7, .6); collide(-10.85, -17.1, 1.7, .6);
collide(-21.65, -17.1, 16.7, .6); collide(-10.85, -17.1, 1.7, .6);
collide(1, -17.1, 22, .6); collide(27.5, -17.1, 21, .6);
collide(1, -17.1, 22, .6); collide(27.5, -17.1, 21, .6);
serviceHallway(scene);
serviceHallway(scene);
+ // full-height backing panel in the exit-door bay — the gap above/around the
+ // 2.4 m door slab looked straight through into the lit hallway (Bug30)
+ scene.add(blocker(box(1.8, 6.2, .14, M.wallDark, -12.5, 3.1, -17.02)));
exitDoor(scene, -12.6, -16.85, 0);
exitDoor(scene, -12.6, -16.85, 0);
// south (z=17): long glass run with parking outside (photos 1/2/4) and a second
// south (z=17): long glass run with parking outside (photos 1/2/4) and a second
// set of entry doors near the market end (panorama), wall far east
// set of entry doors near the market end (panorama), wall far east
@@ -1212,6 +1215,7 @@ function entrance(scene) {
@@ -1212,6 +1215,7 @@ function entrance(scene) {
scene.add(box(.3, 6.2, 1.2, M.wall, -38, 3.1, -2.6));
scene.add(box(.3, 6.2, 1.2, M.wall, -38, 3.1, -2.6));
scene.add(box(.3, 6.2, 1.2, M.wall, -38, 3.1, 2.6));
scene.add(box(.3, 6.2, 1.2, M.wall, -38, 3.1, 2.6));
scene.add(box(.3, 1.6, 6, M.wall, -38, 5.4, 0));
scene.add(box(.3, 1.6, 6, M.wall, -38, 5.4, 0));
+ scene.add(box(.07, 1.9, 4.1, M.glass, -37.9, 3.72, 0)); // transom over the leaves
const signM = new THREE.MeshBasicMaterial({ map: emergencySignTexture() });
const signM = new THREE.MeshBasicMaterial({ map: emergencySignTexture() });
for (const s of [-1, 1]) {
for (const s of [-1, 1]) {
const leaf = box(.07, 2.6, 1.9, M.glass, -37.9, 1.5, s * 1.0);
const leaf = box(.07, 2.6, 1.9, M.glass, -37.9, 1.5, s * 1.0);