- commit
- bf34e5eeb51f533c5dc59aee127ce89aafca0a08
- parent
- 0326ea0364d6be62f013a20ff1dfe713afb87fd3
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 44 | +++++++++++++++++++++++++++--- |
| M | src/ | 32 | +++++++++++++++++++++- |
| M | src/ | 7 | +++++ |
| M | tests/ | 27 | ++++++++++++++++++ |
@@ -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;@@ -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);@@ -43,6 +43,13 @@ export function installStageEvents({
4343 if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;4444 // Gameplay intents for title, proof, and win use the shared input router.4545 if (scene === "title" || scene === "proof" || scene === "win") return;46+ if (scene === "index" && (event.key === "q" || event.key === "Q" || event.key === "e" || event.key === "E")) {47+ const delta = event.key.toLowerCase() === "e" ? 1 : -1;48+ noteInput?.({ type: "world", delta, source: "keyboard" });49+ dispatchOverlayIntent({ type: "world", delta });50+ event.preventDefault();51+ return;52+ }4653 if (["Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key))4754 event.stopPropagation();4855 if (event.key === "Escape" || (scene === "pause" && event.key.toLowerCase() === "z")) {@@ -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 });