clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Route world navigation through stage input

Dami committed bf34e5e
commit
bf34e5eeb51f533c5dc59aee127ce89aafca0a08
parent
0326ea0364d6be62f013a20ff1dfe713afb87fd3
author
Dami <dami.etoile@gmail.com>
4 files changed, +105 -5
Msrc/input.js44+++++++++++++++++++++++++++---
Msrc/stage.js32+++++++++++++++++++++-
Msrc/stage/events.js7+++++
Mtests/input.test.js27++++++++++++++++++
M src/input.js +40 -4
@@ -64,6 +64,8 @@ export function createInput(options = {}) {
6464 let gamepadDirection = null;6565 let previousGamepadButtons = [];6666 let touchStart = null;67+ const activeTouches = new Map();68+ let twoFingerSwipe = null;6769 let attached = false;6870 let frameHandle = null;6971
@@ -113,6 +115,10 @@ export function createInput(options = {}) {
113115 if (typeof dirOrIntent === 'string') {114116 if (DIRECTIONS[dirOrIntent]) return pressDirection(dirOrIntent, source, `${source}:${dirOrIntent}`, time);115117 if (dirOrIntent === 'undo') return pressUndo(source, source, time);118+ if (dirOrIntent === 'world-previous' || dirOrIntent === 'world-next') {119+ emit({ type: 'world', delta: dirOrIntent === 'world-next' ? 1 : -1, source });120+ return true;121+ }116122 if (['confirm', 'restart', 'menu', 'mute', 'summary', 'wait'].includes(dirOrIntent)) { emit({ type: dirOrIntent, source }); return true; }117123 return false;118124 }
@@ -127,6 +133,7 @@ export function createInput(options = {}) {
127133 }128134 function pressAction(type, source = 'gamepad', time = now()) {129135 if (type === 'undo') return pressUndo(source, source, time);136+ if (type === 'world-previous' || type === 'world-next') return press(type, source, time);130137 if (['confirm', 'restart', 'menu', 'mute', 'summary', 'wait'].includes(type)) { emit({ type, source }); return true; }131138 return false;132139 }
@@ -158,12 +165,38 @@ export function createInput(options = {}) {
158165 }159166 function handlePointerDown(event) {160167 if (event.pointerType && event.pointerType !== 'touch' && event.pointerType !== 'pen') return;161- touchStart = { id: event.pointerId, x: event.clientX, y: event.clientY, time: now() };168+ const point = { id: event.pointerId, x: event.clientX, y: event.clientY, time: now() };169+ activeTouches.set(event.pointerId, point);170+ if (activeTouches.size === 1) touchStart = point;171+ if (activeTouches.size >= 2 && !twoFingerSwipe) {172+ const points = [...activeTouches.values()].slice(0, 2);173+ twoFingerSwipe = { points, current: new Map(points.map(point => [point.id, { ...point }])), emitted: false };174+ }162175 event.preventDefault?.();163176 }164177 function handlePointerUp(event) {178+ if (twoFingerSwipe) {179+ const current = twoFingerSwipe.current;180+ current.set(event.pointerId, { ...current.get(event.pointerId), x: event.clientX, y: event.clientY });181+ if (!twoFingerSwipe.emitted) {182+ const [first, second] = twoFingerSwipe.points;183+ const firstNow = current.get(first.id) || first;184+ const secondNow = current.get(second.id) || second;185+ const dx = ((firstNow.x - first.x) + (secondNow.x - second.x)) / 2;186+ const dy = ((firstNow.y - first.y) + (secondNow.y - second.y)) / 2;187+ if (Math.max(Math.abs(dx), Math.abs(dy)) >= timing.swipeThreshold && Math.abs(dx) >= Math.abs(dy)) {188+ emit({ type: 'world', delta: dx < 0 ? -1 : 1, source: 'touch' });189+ twoFingerSwipe.emitted = true;190+ }191+ }192+ activeTouches.delete(event.pointerId);193+ if (!activeTouches.size) { twoFingerSwipe = null; touchStart = null; }194+ event.preventDefault?.();195+ return;196+ }165197 if (!touchStart || touchStart.id !== event.pointerId) return;166198 const start = touchStart; touchStart = null;199+ activeTouches.delete(event.pointerId);167200 const dx = event.clientX - start.x; const dy = event.clientY - start.y;168201 if (Math.max(Math.abs(dx), Math.abs(dy)) >= timing.swipeThreshold) {169202 const dir = directionFromVector(dx, dy, 0);
@@ -171,7 +204,10 @@ export function createInput(options = {}) {
171204 } else emit({ type: 'confirm', source: 'touch', point: { x: event.clientX, y: event.clientY } });172205 event.preventDefault?.();173206 }174- function handlePointerCancel(event) { if (!touchStart || touchStart.id === event.pointerId) touchStart = null; }207+ function handlePointerCancel(event) {208+ activeTouches.delete(event.pointerId);209+ if (!activeTouches.size) { touchStart = null; twoFingerSwipe = null; }210+ }175211 function bindThumbButtons(buttons = {}) {176212 const cleanups = [];177213 for (const [name, type] of [['undo', 'undo'], ['restart', 'restart'], ['menu', 'menu']]) {
@@ -199,7 +235,7 @@ export function createInput(options = {}) {
199235 if (dir) { pressDirection(dir, 'gamepad', 'gamepad:direction', time); gamepadDirection = dir; }200236 else gamepadDirection = null;201237 }202- const actionButtons = [[0, 'confirm'], [1, 'undo'], [2, 'wait'], [3, 'restart'], [9, 'menu']];238+ const actionButtons = [[0, 'confirm'], [1, 'undo'], [2, 'wait'], [3, 'restart'], [4, 'world-previous'], [5, 'world-next'], [9, 'menu']];203239 for (const [index, action] of actionButtons) {204240 const current = buttonPressed(pad.buttons?.[index]); const previous = Boolean(previousGamepadButtons[index]);205241 if (current && !previous) pressAction(action, 'gamepad', time);
@@ -249,7 +285,7 @@ export function createInput(options = {}) {
249285 return api;250286 }251287 function reset() {252- heldDirections.clear(); heldUndo.clear(); activeDirectionKey = null; directionDue = null; undoDue = null; undoRepeats = 0; gamepadDirection = null; previousGamepadButtons = []; touchStart = null;288+ heldDirections.clear(); heldUndo.clear(); activeDirectionKey = null; directionDue = null; undoDue = null; undoRepeats = 0; gamepadDirection = null; previousGamepadButtons = []; touchStart = null; activeTouches.clear(); twoFingerSwipe = null;253289 }254290 const api = Object.freeze({ attach, detach, destroy: detach, reset, tick, press, release, pressAction, releaseAction, pointerDown: handlePointerDown, pointerUp: handlePointerUp, pointerCancel: handlePointerCancel, bindThumbButtons, get attached() { return attached; }, timing });255291 return api;
M src/stage.js +31 -1
@@ -133,6 +133,7 @@ export function createStage(options = {}) {
133133 let winningNodeId = null;134134 let indexPopStart = 0;135135 let indexPopNodeId = null;136+ let indexWorldTransition = null;136137 let sceneMotion = { phase: 'settled', scene: null, target: null, data: null, openedAt: 0, exitStartedAt: null, generation: 0, timer: null };137138 let copyFloatStart = 0;138139 const reducedMotion =
@@ -257,6 +258,28 @@ export function createStage(options = {}) {
257258 indexFacing = direction;258259 return result;259260 }261+ function moveIndexWorld(delta) {262+ if (scene !== 'index' || !indexNavigator) return false;263+ const result = indexNavigator.moveWorld(delta);264+ if (result.moved) {265+ indexLockedNode = null;266+ indexWorldTransition = {267+ from: result.from?.worldId || null,268+ to: result.world?.id || result.node?.worldId || null,269+ nodeId: result.node?.id || null,270+ delta: Math.sign(delta),271+ startedAt: visualNow(),272+ duration: 280,273+ };274+ options.onWorldTransition?.(indexWorldTransition);275+ announce(`World ${result.world?.number || result.node?.worldIndex || ''}: ${result.world?.name || result.node?.packName || result.node?.worldId || 'World'}`.trim());276+ } else {277+ indexLockedNode = null;278+ announce(delta < 0 ? 'Already at the first world' : 'Already at the last world');279+ }280+ render();281+ return result.moved;282+ }260283 function animateVisuals(now) {261284 if (sceneMotion.phase === 'opening' && now - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {262285 sceneMotion = { ...sceneMotion, phase: 'settled' };
@@ -566,7 +589,7 @@ export function createStage(options = {}) {
566589 }567590 function noteInput(intent = {}) {568591 const action = intent.type === 'move' ? `move-${intent.dir}` : intent.type;569- if (!['undo', 'restart', 'menu', 'wait', 'confirm', 'workshop', 'details', 'copy', 'move-left', 'move-right', 'move-up', 'move-down'].includes(action)) return;592+ if (!['undo', 'restart', 'menu', 'wait', 'confirm', 'world', 'workshop', 'details', 'copy', 'move-left', 'move-right', 'move-up', 'move-down'].includes(action)) return;570593 keycapPress = { action, startedAt: visualNow() };571594 render();572595 if (!reducedMotion) visualFrame(animateVisuals);
@@ -665,6 +688,10 @@ export function createStage(options = {}) {
665688 return false;666689 }667690 if (scene === "index") {691+ if (type === "world") {692+ moveIndexWorld(intent.delta);693+ return true;694+ }668695 if (type === "move" && indexNavigator) {669696 moveIndex(intent.dir);670697 render();
@@ -769,6 +796,9 @@ export function createStage(options = {}) {
769796 get isWinExiting() {770797 return winFlow.exiting;771798 },799+ get indexWorldTransition() {800+ return indexWorldTransition;801+ },772802 root,773803 };774804 return Object.freeze(api);
M src/stage/events.js +7 -0
M tests/input.test.js +27 -0
@@ -80,6 +80,18 @@ test('standard gamepad d-pad, stick, and action buttons share the input contract
8080 assert.equal(intents.at(-1).type, 'undo');8181 });8282 83+test('gamepad bumpers request adjacent INDEX worlds once per press', () => {84+ const intents = [];85+ const buttons = Array.from({ length: 16 }, () => ({ pressed: false }));86+ const pad = { connected: true, buttons, axes: [0, 0] };87+ const input = createInput({ autoTick: false, getGamepads: () => [pad], onIntent: intent => intents.push({ ...intent }) });88+ buttons[4].pressed = true;89+ input.tick(0); input.tick(1);90+ buttons[4].pressed = false; buttons[5].pressed = true;91+ input.tick(2);92+ assert.deepEqual(intents.map(intent => [intent.type, intent.delta]), [['world', -1], ['world', 1]]);93+});94+8395 test('touch swipes move while taps confirm', () => {8496 const h = harness();8597 h.input.pointerDown({ pointerId: 1, pointerType: 'touch', clientX: 10, clientY: 20, preventDefault() {} });
@@ -94,6 +106,21 @@ test('touch swipes move while taps confirm', () => {
94106 assert.equal(h.intents.at(-1).dir, 'down');95107 });96108 109+test('two-finger horizontal swipe requests one world switch per gesture', () => {110+ const h = harness();111+ const event = (id, x, y) => ({ pointerId: id, pointerType: 'touch', clientX: x, clientY: y, preventDefault() {} });112+ h.input.pointerDown(event(1, 10, 20));113+ h.input.pointerDown(event(2, 10, 60));114+ h.input.pointerUp(event(1, 55, 20));115+ h.input.pointerUp(event(2, 55, 60));116+ assert.deepEqual(h.intents.map(intent => [intent.type, intent.delta]), [['world', 1]]);117+ h.input.pointerDown(event(3, 80, 20));118+ h.input.pointerDown(event(4, 80, 60));119+ h.input.pointerUp(event(3, 40, 20));120+ h.input.pointerUp(event(4, 40, 60));121+ assert.deepEqual(h.intents.map(intent => [intent.type, intent.delta]), [['world', 1], ['world', -1]]);122+});123+97124 test('T requests an accessible board summary without moving the game', () => {98125 const h = harness();99126 h.target.dispatch('keydown', { key: 't', repeat: false });