@nano133/nanoterra 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +43 -8
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +13 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +12 -4
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +2 -2
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.js +8 -5
- package/dist/admin/panel/overview.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +20 -3
- package/dist/admin/switches.js +25 -6
- package/dist/input/index.d.ts +2 -1
- package/dist/input/index.js +3 -0
- package/dist/input/zoom.d.ts +81 -0
- package/dist/input/zoom.js +140 -0
- package/package.json +1 -1
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +12 -5
- package/src/admin/conformance.ts +14 -5
- package/src/admin/handler.ts +2 -2
- package/src/admin/index.ts +2 -2
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +8 -5
- package/src/admin/panel/overview.ts +1 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +41 -12
- package/src/input/index.ts +5 -0
- package/src/input/zoom.ts +160 -0
package/src/admin/switches.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
|
|
2
|
-
// draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these
|
|
2
|
+
// draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these five settings; never a look, a price or an order.
|
|
3
3
|
|
|
4
4
|
/** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
|
|
5
5
|
export type Character = "nanoterra" | "own";
|
|
@@ -12,52 +12,72 @@ export type Signin = "exchange" | "ticket" | "none";
|
|
|
12
12
|
export type Friends = "nanoterra" | "own" | "off";
|
|
13
13
|
/** Whether the game shows Nanoterra's emotes ("@nano133/nanoterra/emotes"): the wheel's Nanoterra emotes and the other players' on their characters. Off leaves the game's own emotes. */
|
|
14
14
|
export type Emotes = "on" | "off";
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Whether the game has free play. "off": a player pays in the game to play (the game asks for the payment; the switch does not). The listing file says it
|
|
17
|
+
* (`"freePlay": false`), and Nanoterra's listing then says "Paid" in place of "Free to play". The game's paid play, its prices and its risk are the creator's.
|
|
18
|
+
*/
|
|
19
|
+
export type FreePlay = "on" | "off";
|
|
20
|
+
/** The game's own words for the switches. `friends`, `emotes` and `freePlay` may be left out: "nanoterra", "on" and "on" then (on by default). */
|
|
21
|
+
export type GameSettings = { character: Character; signin: Signin; friends?: Friends; emotes?: Emotes; freePlay?: FreePlay };
|
|
17
22
|
/** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
|
|
18
|
-
export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends; emotes?: Emotes };
|
|
23
|
+
export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends; emotes?: Emotes; freePlay?: FreePlay };
|
|
19
24
|
/** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
|
|
20
|
-
export type GameState = {
|
|
25
|
+
export type GameState = {
|
|
26
|
+
character: Character;
|
|
27
|
+
signin: Signin;
|
|
28
|
+
friends: Friends;
|
|
29
|
+
emotes: Emotes;
|
|
30
|
+
freePlay: FreePlay;
|
|
31
|
+
fromAdmin: { character: boolean; signin: boolean; friends: boolean; emotes: boolean; freePlay: boolean };
|
|
32
|
+
};
|
|
21
33
|
|
|
22
34
|
export const isCharacter = (v: unknown): v is Character => v === "nanoterra" || v === "own";
|
|
23
35
|
export const isSigninChoice = (v: unknown): v is "exchange" | "none" => v === "exchange" || v === "none";
|
|
24
36
|
export const isSignin = (v: unknown): v is Signin => v === "exchange" || v === "ticket" || v === "none";
|
|
25
37
|
export const isFriends = (v: unknown): v is Friends => v === "nanoterra" || v === "own" || v === "off";
|
|
26
38
|
export const isEmotes = (v: unknown): v is Emotes => v === "on" || v === "off";
|
|
39
|
+
export const isFreePlay = (v: unknown): v is FreePlay => v === "on" || v === "off";
|
|
27
40
|
/** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
|
|
28
41
|
export const DEFAULT_FRIENDS: Friends = "nanoterra";
|
|
29
42
|
/** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
|
|
30
43
|
export const DEFAULT_EMOTES: Emotes = "on";
|
|
44
|
+
/** Free play is on while neither the game's own words nor /admin say. */
|
|
45
|
+
export const DEFAULT_FREE_PLAY: FreePlay = "on";
|
|
46
|
+
/** The listing file's word for free play (`"freePlay": true` or `false`), from the settings. */
|
|
47
|
+
export const freePlayOn = (s: Pick<GameSettings, "freePlay">): boolean => (s.freePlay ?? DEFAULT_FREE_PLAY) === "on";
|
|
31
48
|
/** The sign-in switch is on for any way but "none". */
|
|
32
49
|
export const signinOn = (s: Signin): boolean => s !== "none";
|
|
33
50
|
|
|
34
|
-
/** A request body for a press: only the
|
|
51
|
+
/** A request body for a press: only the known words, at least one. Anything else is refused, with the reason in words. */
|
|
35
52
|
export function parsePatch(body: unknown): { ok: true; patch: GamePatch } | { ok: false; error: string } {
|
|
36
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin", "friends" or "
|
|
53
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin", "friends", "emotes" or "freePlay".' };
|
|
37
54
|
const patch: GamePatch = {};
|
|
38
55
|
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
|
|
39
56
|
if (key === "character" && isCharacter(value)) patch.character = value;
|
|
40
57
|
else if (key === "signin" && isSigninChoice(value)) patch.signin = value;
|
|
41
58
|
else if (key === "friends" && isFriends(value)) patch.friends = value;
|
|
42
59
|
else if (key === "emotes" && isEmotes(value)) patch.emotes = value;
|
|
60
|
+
else if (key === "freePlay" && isFreePlay(value)) patch.freePlay = value;
|
|
43
61
|
else if (key === "friends") return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
|
|
44
62
|
else if (key === "emotes") return { ok: false, error: '"emotes" must be "on" or "off".' };
|
|
63
|
+
else if (key === "freePlay") return { ok: false, error: '"freePlay" must be "on" or "off".' };
|
|
45
64
|
else if (key === "character") return { ok: false, error: '"character" must be "nanoterra" or "own".' };
|
|
46
65
|
else if (key === "signin") return { ok: false, error: '"signin" must be "exchange" or "none".' };
|
|
47
66
|
else return { ok: false, error: `"${key}" is not a game switch.` };
|
|
48
67
|
}
|
|
49
|
-
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin", "friends" or "
|
|
68
|
+
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin", "friends", "emotes" or "freePlay".' };
|
|
50
69
|
return { ok: true, patch };
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
|
|
54
|
-
export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown }, defaults: GameSettings): GameState {
|
|
73
|
+
export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown; freePlay?: unknown }, defaults: GameSettings): GameState {
|
|
55
74
|
return {
|
|
56
75
|
character: isCharacter(stored.character) ? stored.character : defaults.character,
|
|
57
76
|
signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
|
|
58
77
|
friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
|
|
59
78
|
emotes: isEmotes(stored.emotes) ? stored.emotes : isEmotes(defaults.emotes) ? defaults.emotes : DEFAULT_EMOTES,
|
|
60
|
-
|
|
79
|
+
freePlay: isFreePlay(stored.freePlay) ? stored.freePlay : isFreePlay(defaults.freePlay) ? defaults.freePlay : DEFAULT_FREE_PLAY,
|
|
80
|
+
fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes), freePlay: isFreePlay(stored.freePlay) },
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
83
|
|
|
@@ -76,7 +96,7 @@ export const FRIENDS_CHOICES: Record<Friends, { name: string; note: string }> =
|
|
|
76
96
|
};
|
|
77
97
|
|
|
78
98
|
/** One switch of the card: its title, and one plain sentence for each state. */
|
|
79
|
-
export type SwitchId = "character" | "signin" | "emotes";
|
|
99
|
+
export type SwitchId = "character" | "signin" | "emotes" | "freePlay";
|
|
80
100
|
export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string }> = {
|
|
81
101
|
character: {
|
|
82
102
|
title: "Players can play as their Nanoterra character",
|
|
@@ -93,6 +113,11 @@ export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string
|
|
|
93
113
|
on: "On: players can play Nanoterra's emotes (Wave, Dance, Sit and more) from the Emotes wheel, and see the other players' emotes.",
|
|
94
114
|
off: "Off: the game shows no Nanoterra emote. The game's own emotes, if it has any, stay on the wheel.",
|
|
95
115
|
},
|
|
116
|
+
freePlay: {
|
|
117
|
+
title: "Free play",
|
|
118
|
+
on: "On: players can play without paying. Nanoterra's listing says “Free to play”.",
|
|
119
|
+
off: "Off: there is no free play. A player must pay in your game to play, and Nanoterra's listing says “Paid”. Your game asks for the payment: this switch only says so. Your paid game, its prices and its risk are yours.",
|
|
120
|
+
},
|
|
96
121
|
};
|
|
97
122
|
|
|
98
123
|
/**
|
|
@@ -107,11 +132,15 @@ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every pla
|
|
|
107
132
|
export const REVIEW_NOTE =
|
|
108
133
|
"Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
|
|
109
134
|
export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
|
|
135
|
+
/** Under the free play switch: the word is part of the listing file, as the sign-in word is, so Nanoterra's review decides what Nanoterra shows. */
|
|
136
|
+
export const FREE_PLAY_NOTE = "Your choice is saved here at once. Free play is part of your listing file too, and Nanoterra's review decides what Nanoterra's listing says for a game that is listed.";
|
|
110
137
|
|
|
111
138
|
/** The state of a switch from the settings, and the patch that a press sends (the other state). */
|
|
112
|
-
export const stateOf = (id: SwitchId, s: GameSettings): boolean =>
|
|
139
|
+
export const stateOf = (id: SwitchId, s: GameSettings): boolean =>
|
|
140
|
+
id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : id === "freePlay" ? freePlayOn(s) : signinOn(s.signin);
|
|
113
141
|
export function pressOf(id: SwitchId, s: GameSettings): GamePatch {
|
|
114
142
|
if (id === "character") return { character: s.character === "nanoterra" ? "own" : "nanoterra" };
|
|
115
143
|
if (id === "emotes") return { emotes: stateOf("emotes", s) ? "off" : "on" };
|
|
144
|
+
if (id === "freePlay") return { freePlay: freePlayOn(s) ? "off" : "on" };
|
|
116
145
|
return { signin: signinOn(s.signin) ? "none" : "exchange" };
|
|
117
146
|
}
|
package/src/input/index.ts
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// calls your `pause` when the player really left (a free round stops its clock and shows a Resume key). No package: plain browser events.
|
|
17
17
|
//
|
|
18
18
|
// A ROUND THAT COSTS MONEY must not pause: its deadline must not move. Keep `releaseWhenLost` (the character always stops) and leave `pauseWhenLeft` out.
|
|
19
|
+
//
|
|
20
|
+
// The camera's distance (the wheel, a pinch, the + and − keys, one curve) is `createZoom` and `listenZoom`, in ./zoom.ts.
|
|
21
|
+
|
|
22
|
+
export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
|
|
23
|
+
export type { Zoom, ZoomRange } from "./zoom.js";
|
|
19
24
|
|
|
20
25
|
type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
|
21
26
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The camera's distance for a game the player walks in: the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch
|
|
2
|
+
// screen, and the + and − keys move one number, the camera's distance from the eyes, along ONE curve. 0 is first person; below `first` the camera
|
|
3
|
+
// is in the eyes. It eases to the distance asked for (at once with reduced motion). No package: plain browser events.
|
|
4
|
+
//
|
|
5
|
+
// THE FEEL is nano133.com's island (app/island/zoom.ts). Each notch moves the distance in proportion to it (`0.35 + 0.14 × distance` metres a
|
|
6
|
+
// notch), so a notch feels the same near and far. Every bit of a scroll counts: a dead zone and a speed limit dropped most of a quick spin in
|
|
7
|
+
// Echo Island, and its wheel needed about five times the spins of the island's (Wes, 2026-11-01). Close in, the last notch goes all the way to
|
|
8
|
+
// the eyes; out of the eyes, the first notch goes past the switch, so the camera never rests half inside the head.
|
|
9
|
+
//
|
|
10
|
+
// THE PINCH follows the same curve: the curve is a constant ratio of `distance + 2.5`, so spreading the fingers to twice their start divides it
|
|
11
|
+
// by 2^PINCH.power, wherever the camera is.
|
|
12
|
+
|
|
13
|
+
/** Where the camera may be (metres from the eyes): the farthest, the default, and the switch: nearer than `first` it is in the eyes. */
|
|
14
|
+
export type ZoomRange = { max: number; start: number; first: number };
|
|
15
|
+
|
|
16
|
+
/** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
|
|
17
|
+
export const ZOOM_STEP = { perPx: 0.006, perPxPinch: 0.03, linePx: 16, pagePx: 400, key: 1.2 } as const;
|
|
18
|
+
/** The curve: a notch moves `base + scale × distance` metres. */
|
|
19
|
+
export const ZOOM_CURVE = { base: 0.35, scale: 0.14 } as const;
|
|
20
|
+
/** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
|
|
21
|
+
export const PINCH = { power: 1.3 } as const;
|
|
22
|
+
/** How fast the camera eases to the distance asked for (1/s). */
|
|
23
|
+
export const ZOOM_EASE = 9;
|
|
24
|
+
|
|
25
|
+
const clamp = (r: ZoomRange, d: number) => Math.min(r.max, Math.max(0, d));
|
|
26
|
+
|
|
27
|
+
/** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
|
|
28
|
+
export function wheelNotches(e: { deltaY: number; deltaMode?: number; ctrlKey?: boolean }): number {
|
|
29
|
+
const px = e.deltaMode === 1 ? e.deltaY * ZOOM_STEP.linePx : e.deltaMode === 2 ? e.deltaY * ZOOM_STEP.pagePx : e.deltaY;
|
|
30
|
+
return px * (e.ctrlKey ? ZOOM_STEP.perPxPinch : ZOOM_STEP.perPx);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The snap: close in, a move into the switch goes to the eyes; out of the eyes, a move out goes past the switch. */
|
|
34
|
+
function snapped(r: ZoomRange, from: number, to: number): number {
|
|
35
|
+
if (to < from && to < r.first) return 0;
|
|
36
|
+
if (to > from && from < r.first) return clamp(r, Math.max(to, r.first + 1));
|
|
37
|
+
return clamp(r, to);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The distance after `notches` (negative: in) from `want`. */
|
|
41
|
+
export function zoomedBy(r: ZoomRange, want: number, notches: number): number {
|
|
42
|
+
return snapped(r, want, want + notches * (ZOOM_CURVE.base + want * ZOOM_CURVE.scale));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
|
|
46
|
+
export function pinchedTo(r: ZoomRange, from: number, spread0: number, spread: number): number {
|
|
47
|
+
if (!(spread0 > 0) || !(spread > 0)) return from;
|
|
48
|
+
const k = ZOOM_CURVE.base / ZOOM_CURVE.scale;
|
|
49
|
+
return snapped(r, from, (from + k) * (spread0 / spread) ** PINCH.power - k);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type Zoom = {
|
|
53
|
+
/** The distance asked for (metres from the eyes). */
|
|
54
|
+
want(): number;
|
|
55
|
+
/** The distance now, as the last `step` eased it. */
|
|
56
|
+
now(): number;
|
|
57
|
+
/** True while the camera is in the eyes (the distance now below `first`). */
|
|
58
|
+
eyes(): boolean;
|
|
59
|
+
/** Asks for a distance (clamped), writes it and tells the listeners. */
|
|
60
|
+
set(d: number): void;
|
|
61
|
+
/** Moves the distance by notches (negative: in), along the curve. */
|
|
62
|
+
by(notches: number): void;
|
|
63
|
+
/** A wheel event (or one like it) moves the distance. */
|
|
64
|
+
wheel(e: { deltaY: number; deltaMode?: number; ctrlKey?: boolean }): void;
|
|
65
|
+
/** A touch pinch starts with the fingers `spread` apart (px); `pinch` follows it; `pinchEnd` ends it. */
|
|
66
|
+
pinchStart(spread: number): void;
|
|
67
|
+
pinch(spread: number): void;
|
|
68
|
+
pinchEnd(): void;
|
|
69
|
+
/** One frame: the distance eases to the one asked for (at once when `calm`, reduced motion). Returns it. */
|
|
70
|
+
step(dt: number, calm: boolean): number;
|
|
71
|
+
/** Calls `on` when the distance asked for changes. Returns the function that stops it. */
|
|
72
|
+
subscribe(on: () => void): () => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
|
|
77
|
+
* asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
|
|
78
|
+
*/
|
|
79
|
+
export function createZoom(r: ZoomRange, keep: { read?: () => number | null; write?: (d: number) => void } = {}): Zoom {
|
|
80
|
+
let want = r.start;
|
|
81
|
+
let now = r.start;
|
|
82
|
+
let loaded = false;
|
|
83
|
+
let pinchFrom: { d: number; spread: number } | null = null;
|
|
84
|
+
const listeners = new Set<() => void>();
|
|
85
|
+
const load = () => {
|
|
86
|
+
if (loaded) return;
|
|
87
|
+
loaded = true;
|
|
88
|
+
const v = keep.read?.();
|
|
89
|
+
if (typeof v === "number" && Number.isFinite(v)) want = now = clamp(r, v);
|
|
90
|
+
};
|
|
91
|
+
const set = (d: number) => {
|
|
92
|
+
load();
|
|
93
|
+
want = clamp(r, d);
|
|
94
|
+
keep.write?.(want);
|
|
95
|
+
for (const on of listeners) on();
|
|
96
|
+
};
|
|
97
|
+
const by = (notches: number) => {
|
|
98
|
+
load();
|
|
99
|
+
if (notches !== 0) set(zoomedBy(r, want, notches));
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
want: () => (load(), want),
|
|
103
|
+
now: () => (load(), now),
|
|
104
|
+
eyes: () => (load(), now < r.first),
|
|
105
|
+
set,
|
|
106
|
+
by,
|
|
107
|
+
wheel: (e) => by(wheelNotches(e)),
|
|
108
|
+
pinchStart(spread) {
|
|
109
|
+
load();
|
|
110
|
+
pinchFrom = { d: want, spread };
|
|
111
|
+
},
|
|
112
|
+
pinch(spread) {
|
|
113
|
+
if (pinchFrom) set(pinchedTo(r, pinchFrom.d, pinchFrom.spread, spread));
|
|
114
|
+
},
|
|
115
|
+
pinchEnd() {
|
|
116
|
+
pinchFrom = null;
|
|
117
|
+
},
|
|
118
|
+
step(dt, calm) {
|
|
119
|
+
load();
|
|
120
|
+
now = calm ? want : now + (want - now) * (1 - Math.exp(-dt * ZOOM_EASE));
|
|
121
|
+
if (Math.abs(now - want) < 0.002) now = want;
|
|
122
|
+
return now;
|
|
123
|
+
},
|
|
124
|
+
subscribe(on) {
|
|
125
|
+
listeners.add(on);
|
|
126
|
+
return () => void listeners.delete(on);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
|
|
135
|
+
* `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
|
|
136
|
+
* for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
|
|
137
|
+
* The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
|
|
138
|
+
*/
|
|
139
|
+
export function listenZoom(zoom: Zoom, el: Listens, opts: { skip?: (e: Event) => boolean; keys?: boolean } = {}, win: Listens = window): () => void {
|
|
140
|
+
const wheel = (e: Event) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
if (opts.skip?.(e)) return;
|
|
143
|
+
zoom.wheel(e as WheelEvent);
|
|
144
|
+
};
|
|
145
|
+
const key = (e: Event) => {
|
|
146
|
+
const k = e as KeyboardEvent;
|
|
147
|
+
if (k.metaKey || k.altKey || k.ctrlKey || opts.skip?.(e)) return;
|
|
148
|
+
const t = k.target as HTMLElement | null;
|
|
149
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName ?? ""))) return;
|
|
150
|
+
if (k.code === "Equal" || k.code === "NumpadAdd") zoom.by(-ZOOM_STEP.key);
|
|
151
|
+
else if (k.code === "Minus" || k.code === "NumpadSubtract") zoom.by(ZOOM_STEP.key);
|
|
152
|
+
};
|
|
153
|
+
el.addEventListener("wheel", wheel, { passive: false });
|
|
154
|
+
const keys = opts.keys !== false;
|
|
155
|
+
if (keys) win.addEventListener("keydown", key);
|
|
156
|
+
return () => {
|
|
157
|
+
el.removeEventListener("wheel", wheel);
|
|
158
|
+
if (keys) win.removeEventListener("keydown", key);
|
|
159
|
+
};
|
|
160
|
+
}
|