@edv4h/usketch-plugin-character 0.2.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/dist/character-camera.d.ts +33 -0
  4. package/dist/character-camera.d.ts.map +1 -0
  5. package/dist/character-camera.js +45 -0
  6. package/dist/character-camera.js.map +1 -0
  7. package/dist/character-input.d.ts +18 -0
  8. package/dist/character-input.d.ts.map +1 -0
  9. package/dist/character-input.js +89 -0
  10. package/dist/character-input.js.map +1 -0
  11. package/dist/character-layer.d.ts +7 -0
  12. package/dist/character-layer.d.ts.map +1 -0
  13. package/dist/character-layer.js +53 -0
  14. package/dist/character-layer.js.map +1 -0
  15. package/dist/character-physics.d.ts +55 -0
  16. package/dist/character-physics.d.ts.map +1 -0
  17. package/dist/character-physics.js +97 -0
  18. package/dist/character-physics.js.map +1 -0
  19. package/dist/character-runtime.d.ts +30 -0
  20. package/dist/character-runtime.d.ts.map +1 -0
  21. package/dist/character-runtime.js +229 -0
  22. package/dist/character-runtime.js.map +1 -0
  23. package/dist/character-service.d.ts +47 -0
  24. package/dist/character-service.d.ts.map +1 -0
  25. package/dist/character-service.js +36 -0
  26. package/dist/character-service.js.map +1 -0
  27. package/dist/character-store.d.ts +55 -0
  28. package/dist/character-store.d.ts.map +1 -0
  29. package/dist/character-store.js +100 -0
  30. package/dist/character-store.js.map +1 -0
  31. package/dist/character-sync.d.ts +42 -0
  32. package/dist/character-sync.d.ts.map +1 -0
  33. package/dist/character-sync.js +117 -0
  34. package/dist/character-sync.js.map +1 -0
  35. package/dist/character-types.d.ts +52 -0
  36. package/dist/character-types.d.ts.map +1 -0
  37. package/dist/character-types.js +7 -0
  38. package/dist/character-types.js.map +1 -0
  39. package/dist/index.d.ts +8 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +8 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/plugin.d.ts +36 -0
  44. package/dist/plugin.d.ts.map +1 -0
  45. package/dist/plugin.js +38 -0
  46. package/dist/plugin.js.map +1 -0
  47. package/dist/register-character-hud.d.ts +4 -0
  48. package/dist/register-character-hud.d.ts.map +1 -0
  49. package/dist/register-character-hud.js +122 -0
  50. package/dist/register-character-hud.js.map +1 -0
  51. package/package.json +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EdV4H
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @edv4h/usketch-plugin-character
2
+
3
+ A controllable character for uSketch: place it at a chosen spot on screen, drive it
4
+ with customizable keys (WASD by default), keep track of which way it faces, and have
5
+ the camera follow it — either keeping the world upright or turning the world with
6
+ the character's heading (racing view, via the Core viewport `rotation`). Other
7
+ users' characters appear alongside yours through Yjs awareness.
8
+
9
+ **The plugin draws nothing.** It computes positions and headings and hands them to
10
+ your `renderCharacter`, so the look (sprite, car, avatar, labels, animation) is
11
+ entirely the host's.
12
+
13
+ ```ts
14
+ import { createCharacterPlugin } from "@edv4h/usketch-plugin-character";
15
+
16
+ createCharacterPlugin({
17
+ renderCharacter: ({ screenHeading, appearance, name }) => (
18
+ <div style={{ transform: `rotate(${screenHeading}deg)`, color: String(appearance.color) }}>▲</div>
19
+ ),
20
+ appearance: { color: "#e44" }, // broadcast, so others draw you the same way
21
+ wsProvider, // optional: omit for single-player
22
+ userName: "Alice",
23
+ keys: { up: ["KeyW", "ArrowUp"] }, // KeyboardEvent.code values
24
+ cameraMode: "rotate", // "fixed" | "rotate" | "free"
25
+ controlScheme: "vehicle", // "directional" | "vehicle"
26
+ screenAnchor: { x: 0.5, y: 0.75 },
27
+ });
28
+ ```
29
+
30
+ ## Concepts
31
+
32
+ - **Heading**: degrees, `0` = up (world −y), clockwise-positive — the same convention
33
+ as CSS `rotate()` and the viewport `rotation`.
34
+ - **Render props**: `heading` (world), `screenHeading` (= `heading + viewport.rotation`,
35
+ what to rotate a top-down sprite by), `moving`, `speed`, `zoom`, `appearance`,
36
+ `name`, `isSelf`, `id`. Each character is placed centered on its screen position
37
+ in one screen-space layer, so self and remote characters look the same under zoom
38
+ and camera rotation.
39
+ - **Control schemes**
40
+ - `directional` — keys move in screen directions (camera-relative); the heading
41
+ turns toward the movement at `turnRate`. With the `rotate` camera, left/right
42
+ therefore circle (the camera follows the turn).
43
+ - `vehicle` — up/down throttle and reverse, left/right steer. Steering scales
44
+ with speed (a stopped car can't turn) and inverts in reverse.
45
+ - **Camera modes**
46
+ - `fixed` — follow, world stays upright.
47
+ - `rotate` — follow and turn the world by `-heading` (character always faces up).
48
+ - `free` — don't move the camera.
49
+
50
+ Panning by hand pauses following; press a movement key to resume. Zooming keeps
51
+ following. Turning the character off turns the camera back upright.
52
+ - **Multiplayer**: pass `wsProvider` (anything with a y-protocols `awareness`). Your
53
+ character is published under the `character` awareness field (~20 Hz, only when it
54
+ changes) and others are eased between updates. It's ephemeral: a character
55
+ vanishes when its owner disconnects or turns it off. Nothing is stored as shapes.
56
+ - **Input**: held keys are tracked from window `keydown`/`keyup` (capture phase,
57
+ `event.code`), swallowed while the character is on so single-letter tool
58
+ shortcuts don't fire. Typing in text fields, and key chords with Ctrl/Cmd/Alt,
59
+ are ignored.
60
+
61
+ ## HUD
62
+
63
+ A "キャラクター" settings group (on/off, camera, scheme, speed, turn rate, screen
64
+ position, camera lag) plus actions to toggle the character and to place it at the
65
+ screen position.
66
+
67
+ ## Service
68
+
69
+ ```ts
70
+ import { getCharacterApi } from "@edv4h/usketch-plugin-character";
71
+
72
+ const api = getCharacterApi(app.services);
73
+ api?.enable();
74
+ api?.setCameraMode("rotate");
75
+ api?.setRenderer(myRenderer);
76
+ api?.getPose(); // { x, y, heading, speed, moving }
77
+ ```
78
+
79
+ ## Limitations
80
+
81
+ While the camera is rotated, overlays that compute screen positions themselves
82
+ (snap guides, resize handles, connectors, comment badges, the background grid)
83
+ are not rotation-aware yet. Tools that go through the Core `screenToWorld` get
84
+ correct coordinates.
@@ -0,0 +1,33 @@
1
+ import { type Point, type Viewport } from "@edv4h/usketch-shared";
2
+ /**
3
+ * - `fixed`: follow; the world stays axis-aligned (rotation 0) and the character
4
+ * turns on screen.
5
+ * - `rotate`: follow AND turn the world by `-heading`, so the character always
6
+ * faces screen-up (racing view).
7
+ * - `free`: don't touch the camera.
8
+ */
9
+ export type CameraMode = "fixed" | "rotate" | "free";
10
+ /** The smoothed camera target: the world point pinned to the anchor + rotation. */
11
+ export interface CameraFocus {
12
+ focus: Point;
13
+ rotation: number;
14
+ }
15
+ /** The camera rotation a mode wants for a heading. */
16
+ export declare function targetRotation(mode: CameraMode, heading: number): number;
17
+ /**
18
+ * Frame-rate-independent smoothing factor: `smoothing` 0 = rigid (always 1),
19
+ * approaching 1 = heavier lag. It is the fraction LEFT after 1/60 s.
20
+ */
21
+ export declare function smoothingAlpha(smoothing: number, dt: number): number;
22
+ /** Ease `current` toward `target` by `alpha` (rotation the short way round). */
23
+ export declare function easeFocus(current: CameraFocus, target: CameraFocus, alpha: number): CameraFocus;
24
+ /** Screen-pixel anchor from a fractional anchor and the canvas size. */
25
+ export declare function anchorPx(anchor: Point, size: {
26
+ width: number;
27
+ height: number;
28
+ }): Point;
29
+ /** The viewport that pins `cam.focus` to `anchor` (px) at `zoom`, turned by `cam.rotation`. */
30
+ export declare function followViewport(cam: CameraFocus, anchor: Point, zoom: number): Viewport;
31
+ /** True when two viewports are the same to within sub-pixel / sub-degree noise. */
32
+ export declare function sameViewport(a: Viewport, b: Viewport): boolean;
33
+ //# sourceMappingURL=character-camera.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-camera.d.ts","sourceRoot":"","sources":["../src/character-camera.ts"],"names":[],"mappings":"AAEA,OAAO,EACN,KAAK,KAAK,EAEV,KAAK,QAAQ,EAGb,MAAM,uBAAuB,CAAC;AAE/B;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAErD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,KAAK,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,sDAAsD;AACtD,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAExE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,gFAAgF;AAChF,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,WAAW,CAW/F;AAED,wEAAwE;AACxE,wBAAgB,QAAQ,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CAEtF;AAED,+FAA+F;AAC/F,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,QAAQ,CAEtF;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,OAAO,CAO9D"}
@@ -0,0 +1,45 @@
1
+ // Pure follow-camera math: where the viewport should be so the character sits at
2
+ // its screen anchor, optionally turning the world so the character faces up.
3
+ import { shortestAngleDelta, viewportAnchoredAt, wrapDeg, } from "@edv4h/usketch-shared";
4
+ /** The camera rotation a mode wants for a heading. */
5
+ export function targetRotation(mode, heading) {
6
+ return mode === "rotate" ? wrapDeg(-heading) : 0;
7
+ }
8
+ /**
9
+ * Frame-rate-independent smoothing factor: `smoothing` 0 = rigid (always 1),
10
+ * approaching 1 = heavier lag. It is the fraction LEFT after 1/60 s.
11
+ */
12
+ export function smoothingAlpha(smoothing, dt) {
13
+ const s = Math.min(0.99, Math.max(0, smoothing));
14
+ if (s === 0)
15
+ return 1;
16
+ return 1 - s ** (dt * 60);
17
+ }
18
+ /** Ease `current` toward `target` by `alpha` (rotation the short way round). */
19
+ export function easeFocus(current, target, alpha) {
20
+ if (alpha >= 1)
21
+ return target;
22
+ return {
23
+ focus: {
24
+ x: current.focus.x + (target.focus.x - current.focus.x) * alpha,
25
+ y: current.focus.y + (target.focus.y - current.focus.y) * alpha,
26
+ },
27
+ rotation: wrapDeg(current.rotation + shortestAngleDelta(current.rotation, target.rotation) * alpha),
28
+ };
29
+ }
30
+ /** Screen-pixel anchor from a fractional anchor and the canvas size. */
31
+ export function anchorPx(anchor, size) {
32
+ return { x: anchor.x * size.width, y: anchor.y * size.height };
33
+ }
34
+ /** The viewport that pins `cam.focus` to `anchor` (px) at `zoom`, turned by `cam.rotation`. */
35
+ export function followViewport(cam, anchor, zoom) {
36
+ return viewportAnchoredAt(cam.focus, anchor, zoom, cam.rotation);
37
+ }
38
+ /** True when two viewports are the same to within sub-pixel / sub-degree noise. */
39
+ export function sameViewport(a, b) {
40
+ return (Math.abs(a.x - b.x) < 0.01 &&
41
+ Math.abs(a.y - b.y) < 0.01 &&
42
+ Math.abs(a.zoom - b.zoom) < 1e-6 &&
43
+ Math.abs(shortestAngleDelta(a.rotation ?? 0, b.rotation ?? 0)) < 1e-4);
44
+ }
45
+ //# sourceMappingURL=character-camera.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-camera.js","sourceRoot":"","sources":["../src/character-camera.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,6EAA6E;AAC7E,OAAO,EAEN,kBAAkB,EAElB,kBAAkB,EAClB,OAAO,GACP,MAAM,uBAAuB,CAAC;AAiB/B,sDAAsD;AACtD,MAAM,UAAU,cAAc,CAAC,IAAgB,EAAE,OAAe;IAC/D,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAClD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB,EAAE,EAAU;IAC3D,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IACjD,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtB,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC;AAC3B,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,SAAS,CAAC,OAAoB,EAAE,MAAmB,EAAE,KAAa;IACjF,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,MAAM,CAAC;IAC9B,OAAO;QACN,KAAK,EAAE;YACN,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK;YAC/D,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK;SAC/D;QACD,QAAQ,EAAE,OAAO,CAChB,OAAO,CAAC,QAAQ,GAAG,kBAAkB,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,GAAG,KAAK,CAChF;KACD,CAAC;AACH,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,QAAQ,CAAC,MAAa,EAAE,IAAuC;IAC9E,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;AAChE,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,cAAc,CAAC,GAAgB,EAAE,MAAa,EAAE,IAAY;IAC3E,OAAO,kBAAkB,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;AAClE,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,YAAY,CAAC,CAAW,EAAE,CAAW;IACpD,OAAO,CACN,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;QAC1B,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;QAC1B,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,IAAI;QAChC,IAAI,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,EAAE,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CACrE,CAAC;AACH,CAAC"}
@@ -0,0 +1,18 @@
1
+ import type { MoveInput } from "./character-physics.js";
2
+ import type { CharacterKeys } from "./character-types.js";
3
+ export interface KeyInput {
4
+ /** The currently held directions. */
5
+ read(): MoveInput;
6
+ /** Whether any bound key is held. */
7
+ anyHeld(): boolean;
8
+ /** Replace the key bindings (held state is cleared). */
9
+ setKeys(keys: CharacterKeys): void;
10
+ dispose(): void;
11
+ }
12
+ /**
13
+ * Listen for the bound keys on `window`. `isActive()` gates everything: while it is
14
+ * false no key is captured (and none is swallowed). Held keys are cleared on window
15
+ * blur / tab hide so a key released elsewhere can't get stuck "down".
16
+ */
17
+ export declare function createKeyInput(initialKeys: CharacterKeys, isActive: () => boolean): KeyInput;
18
+ //# sourceMappingURL=character-input.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-input.d.ts","sourceRoot":"","sources":["../src/character-input.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE1D,MAAM,WAAW,QAAQ;IACxB,qCAAqC;IACrC,IAAI,IAAI,SAAS,CAAC;IAClB,qCAAqC;IACrC,OAAO,IAAI,OAAO,CAAC;IACnB,wDAAwD;IACxD,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IACnC,OAAO,IAAI,IAAI,CAAC;CAChB;AAuBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,GAAG,QAAQ,CA6D5F"}
@@ -0,0 +1,89 @@
1
+ /** `<input type>`s that don't take typed text (a focused HUD checkbox must not eat WASD). */
2
+ const NON_TEXT_INPUTS = new Set([
3
+ "checkbox",
4
+ "radio",
5
+ "button",
6
+ "submit",
7
+ "reset",
8
+ "range",
9
+ "color",
10
+ "file",
11
+ "image",
12
+ ]);
13
+ function isTextInput(target) {
14
+ const el = target;
15
+ if (!el || typeof el.tagName !== "string")
16
+ return false;
17
+ const tag = el.tagName;
18
+ if (tag === "INPUT")
19
+ return !NON_TEXT_INPUTS.has((el.type ?? "text").toLowerCase());
20
+ return tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;
21
+ }
22
+ /**
23
+ * Listen for the bound keys on `window`. `isActive()` gates everything: while it is
24
+ * false no key is captured (and none is swallowed). Held keys are cleared on window
25
+ * blur / tab hide so a key released elsewhere can't get stuck "down".
26
+ */
27
+ export function createKeyInput(initialKeys, isActive) {
28
+ let keys = initialKeys;
29
+ const held = new Set();
30
+ const directionOf = (code) => {
31
+ for (const dir of ["up", "down", "left", "right"]) {
32
+ if (keys[dir].includes(code))
33
+ return dir;
34
+ }
35
+ return null;
36
+ };
37
+ const onKeyDown = (e) => {
38
+ if (!isActive() || isTextInput(e.target))
39
+ return;
40
+ if (e.ctrlKey || e.metaKey || e.altKey)
41
+ return; // leave chords (Cmd+S …) alone
42
+ if (!directionOf(e.code))
43
+ return;
44
+ held.add(e.code);
45
+ e.preventDefault();
46
+ e.stopImmediatePropagation();
47
+ };
48
+ const onKeyUp = (e) => {
49
+ if (!held.delete(e.code))
50
+ return;
51
+ e.preventDefault();
52
+ e.stopImmediatePropagation();
53
+ };
54
+ const clear = () => held.clear();
55
+ const onVisibility = () => {
56
+ if (typeof document !== "undefined" && document.hidden)
57
+ clear();
58
+ };
59
+ const hasWindow = typeof window !== "undefined";
60
+ if (hasWindow) {
61
+ window.addEventListener("keydown", onKeyDown, true);
62
+ window.addEventListener("keyup", onKeyUp, true);
63
+ window.addEventListener("blur", clear);
64
+ document.addEventListener("visibilitychange", onVisibility);
65
+ }
66
+ const isHeld = (dir) => keys[dir].some((c) => held.has(c));
67
+ return {
68
+ read: () => ({
69
+ up: isHeld("up"),
70
+ down: isHeld("down"),
71
+ left: isHeld("left"),
72
+ right: isHeld("right"),
73
+ }),
74
+ anyHeld: () => held.size > 0,
75
+ setKeys(next) {
76
+ keys = next;
77
+ held.clear();
78
+ },
79
+ dispose() {
80
+ if (!hasWindow)
81
+ return;
82
+ window.removeEventListener("keydown", onKeyDown, true);
83
+ window.removeEventListener("keyup", onKeyUp, true);
84
+ window.removeEventListener("blur", clear);
85
+ document.removeEventListener("visibilitychange", onVisibility);
86
+ },
87
+ };
88
+ }
89
+ //# sourceMappingURL=character-input.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-input.js","sourceRoot":"","sources":["../src/character-input.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import { type Viewport } from "@edv4h/usketch-shared";
2
+ import type { CharacterStore } from "./character-store.js";
3
+ export declare function CharacterLayer({ store, viewport }: {
4
+ store: CharacterStore;
5
+ viewport: Viewport;
6
+ }): import("react").JSX.Element | null;
7
+ //# sourceMappingURL=character-layer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-layer.d.ts","sourceRoot":"","sources":["../src/character-layer.tsx"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,QAAQ,EAA4C,MAAM,uBAAuB,CAAC;AAEhG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AA0B3D,wBAAgB,cAAc,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;IAAE,KAAK,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,sCAuDhG"}
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The one fixed (screen-space) layer that places every character — self and remote
3
+ // — at `worldToScreen(position)`. It draws NOTHING itself: each spot is a zero-size,
4
+ // pointer-transparent wrapper whose content is the host's `renderCharacter`. One
5
+ // screen-space layer keeps self/remote consistent (same size under zoom, same
6
+ // `screenHeading` math under camera rotation).
7
+ import { viewportRotation, worldToScreen, wrapDeg } from "@edv4h/usketch-shared";
8
+ import { useSyncExternalStore } from "react";
9
+ function Spot({ x, y, renderer, props, }) {
10
+ return (_jsx("div", { "data-character-id": props.id, style: { position: "absolute", left: x, top: y, width: 0, height: 0, pointerEvents: "none" }, children: _jsx("div", { style: { position: "absolute", left: 0, top: 0, transform: "translate(-50%, -50%)" }, children: renderer(props) }) }));
11
+ }
12
+ export function CharacterLayer({ store, viewport }) {
13
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
14
+ const renderer = store.getRenderer();
15
+ if (!renderer)
16
+ return null; // headless: no look supplied → draw nothing
17
+ const rot = viewportRotation(viewport);
18
+ const spots = [];
19
+ for (const r of state.remotes.values()) {
20
+ const p = worldToScreen(r.x, r.y, viewport);
21
+ spots.push(_jsx(Spot, { x: p.x, y: p.y, renderer: renderer, props: {
22
+ id: r.id,
23
+ isSelf: false,
24
+ heading: r.heading,
25
+ screenHeading: wrapDeg(r.heading + rot),
26
+ moving: r.moving,
27
+ speed: r.speed,
28
+ zoom: viewport.zoom,
29
+ appearance: r.appearance,
30
+ name: r.name,
31
+ } }, `remote-${r.id}`));
32
+ }
33
+ if (state.enabled && state.spawned) {
34
+ const { pose } = state;
35
+ const p = worldToScreen(pose.x, pose.y, viewport);
36
+ // Self last → drawn on top of the others.
37
+ spots.push(_jsx(Spot, { x: p.x, y: p.y, renderer: renderer, props: {
38
+ id: "self",
39
+ isSelf: true,
40
+ heading: pose.heading,
41
+ screenHeading: wrapDeg(pose.heading + rot),
42
+ moving: pose.moving,
43
+ speed: pose.speed,
44
+ zoom: viewport.zoom,
45
+ appearance: state.appearance,
46
+ name: state.name,
47
+ } }, "self"));
48
+ }
49
+ if (spots.length === 0)
50
+ return null;
51
+ return _jsx("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: spots });
52
+ }
53
+ //# sourceMappingURL=character-layer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-layer.js","sourceRoot":"","sources":["../src/character-layer.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ export type ControlScheme = "directional" | "vehicle";
2
+ /** Which movement directions are currently held. */
3
+ export interface MoveInput {
4
+ up: boolean;
5
+ down: boolean;
6
+ left: boolean;
7
+ right: boolean;
8
+ }
9
+ export declare const NO_INPUT: MoveInput;
10
+ export interface CharacterPose {
11
+ /** World position. */
12
+ x: number;
13
+ y: number;
14
+ /** Facing, degrees (0 = up / -y, clockwise). */
15
+ heading: number;
16
+ /** Signed speed along the heading, world units / s (negative = reversing). */
17
+ speed: number;
18
+ /** Whether the character moved this step. */
19
+ moving: boolean;
20
+ }
21
+ export interface MotionParams {
22
+ /** Top speed, world units / s. */
23
+ moveSpeed: number;
24
+ /** Turn rate, degrees / s. */
25
+ turnRate: number;
26
+ /** Vehicle: throttle acceleration, world units / s². */
27
+ acceleration: number;
28
+ /** Vehicle: coasting / braking deceleration, world units / s². */
29
+ friction: number;
30
+ /** Vehicle: reverse top speed as a fraction of `moveSpeed`. */
31
+ reverseRatio: number;
32
+ }
33
+ export declare const DEFAULT_MOTION: MotionParams;
34
+ /** Unit vector of a heading in world space (0° → (0, -1), 90° → (1, 0)). */
35
+ export declare function headingVector(deg: number): {
36
+ x: number;
37
+ y: number;
38
+ };
39
+ /** Heading (degrees) of a non-zero world vector. */
40
+ export declare function vectorHeading(x: number, y: number): number;
41
+ /** Move `from` toward `to` by at most `maxStep` degrees, the short way round. */
42
+ export declare function turnToward(from: number, to: number, maxStep: number): number;
43
+ /**
44
+ * Advance `pose` by `dt` seconds.
45
+ *
46
+ * - `directional`: the held keys give a SCREEN-space direction (up = screen up),
47
+ * turned into world space through the camera rotation; the character moves that
48
+ * way at `moveSpeed` and its heading turns toward it at `turnRate`. (Turning
49
+ * rather than snapping keeps a heading-following camera from spinning.)
50
+ * - `vehicle`: up/down are throttle/reverse along the heading, left/right steer.
51
+ * Steering authority scales with speed (a stopped car can't turn) and inverts
52
+ * when reversing, like a car.
53
+ */
54
+ export declare function stepCharacter(pose: CharacterPose, input: MoveInput, dt: number, scheme: ControlScheme, params: MotionParams, cameraRotation?: number): CharacterPose;
55
+ //# sourceMappingURL=character-physics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-physics.d.ts","sourceRoot":"","sources":["../src/character-physics.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,SAAS,CAAC;AAEtD,oDAAoD;AACpD,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CACf;AAED,eAAO,MAAM,QAAQ,EAAE,SAAiE,CAAC;AAEzF,MAAM,WAAW,aAAa;IAC7B,sBAAsB;IACtB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,MAAM,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC5B,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,cAAc,EAAE,YAM5B,CAAC;AAEF,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAGnE;AAED,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAI5E;AAOD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC5B,IAAI,EAAE,aAAa,EACnB,KAAK,EAAE,SAAS,EAChB,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,YAAY,EACpB,cAAc,SAAI,GAChB,aAAa,CAoDf"}
@@ -0,0 +1,97 @@
1
+ // Pure motion model (no React / DOM): input + dt → next pose. Headings are degrees,
2
+ // 0 = up (world -y), clockwise-positive — the same convention as CSS `rotate()` and
3
+ // the Core viewport `rotation`, so `screenHeading = heading + viewport.rotation`.
4
+ import { shortestAngleDelta, wrapDeg } from "@edv4h/usketch-shared";
5
+ export const NO_INPUT = { up: false, down: false, left: false, right: false };
6
+ export const DEFAULT_MOTION = {
7
+ moveSpeed: 400,
8
+ turnRate: 240,
9
+ acceleration: 900,
10
+ friction: 700,
11
+ reverseRatio: 0.5,
12
+ };
13
+ /** Unit vector of a heading in world space (0° → (0, -1), 90° → (1, 0)). */
14
+ export function headingVector(deg) {
15
+ const rad = (deg * Math.PI) / 180;
16
+ return { x: Math.sin(rad), y: -Math.cos(rad) };
17
+ }
18
+ /** Heading (degrees) of a non-zero world vector. */
19
+ export function vectorHeading(x, y) {
20
+ return wrapDeg((Math.atan2(x, -y) * 180) / Math.PI);
21
+ }
22
+ /** Move `from` toward `to` by at most `maxStep` degrees, the short way round. */
23
+ export function turnToward(from, to, maxStep) {
24
+ const d = shortestAngleDelta(from, to);
25
+ if (Math.abs(d) <= maxStep)
26
+ return wrapDeg(to);
27
+ return wrapDeg(from + Math.sign(d) * maxStep);
28
+ }
29
+ function approach(v, target, step) {
30
+ if (v < target)
31
+ return Math.min(target, v + step);
32
+ return Math.max(target, v - step);
33
+ }
34
+ /**
35
+ * Advance `pose` by `dt` seconds.
36
+ *
37
+ * - `directional`: the held keys give a SCREEN-space direction (up = screen up),
38
+ * turned into world space through the camera rotation; the character moves that
39
+ * way at `moveSpeed` and its heading turns toward it at `turnRate`. (Turning
40
+ * rather than snapping keeps a heading-following camera from spinning.)
41
+ * - `vehicle`: up/down are throttle/reverse along the heading, left/right steer.
42
+ * Steering authority scales with speed (a stopped car can't turn) and inverts
43
+ * when reversing, like a car.
44
+ */
45
+ export function stepCharacter(pose, input, dt, scheme, params, cameraRotation = 0) {
46
+ if (dt <= 0)
47
+ return pose;
48
+ if (scheme === "directional") {
49
+ const sx = (input.right ? 1 : 0) - (input.left ? 1 : 0);
50
+ const sy = (input.down ? 1 : 0) - (input.up ? 1 : 0);
51
+ if (sx === 0 && sy === 0) {
52
+ return pose.moving || pose.speed !== 0 ? { ...pose, speed: 0, moving: false } : pose;
53
+ }
54
+ // Screen direction → world direction: undo the camera rotation.
55
+ const target = wrapDeg(vectorHeading(sx, sy) - cameraRotation);
56
+ const dir = headingVector(target);
57
+ const dist = params.moveSpeed * dt;
58
+ return {
59
+ x: pose.x + dir.x * dist,
60
+ y: pose.y + dir.y * dist,
61
+ heading: turnToward(pose.heading, target, params.turnRate * dt),
62
+ speed: params.moveSpeed,
63
+ moving: true,
64
+ };
65
+ }
66
+ const throttle = (input.up ? 1 : 0) - (input.down ? 1 : 0);
67
+ let speed = pose.speed;
68
+ if (throttle > 0) {
69
+ // Braking from reverse uses friction + throttle for a snappier stop.
70
+ const accel = speed < 0 ? params.acceleration + params.friction : params.acceleration;
71
+ speed = approach(speed, params.moveSpeed, accel * dt);
72
+ }
73
+ else if (throttle < 0) {
74
+ const accel = speed > 0 ? params.acceleration + params.friction : params.acceleration;
75
+ speed = approach(speed, -params.moveSpeed * params.reverseRatio, accel * dt);
76
+ }
77
+ else {
78
+ speed = approach(speed, 0, params.friction * dt);
79
+ }
80
+ const steer = (input.right ? 1 : 0) - (input.left ? 1 : 0);
81
+ const authority = Math.min(1, (Math.abs(speed) / Math.max(1, params.moveSpeed)) * 3);
82
+ const heading = steer === 0 || authority === 0
83
+ ? pose.heading
84
+ : wrapDeg(pose.heading + steer * Math.sign(speed) * authority * params.turnRate * dt);
85
+ if (speed === 0 && heading === pose.heading) {
86
+ return pose.moving || pose.speed !== 0 ? { ...pose, speed: 0, moving: false } : pose;
87
+ }
88
+ const dir = headingVector(heading);
89
+ return {
90
+ x: pose.x + dir.x * speed * dt,
91
+ y: pose.y + dir.y * speed * dt,
92
+ heading,
93
+ speed,
94
+ moving: speed !== 0,
95
+ };
96
+ }
97
+ //# sourceMappingURL=character-physics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-physics.js","sourceRoot":"","sources":["../src/character-physics.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import { type PluginContext, type Point } from "@edv4h/usketch-shared";
2
+ import type { CharacterStore } from "./character-store.js";
3
+ import { type CharacterAwareness } from "./character-sync.js";
4
+ import type { CharacterKeys } from "./character-types.js";
5
+ export declare const CHARACTER_LAYER_ID = "character";
6
+ /** `viewport:claimed` priority: above start-position (10), below deep links (100). */
7
+ export declare const CHARACTER_VIEWPORT_PRIORITY = 20;
8
+ /**
9
+ * The main canvas's pixel size. Found via `[data-layer-id]` wrappers' parent (the
10
+ * app build strips `data-testid`, so `canvas-container` can't be queried); the
11
+ * largest wins when a minimap also renders layers. Falls back to the window.
12
+ */
13
+ export declare function canvasSize(): {
14
+ width: number;
15
+ height: number;
16
+ };
17
+ export interface CharacterRuntimeOptions {
18
+ keys: CharacterKeys;
19
+ awareness?: CharacterAwareness;
20
+ }
21
+ export interface CharacterRuntime {
22
+ /** Move the character to the world point under its screen anchor. */
23
+ placeAtAnchor(): void;
24
+ /** The screen-px anchor (for rotating the camera about the character's spot). */
25
+ anchorPoint(): Point;
26
+ setKeys(keys: CharacterKeys): void;
27
+ dispose(): void;
28
+ }
29
+ export declare function setupCharacterRuntime(ctx: PluginContext, store: CharacterStore, options: CharacterRuntimeOptions): CharacterRuntime;
30
+ //# sourceMappingURL=character-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character-runtime.d.ts","sourceRoot":"","sources":["../src/character-runtime.tsx"],"names":[],"mappings":"AAGA,OAAO,EACN,KAAK,aAAa,EAClB,KAAK,KAAK,EAIV,MAAM,uBAAuB,CAAC;AAa/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EACN,KAAK,kBAAkB,EAIvB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,aAAa,EAAmB,MAAM,sBAAsB,CAAC;AAE3E,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAG9C,sFAAsF;AACtF,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAe9C;;;;GAIG;AACH,wBAAgB,UAAU,IAAI;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAkB9D;AAED,MAAM,WAAW,uBAAuB;IACvC,IAAI,EAAE,aAAa,CAAC;IACpB,SAAS,CAAC,EAAE,kBAAkB,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAgB;IAChC,qEAAqE;IACrE,aAAa,IAAI,IAAI,CAAC;IACtB,iFAAiF;IACjF,WAAW,IAAI,KAAK,CAAC;IACrB,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IACnC,OAAO,IAAI,IAAI,CAAC;CAChB;AAED,wBAAgB,qBAAqB,CACpC,GAAG,EAAE,aAAa,EAClB,KAAK,EAAE,cAAc,EACrB,OAAO,EAAE,uBAAuB,GAC9B,gBAAgB,CAmKlB"}