@umicat/three-sdk 0.1.0 → 0.3.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/README.md +54 -0
- package/dist/CharacterAnimator.d.ts +58 -0
- package/dist/CharacterAnimator.js +103 -0
- package/dist/CharacterController3D.d.ts +46 -1
- package/dist/CharacterController3D.js +60 -2
- package/dist/Input3D.d.ts +38 -5
- package/dist/Input3D.js +134 -12
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -96,6 +96,60 @@ One real bug was fixed while chasing it and is unrelated but worth having: a
|
|
|
96
96
|
bones, so three.js culls a character against a stale volume once it moves.
|
|
97
97
|
`loadScene3D` sets `frustumCulled = false` on skinned meshes.
|
|
98
98
|
|
|
99
|
+
## 0.3.0 — actions, not just locomotion
|
|
100
|
+
|
|
101
|
+
`CharacterAnimator` owns the two things every game was writing by hand, and
|
|
102
|
+
both are character behaviour rather than game logic (ADR-034).
|
|
103
|
+
|
|
104
|
+
**Locomotion follows `character.state`**, not the keys held — a clip chosen
|
|
105
|
+
from input leaves a character walking in mid-air.
|
|
106
|
+
|
|
107
|
+
**An action is a one-shot that interrupts and returns.** Attacking is not a
|
|
108
|
+
state you enter, it is a thing that happens: `play('attack')` runs the clip
|
|
109
|
+
once, holds its last frame rather than snapping to the bind pose in the gap,
|
|
110
|
+
and hands control back. `busy` is there so a game can refuse a second swing
|
|
111
|
+
from one press — firing twice is the usual reason an attack feels broken.
|
|
112
|
+
|
|
113
|
+
Changing locomotion mid-action does **not** blend a walk cycle into the middle
|
|
114
|
+
of a sword swing: the new locomotion clip is swapped in underneath at zero
|
|
115
|
+
weight and faded up only when the action releases.
|
|
116
|
+
|
|
117
|
+
Names resolve through the manifest's map first (`attack` -> `attack-melee-right`)
|
|
118
|
+
and then by raw clip name, so a game can reach a clip nobody mapped.
|
|
119
|
+
|
|
120
|
+
## 0.2.0 — the character's jump and controls belong to the platform
|
|
121
|
+
|
|
122
|
+
ADR-034 makes the platform the owner of the character, so two things that used
|
|
123
|
+
to be each game's problem moved in here.
|
|
124
|
+
|
|
125
|
+
**Jump** (`jumpSpeed`, `coyoteTime`, `jumpBuffer`, `jumpCut`). 0.1.0 left jump
|
|
126
|
+
to the game on the grounds that how it feels is a design decision. That is true
|
|
127
|
+
right up until every game shares one character — then a badly-tuned jump is
|
|
128
|
+
badly tuned everywhere. `update(dt, dir, { jump })` takes the button's CURRENT
|
|
129
|
+
state and the controller owns the timing: coyote time so a press a few frames
|
|
130
|
+
late still counts, buffering so a press slightly early is not lost, and a cut on
|
|
131
|
+
release so a tap hops and a hold arcs.
|
|
132
|
+
|
|
133
|
+
The cut applies **once, on release**. Applying it every airborne frame compounds
|
|
134
|
+
(×0.45, ×0.2, ×0.09…) and swallows the jump entirely — caught by the
|
|
135
|
+
release-before-landing test, not by reading the code.
|
|
136
|
+
|
|
137
|
+
**`state`** (`idle` | `walk` | `jump` | `fall`) so animation follows what the
|
|
138
|
+
character is DOING rather than what was pressed; deriving it from input leaves a
|
|
139
|
+
character walking in mid-air. **`teleport()`** is the respawn primitive, and it
|
|
140
|
+
clears vertical velocity — without that, a character rescued from a long drop
|
|
141
|
+
arrives still travelling at the speed it fell.
|
|
142
|
+
|
|
143
|
+
**Touch** (`Input3D({ touch })`, on by default for coarse-pointer devices). An
|
|
144
|
+
on-screen thumbstick and jump button, merged into the same `direction()` and
|
|
145
|
+
`jump` a keyboard feeds, so no game branches on input source. Before this, 3D
|
|
146
|
+
was simply unplayable on a phone.
|
|
147
|
+
|
|
148
|
+
The controls mount to `<body>`, **not** the game's `#hud`: a game that writes
|
|
149
|
+
`hud.textContent = '...'` wipes every child, and the controls vanish with no
|
|
150
|
+
error at all. That happened. The HUD belongs to the game; this layer belongs to
|
|
151
|
+
the platform.
|
|
152
|
+
|
|
99
153
|
## What is deliberately missing
|
|
100
154
|
|
|
101
155
|
No character controller (Rapier's `KinematicCharacterController` is the intended
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { CharacterState } from './CharacterController3D.js';
|
|
3
|
+
/** Semantic name -> the clip actually inside the model, as authored in the
|
|
4
|
+
* manifest. Never guess a clip name: this character calls its run `sprint`. */
|
|
5
|
+
export type ClipMap = Record<string, string>;
|
|
6
|
+
export interface CharacterAnimatorOptions {
|
|
7
|
+
/** Cross-fade between locomotion clips, seconds. */
|
|
8
|
+
blend?: number;
|
|
9
|
+
/** Cross-fade in and out of a one-shot action, seconds. */
|
|
10
|
+
actionBlend?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Locomotion plus one-shot actions, for a character whose clips all sit on one
|
|
14
|
+
* rig.
|
|
15
|
+
*
|
|
16
|
+
* Two things were being written by hand in every game, and both are character
|
|
17
|
+
* behaviour rather than game logic (ADR-034), so they live here.
|
|
18
|
+
*
|
|
19
|
+
* **Locomotion follows the CONTROLLER's state, not the keys held.** A clip
|
|
20
|
+
* chosen from input leaves a character walking in mid-air.
|
|
21
|
+
*
|
|
22
|
+
* **An action is a one-shot that interrupts and returns.** Attacking is not a
|
|
23
|
+
* state you enter, it is a thing that happens: it plays once, holds its last
|
|
24
|
+
* frame rather than snapping, and hands control back. Without that, a game
|
|
25
|
+
* either cannot attack at all or leaves the character stuck mid-swing.
|
|
26
|
+
*/
|
|
27
|
+
export declare class CharacterAnimator {
|
|
28
|
+
private readonly mixer;
|
|
29
|
+
private readonly map;
|
|
30
|
+
private readonly actions;
|
|
31
|
+
private locomotion;
|
|
32
|
+
private locomotionName;
|
|
33
|
+
private oneShot;
|
|
34
|
+
private oneShotName;
|
|
35
|
+
private readonly blend;
|
|
36
|
+
private readonly actionBlend;
|
|
37
|
+
private readonly onFinished;
|
|
38
|
+
constructor(mixer: THREE.AnimationMixer, clips: THREE.AnimationClip[], map?: ClipMap, options?: CharacterAnimatorOptions);
|
|
39
|
+
/** Is a one-shot still playing? Games gate input on this — swinging twice
|
|
40
|
+
* from one press is the usual reason an attack feels broken. */
|
|
41
|
+
get busy(): boolean;
|
|
42
|
+
/** Which one-shot, if any. */
|
|
43
|
+
get action(): string;
|
|
44
|
+
/** Look a semantic name up through the manifest's map, then by raw clip name
|
|
45
|
+
* so a game can reach a clip the manifest never mapped. */
|
|
46
|
+
private find;
|
|
47
|
+
has(name: string): boolean;
|
|
48
|
+
/** Drive locomotion. Pass `character.state` every frame. */
|
|
49
|
+
update(state: CharacterState | string): void;
|
|
50
|
+
/**
|
|
51
|
+
* Play a one-shot. Returns false if there is no such clip, or if one is
|
|
52
|
+
* already running and `interrupt` was not asked for.
|
|
53
|
+
*/
|
|
54
|
+
play(name: string, opts?: {
|
|
55
|
+
interrupt?: boolean;
|
|
56
|
+
}): boolean;
|
|
57
|
+
dispose(): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Locomotion plus one-shot actions, for a character whose clips all sit on one
|
|
4
|
+
* rig.
|
|
5
|
+
*
|
|
6
|
+
* Two things were being written by hand in every game, and both are character
|
|
7
|
+
* behaviour rather than game logic (ADR-034), so they live here.
|
|
8
|
+
*
|
|
9
|
+
* **Locomotion follows the CONTROLLER's state, not the keys held.** A clip
|
|
10
|
+
* chosen from input leaves a character walking in mid-air.
|
|
11
|
+
*
|
|
12
|
+
* **An action is a one-shot that interrupts and returns.** Attacking is not a
|
|
13
|
+
* state you enter, it is a thing that happens: it plays once, holds its last
|
|
14
|
+
* frame rather than snapping, and hands control back. Without that, a game
|
|
15
|
+
* either cannot attack at all or leaves the character stuck mid-swing.
|
|
16
|
+
*/
|
|
17
|
+
export class CharacterAnimator {
|
|
18
|
+
constructor(mixer, clips, map = {}, options = {}) {
|
|
19
|
+
this.mixer = mixer;
|
|
20
|
+
this.map = map;
|
|
21
|
+
this.actions = new Map();
|
|
22
|
+
this.locomotion = null;
|
|
23
|
+
this.locomotionName = '';
|
|
24
|
+
this.oneShot = null;
|
|
25
|
+
this.oneShotName = '';
|
|
26
|
+
this.blend = options.blend ?? 0.15;
|
|
27
|
+
this.actionBlend = options.actionBlend ?? 0.08;
|
|
28
|
+
for (const clip of clips)
|
|
29
|
+
this.actions.set(clip.name, mixer.clipAction(clip));
|
|
30
|
+
this.onFinished = (e) => {
|
|
31
|
+
if (this.oneShot && e.action === this.oneShot) {
|
|
32
|
+
this.oneShot.fadeOut(this.actionBlend);
|
|
33
|
+
this.oneShot = null;
|
|
34
|
+
this.oneShotName = '';
|
|
35
|
+
// Bring locomotion back explicitly — it was faded down, not stopped,
|
|
36
|
+
// so it is still running underneath at weight ~0.
|
|
37
|
+
this.locomotion?.reset().fadeIn(this.actionBlend).play();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
mixer.addEventListener('finished', this.onFinished);
|
|
41
|
+
}
|
|
42
|
+
/** Is a one-shot still playing? Games gate input on this — swinging twice
|
|
43
|
+
* from one press is the usual reason an attack feels broken. */
|
|
44
|
+
get busy() { return this.oneShot !== null; }
|
|
45
|
+
/** Which one-shot, if any. */
|
|
46
|
+
get action() { return this.oneShotName; }
|
|
47
|
+
/** Look a semantic name up through the manifest's map, then by raw clip name
|
|
48
|
+
* so a game can reach a clip the manifest never mapped. */
|
|
49
|
+
find(name) {
|
|
50
|
+
return this.actions.get(this.map[name] ?? name) ?? this.actions.get(name) ?? null;
|
|
51
|
+
}
|
|
52
|
+
has(name) { return this.find(name) !== null; }
|
|
53
|
+
/** Drive locomotion. Pass `character.state` every frame. */
|
|
54
|
+
update(state) {
|
|
55
|
+
if (state === this.locomotionName)
|
|
56
|
+
return;
|
|
57
|
+
const next = this.find(state) ?? this.find('idle');
|
|
58
|
+
if (!next)
|
|
59
|
+
return;
|
|
60
|
+
this.locomotionName = state;
|
|
61
|
+
if (next === this.locomotion)
|
|
62
|
+
return;
|
|
63
|
+
next.reset().setLoop(THREE.LoopRepeat, Infinity).setEffectiveWeight(1);
|
|
64
|
+
// Under a one-shot, swap the underlying clip silently — fading it in here
|
|
65
|
+
// would blend a walk cycle into the middle of a sword swing.
|
|
66
|
+
if (this.busy) {
|
|
67
|
+
next.play().setEffectiveWeight(0);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
next.fadeIn(this.blend).play();
|
|
71
|
+
this.locomotion?.fadeOut(this.blend);
|
|
72
|
+
}
|
|
73
|
+
this.locomotion = next;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Play a one-shot. Returns false if there is no such clip, or if one is
|
|
77
|
+
* already running and `interrupt` was not asked for.
|
|
78
|
+
*/
|
|
79
|
+
play(name, opts = {}) {
|
|
80
|
+
const next = this.find(name);
|
|
81
|
+
if (!next)
|
|
82
|
+
return false;
|
|
83
|
+
if (this.busy && !opts.interrupt)
|
|
84
|
+
return false;
|
|
85
|
+
if (this.oneShot && this.oneShot !== next)
|
|
86
|
+
this.oneShot.fadeOut(this.actionBlend);
|
|
87
|
+
this.locomotion?.fadeOut(this.actionBlend);
|
|
88
|
+
next.reset()
|
|
89
|
+
.setLoop(THREE.LoopOnce, 1)
|
|
90
|
+
.setEffectiveWeight(1)
|
|
91
|
+
.fadeIn(this.actionBlend)
|
|
92
|
+
.play();
|
|
93
|
+
// Hold the last frame instead of snapping back to the bind pose in the
|
|
94
|
+
// gap before locomotion fades in.
|
|
95
|
+
next.clampWhenFinished = true;
|
|
96
|
+
this.oneShot = next;
|
|
97
|
+
this.oneShotName = name;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
dispose() {
|
|
101
|
+
this.mixer.removeEventListener('finished', this.onFinished);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -21,6 +21,32 @@ export interface CharacterOptions {
|
|
|
21
21
|
y: number;
|
|
22
22
|
z: number;
|
|
23
23
|
};
|
|
24
|
+
/** Upward speed at the moment of a jump. 0 disables jumping entirely. */
|
|
25
|
+
jumpSpeed?: number;
|
|
26
|
+
/**
|
|
27
|
+
* How long after walking off an edge a jump still counts, in seconds.
|
|
28
|
+
* Without it, players who press jump a frame or two late — which is most
|
|
29
|
+
* players, most of the time — simply fall, and the game feels like it
|
|
30
|
+
* ignored them. Named for the cartoon coyote.
|
|
31
|
+
*/
|
|
32
|
+
coyoteTime?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How long before landing a jump press is remembered, in seconds. The other
|
|
35
|
+
* half of the same problem: pressing slightly EARLY should also work.
|
|
36
|
+
*/
|
|
37
|
+
jumpBuffer?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Releasing the button early cuts upward speed by this factor, giving a
|
|
40
|
+
* short hop for a tap and a full arc for a hold. 1 disables it.
|
|
41
|
+
*/
|
|
42
|
+
jumpCut?: number;
|
|
43
|
+
}
|
|
44
|
+
/** What the character is doing, for animation. */
|
|
45
|
+
export type CharacterState = 'idle' | 'walk' | 'jump' | 'fall';
|
|
46
|
+
/** Per-frame input. `jump` is the button's CURRENT state, not an event —
|
|
47
|
+
* edge detection, coyote time and buffering all live in the controller. */
|
|
48
|
+
export interface CharacterInput {
|
|
49
|
+
jump?: boolean;
|
|
24
50
|
}
|
|
25
51
|
/**
|
|
26
52
|
* A kinematic character: walks, collides with walls, climbs steps, falls.
|
|
@@ -44,8 +70,27 @@ export declare class CharacterController3D {
|
|
|
44
70
|
private readonly controller;
|
|
45
71
|
private verticalVelocity;
|
|
46
72
|
private readonly opts;
|
|
73
|
+
private coyoteLeft;
|
|
74
|
+
private bufferLeft;
|
|
75
|
+
private jumpWasHeld;
|
|
76
|
+
private jumping;
|
|
77
|
+
private jumpCutApplied;
|
|
78
|
+
private _state;
|
|
47
79
|
constructor(world: any, RAPIER: RapierRuntime, options?: CharacterOptions);
|
|
48
80
|
get grounded(): boolean;
|
|
81
|
+
/** What the character is doing — map this to a clip rather than deriving it
|
|
82
|
+
* from input, or a character keeps walking in mid-air. */
|
|
83
|
+
get state(): CharacterState;
|
|
84
|
+
/** Rising under its own power, as opposed to merely off the ground. */
|
|
85
|
+
get airborne(): boolean;
|
|
86
|
+
/** Put the character somewhere, cancelling any fall. The respawn primitive:
|
|
87
|
+
* without clearing the velocity, a character teleported out of a long drop
|
|
88
|
+
* arrives still travelling at the speed it fell. */
|
|
89
|
+
teleport(p: {
|
|
90
|
+
x: number;
|
|
91
|
+
y: number;
|
|
92
|
+
z: number;
|
|
93
|
+
}): void;
|
|
49
94
|
get position(): {
|
|
50
95
|
x: number;
|
|
51
96
|
y: number;
|
|
@@ -59,7 +104,7 @@ export declare class CharacterController3D {
|
|
|
59
104
|
update(dt: number, dir: {
|
|
60
105
|
x: number;
|
|
61
106
|
z: number;
|
|
62
|
-
}): void;
|
|
107
|
+
}, input?: CharacterInput): void;
|
|
63
108
|
/** Copy the simulated position onto the rendered object. */
|
|
64
109
|
syncTo(object: THREE.Object3D, yOffset?: number): void;
|
|
65
110
|
/** Face the direction of travel; ignores tiny inputs so idle doesn't spin. */
|
|
@@ -17,6 +17,12 @@ export class CharacterController3D {
|
|
|
17
17
|
constructor(world, RAPIER, options = {}) {
|
|
18
18
|
this.world = world;
|
|
19
19
|
this.verticalVelocity = 0;
|
|
20
|
+
this.coyoteLeft = 0;
|
|
21
|
+
this.bufferLeft = 0;
|
|
22
|
+
this.jumpWasHeld = false;
|
|
23
|
+
this.jumping = false;
|
|
24
|
+
this.jumpCutApplied = false;
|
|
25
|
+
this._state = 'idle';
|
|
20
26
|
this.opts = {
|
|
21
27
|
halfHeight: options.halfHeight ?? 0.5,
|
|
22
28
|
radius: options.radius ?? 0.35,
|
|
@@ -24,6 +30,10 @@ export class CharacterController3D {
|
|
|
24
30
|
stepHeight: options.stepHeight ?? 0.4,
|
|
25
31
|
maxSlopeDegrees: options.maxSlopeDegrees ?? 50,
|
|
26
32
|
gravity: options.gravity ?? 9.81,
|
|
33
|
+
jumpSpeed: options.jumpSpeed ?? 0,
|
|
34
|
+
coyoteTime: options.coyoteTime ?? 0.12,
|
|
35
|
+
jumpBuffer: options.jumpBuffer ?? 0.12,
|
|
36
|
+
jumpCut: options.jumpCut ?? 0.45,
|
|
27
37
|
};
|
|
28
38
|
const p = options.position ?? { x: 0, y: 2, z: 0 };
|
|
29
39
|
this.body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(p.x, p.y, p.z));
|
|
@@ -38,30 +48,78 @@ export class CharacterController3D {
|
|
|
38
48
|
this.controller.setApplyImpulsesToDynamicBodies(true);
|
|
39
49
|
}
|
|
40
50
|
get grounded() { return this.controller.computedGrounded(); }
|
|
51
|
+
/** What the character is doing — map this to a clip rather than deriving it
|
|
52
|
+
* from input, or a character keeps walking in mid-air. */
|
|
53
|
+
get state() { return this._state; }
|
|
54
|
+
/** Rising under its own power, as opposed to merely off the ground. */
|
|
55
|
+
get airborne() { return !this.grounded; }
|
|
56
|
+
/** Put the character somewhere, cancelling any fall. The respawn primitive:
|
|
57
|
+
* without clearing the velocity, a character teleported out of a long drop
|
|
58
|
+
* arrives still travelling at the speed it fell. */
|
|
59
|
+
teleport(p) {
|
|
60
|
+
this.body.setTranslation(p, true);
|
|
61
|
+
this.verticalVelocity = 0;
|
|
62
|
+
this.jumping = false;
|
|
63
|
+
this.jumpCutApplied = false;
|
|
64
|
+
this.coyoteLeft = 0;
|
|
65
|
+
this.bufferLeft = 0;
|
|
66
|
+
}
|
|
41
67
|
get position() { return this.body.translation(); }
|
|
42
68
|
/**
|
|
43
69
|
* Move for one step. `dir` is a desired direction in world space (y ignored);
|
|
44
70
|
* it is normalised here so diagonals aren't faster than cardinals — a bug old
|
|
45
71
|
* enough to have a name.
|
|
46
72
|
*/
|
|
47
|
-
update(dt, dir) {
|
|
73
|
+
update(dt, dir, input = {}) {
|
|
48
74
|
const len = Math.hypot(dir.x, dir.z);
|
|
49
75
|
const nx = len > 0 ? (dir.x / len) * this.opts.speed * dt : 0;
|
|
50
76
|
const nz = len > 0 ? (dir.z / len) * this.opts.speed * dt : 0;
|
|
51
|
-
|
|
77
|
+
const grounded = this.grounded;
|
|
78
|
+
const jumpHeld = input.jump === true;
|
|
79
|
+
const jumpPressed = jumpHeld && !this.jumpWasHeld;
|
|
80
|
+
this.jumpWasHeld = jumpHeld;
|
|
81
|
+
// Both timers count DOWN in real time, so they behave the same at 30fps and
|
|
82
|
+
// 144fps. A window measured in frames is a window that changes size with
|
|
83
|
+
// the machine.
|
|
84
|
+
this.coyoteLeft = grounded ? this.opts.coyoteTime : Math.max(0, this.coyoteLeft - dt);
|
|
85
|
+
this.bufferLeft = jumpPressed ? this.opts.jumpBuffer : Math.max(0, this.bufferLeft - dt);
|
|
86
|
+
if (grounded) {
|
|
52
87
|
// Small constant bias, not accumulated gravity: enough for snap-to-ground
|
|
53
88
|
// to hold contact over stairs and slopes without pinning the character.
|
|
54
89
|
this.verticalVelocity = -this.opts.gravity * dt;
|
|
90
|
+
this.jumping = false;
|
|
55
91
|
}
|
|
56
92
|
else {
|
|
57
93
|
this.verticalVelocity -= this.opts.gravity * dt;
|
|
58
94
|
}
|
|
95
|
+
if (this.opts.jumpSpeed > 0 && this.bufferLeft > 0 && this.coyoteLeft > 0 && !this.jumping) {
|
|
96
|
+
this.verticalVelocity = this.opts.jumpSpeed;
|
|
97
|
+
this.jumping = true;
|
|
98
|
+
this.jumpCutApplied = false;
|
|
99
|
+
// Spend both windows, or one press keeps re-triggering all the way up.
|
|
100
|
+
this.bufferLeft = 0;
|
|
101
|
+
this.coyoteLeft = 0;
|
|
102
|
+
}
|
|
103
|
+
// Variable height: let go early and the rise is cut short. Applied ONCE,
|
|
104
|
+
// on the release, not every frame — multiplying each frame while the button
|
|
105
|
+
// is up compounds (x0.45, x0.2, x0.09...) and swallows the jump entirely,
|
|
106
|
+
// which is exactly what the release-before-landing test caught.
|
|
107
|
+
if (this.jumping && !jumpHeld && !this.jumpCutApplied && this.verticalVelocity > 0) {
|
|
108
|
+
this.verticalVelocity *= this.opts.jumpCut;
|
|
109
|
+
this.jumpCutApplied = true;
|
|
110
|
+
}
|
|
59
111
|
this.controller.computeColliderMovement(this.collider, {
|
|
60
112
|
x: nx, y: this.verticalVelocity * dt, z: nz,
|
|
61
113
|
});
|
|
62
114
|
const move = this.controller.computedMovement();
|
|
63
115
|
const t = this.body.translation();
|
|
64
116
|
this.body.setNextKinematicTranslation({ x: t.x + move.x, y: t.y + move.y, z: t.z + move.z });
|
|
117
|
+
// State is read AFTER the move so a landing this frame reads as landed.
|
|
118
|
+
// Note `grounded` above is the PRE-move value — the two differ exactly on
|
|
119
|
+
// the frames that matter for animation.
|
|
120
|
+
this._state = this.grounded
|
|
121
|
+
? (len > 0 ? 'walk' : 'idle')
|
|
122
|
+
: (this.verticalVelocity > 0 ? 'jump' : 'fall');
|
|
65
123
|
}
|
|
66
124
|
/** Copy the simulated position onto the rendered object. */
|
|
67
125
|
syncTo(object, yOffset = 0) {
|
package/dist/Input3D.d.ts
CHANGED
|
@@ -1,26 +1,59 @@
|
|
|
1
|
+
export interface Input3DOptions {
|
|
2
|
+
/** Where key events are read from. */
|
|
3
|
+
target?: Window;
|
|
4
|
+
/**
|
|
5
|
+
* On-screen thumbstick + jump button. `'auto'` (the default) adds them when
|
|
6
|
+
* the device reports coarse pointers and no fine one — i.e. a phone, not a
|
|
7
|
+
* laptop with a touchscreen.
|
|
8
|
+
*/
|
|
9
|
+
touch?: boolean | 'auto';
|
|
10
|
+
/**
|
|
11
|
+
* Where the controls are mounted. Defaults to `<body>` — deliberately NOT
|
|
12
|
+
* the game's `#hud`: a game that writes `hud.textContent = '...'` wipes every
|
|
13
|
+
* child, and the controls vanish with no error. The HUD belongs to the game;
|
|
14
|
+
* this layer belongs to the platform.
|
|
15
|
+
*/
|
|
16
|
+
container?: HTMLElement | null;
|
|
17
|
+
}
|
|
1
18
|
/**
|
|
2
|
-
*
|
|
19
|
+
* Movement and jump, from a keyboard or a thumb, behind one interface.
|
|
3
20
|
*
|
|
4
|
-
* Reads
|
|
21
|
+
* Reads input STATE rather than events, because an event-driven controller
|
|
5
22
|
* drops input whenever a frame lands between keydown and the read, and holds a
|
|
6
23
|
* direction forever if the keyup is lost (alt-tab is the classic way to lose
|
|
7
24
|
* one). `dispose()` matters: a listener left on window outlives the scene.
|
|
25
|
+
*
|
|
26
|
+
* **Why touch is here and not in each game.** `Input3D` used to be keyboard
|
|
27
|
+
* only, so every 3D game had to build its own thumbstick — which meant none of
|
|
28
|
+
* them did, which meant 3D was unplayable on a phone. Under ADR-034 the
|
|
29
|
+
* platform owns the character, so it owns the controls too: built once,
|
|
30
|
+
* correct everywhere, and `direction()` reads the same either way so no game
|
|
31
|
+
* has to branch on input source.
|
|
8
32
|
*/
|
|
9
33
|
export declare class Input3D {
|
|
10
|
-
private readonly target;
|
|
11
34
|
private readonly held;
|
|
35
|
+
private readonly target;
|
|
12
36
|
private readonly onDown;
|
|
13
37
|
private readonly onUp;
|
|
14
38
|
private readonly onBlur;
|
|
15
|
-
|
|
39
|
+
private readonly stick;
|
|
40
|
+
private touchJump;
|
|
41
|
+
private root;
|
|
42
|
+
private readonly cleanups;
|
|
43
|
+
constructor(options?: Input3DOptions | Window);
|
|
16
44
|
isDown(...codes: string[]): boolean;
|
|
17
|
-
/** Camera-relative would need the camera; this is world-axis movement.
|
|
45
|
+
/** Camera-relative would need the camera; this is world-axis movement.
|
|
46
|
+
* Keyboard and stick are merged, so both work on a device with both. */
|
|
18
47
|
direction(): {
|
|
19
48
|
x: number;
|
|
20
49
|
z: number;
|
|
21
50
|
};
|
|
51
|
+
/** Whether the jump control is held right now. Pass it to the controller —
|
|
52
|
+
* coyote time and buffering live there, not here. */
|
|
53
|
+
get jump(): boolean;
|
|
22
54
|
/** Test seam: drive the controller without synthesising DOM events. */
|
|
23
55
|
press(code: string): void;
|
|
24
56
|
release(code: string): void;
|
|
25
57
|
dispose(): void;
|
|
58
|
+
private mountTouch;
|
|
26
59
|
}
|
package/dist/Input3D.js
CHANGED
|
@@ -1,27 +1,55 @@
|
|
|
1
|
+
const isTouchDevice = () => typeof matchMedia === 'function' &&
|
|
2
|
+
matchMedia('(pointer: coarse)').matches &&
|
|
3
|
+
!matchMedia('(pointer: fine)').matches;
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
5
|
+
* Movement and jump, from a keyboard or a thumb, behind one interface.
|
|
3
6
|
*
|
|
4
|
-
* Reads
|
|
7
|
+
* Reads input STATE rather than events, because an event-driven controller
|
|
5
8
|
* drops input whenever a frame lands between keydown and the read, and holds a
|
|
6
9
|
* direction forever if the keyup is lost (alt-tab is the classic way to lose
|
|
7
10
|
* one). `dispose()` matters: a listener left on window outlives the scene.
|
|
11
|
+
*
|
|
12
|
+
* **Why touch is here and not in each game.** `Input3D` used to be keyboard
|
|
13
|
+
* only, so every 3D game had to build its own thumbstick — which meant none of
|
|
14
|
+
* them did, which meant 3D was unplayable on a phone. Under ADR-034 the
|
|
15
|
+
* platform owns the character, so it owns the controls too: built once,
|
|
16
|
+
* correct everywhere, and `direction()` reads the same either way so no game
|
|
17
|
+
* has to branch on input source.
|
|
8
18
|
*/
|
|
9
19
|
export class Input3D {
|
|
10
|
-
constructor(
|
|
11
|
-
this.target = target;
|
|
20
|
+
constructor(options = {}) {
|
|
12
21
|
this.held = new Set();
|
|
13
|
-
this.onDown = (e) => {
|
|
22
|
+
this.onDown = (e) => {
|
|
23
|
+
this.held.add(e.code);
|
|
24
|
+
// Space scrolls the page, which in an embedded game scrolls the HOST page
|
|
25
|
+
// out from under the player.
|
|
26
|
+
if (e.code === 'Space')
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
};
|
|
14
29
|
this.onUp = (e) => { this.held.delete(e.code); };
|
|
15
|
-
this.onBlur = () => { this.held.clear(); };
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
this.onBlur = () => { this.held.clear(); this.stick.x = 0; this.stick.z = 0; this.touchJump = false; };
|
|
31
|
+
this.stick = { x: 0, z: 0 };
|
|
32
|
+
this.touchJump = false;
|
|
33
|
+
this.root = null;
|
|
34
|
+
this.cleanups = [];
|
|
35
|
+
// A Window here is the pre-touch signature; keep it working.
|
|
36
|
+
const opts = (typeof Window !== 'undefined' && options instanceof Window)
|
|
37
|
+
? { target: options } : options;
|
|
38
|
+
this.target = opts.target ?? window;
|
|
39
|
+
this.target.addEventListener('keydown', this.onDown);
|
|
40
|
+
this.target.addEventListener('keyup', this.onUp);
|
|
18
41
|
// Losing focus mid-press would otherwise leave the character walking.
|
|
19
|
-
target.addEventListener('blur', this.onBlur);
|
|
42
|
+
this.target.addEventListener('blur', this.onBlur);
|
|
43
|
+
const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
|
|
44
|
+
if (wantTouch && typeof document !== 'undefined') {
|
|
45
|
+
this.mountTouch(opts.container ?? document.body);
|
|
46
|
+
}
|
|
20
47
|
}
|
|
21
48
|
isDown(...codes) { return codes.some((c) => this.held.has(c)); }
|
|
22
|
-
/** Camera-relative would need the camera; this is world-axis movement.
|
|
49
|
+
/** Camera-relative would need the camera; this is world-axis movement.
|
|
50
|
+
* Keyboard and stick are merged, so both work on a device with both. */
|
|
23
51
|
direction() {
|
|
24
|
-
let x =
|
|
52
|
+
let x = this.stick.x, z = this.stick.z;
|
|
25
53
|
if (this.isDown('KeyW', 'ArrowUp'))
|
|
26
54
|
z -= 1;
|
|
27
55
|
if (this.isDown('KeyS', 'ArrowDown'))
|
|
@@ -30,8 +58,13 @@ export class Input3D {
|
|
|
30
58
|
x -= 1;
|
|
31
59
|
if (this.isDown('KeyD', 'ArrowRight'))
|
|
32
60
|
x += 1;
|
|
33
|
-
|
|
61
|
+
const len = Math.hypot(x, z);
|
|
62
|
+
// Clamp rather than normalise: a half-pushed stick should walk slowly.
|
|
63
|
+
return len > 1 ? { x: x / len, z: z / len } : { x, z };
|
|
34
64
|
}
|
|
65
|
+
/** Whether the jump control is held right now. Pass it to the controller —
|
|
66
|
+
* coyote time and buffering live there, not here. */
|
|
67
|
+
get jump() { return this.touchJump || this.isDown('Space'); }
|
|
35
68
|
/** Test seam: drive the controller without synthesising DOM events. */
|
|
36
69
|
press(code) { this.held.add(code); }
|
|
37
70
|
release(code) { this.held.delete(code); }
|
|
@@ -39,6 +72,95 @@ export class Input3D {
|
|
|
39
72
|
this.target.removeEventListener('keydown', this.onDown);
|
|
40
73
|
this.target.removeEventListener('keyup', this.onUp);
|
|
41
74
|
this.target.removeEventListener('blur', this.onBlur);
|
|
75
|
+
for (const c of this.cleanups)
|
|
76
|
+
c();
|
|
77
|
+
this.cleanups.length = 0;
|
|
78
|
+
this.root?.remove();
|
|
79
|
+
this.root = null;
|
|
42
80
|
this.held.clear();
|
|
43
81
|
}
|
|
82
|
+
// ── on-screen controls ────────────────────────────────────────────────────
|
|
83
|
+
mountTouch(container) {
|
|
84
|
+
const root = document.createElement('div');
|
|
85
|
+
root.dataset.umicatTouch = '';
|
|
86
|
+
// pointer-events:none on the layer, auto on the controls — otherwise the
|
|
87
|
+
// overlay eats every tap meant for the world.
|
|
88
|
+
Object.assign(root.style, {
|
|
89
|
+
// fixed, not absolute: on <body> an absolute layer depends on whether
|
|
90
|
+
// some ancestor happens to be positioned, and "happens to be" is not a
|
|
91
|
+
// layout strategy.
|
|
92
|
+
position: 'fixed', inset: '0', pointerEvents: 'none',
|
|
93
|
+
touchAction: 'none', userSelect: 'none', zIndex: '10',
|
|
94
|
+
});
|
|
95
|
+
const pad = document.createElement('div');
|
|
96
|
+
Object.assign(pad.style, {
|
|
97
|
+
position: 'absolute', left: '5vmin', bottom: '5vmin',
|
|
98
|
+
width: '30vmin', height: '30vmin', maxWidth: '180px', maxHeight: '180px',
|
|
99
|
+
borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
|
|
100
|
+
border: '2px solid rgba(255,255,255,0.35)', pointerEvents: 'auto',
|
|
101
|
+
});
|
|
102
|
+
const knob = document.createElement('div');
|
|
103
|
+
Object.assign(knob.style, {
|
|
104
|
+
position: 'absolute', left: '50%', top: '50%', width: '40%', height: '40%',
|
|
105
|
+
borderRadius: '50%', background: 'rgba(255,255,255,0.55)',
|
|
106
|
+
transform: 'translate(-50%, -50%)', pointerEvents: 'none',
|
|
107
|
+
});
|
|
108
|
+
pad.appendChild(knob);
|
|
109
|
+
const btn = document.createElement('div');
|
|
110
|
+
Object.assign(btn.style, {
|
|
111
|
+
position: 'absolute', right: '6vmin', bottom: '7vmin',
|
|
112
|
+
width: '20vmin', height: '20vmin', maxWidth: '120px', maxHeight: '120px',
|
|
113
|
+
borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
|
|
114
|
+
border: '2px solid rgba(255,255,255,0.4)', pointerEvents: 'auto',
|
|
115
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
116
|
+
color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
|
|
117
|
+
});
|
|
118
|
+
btn.textContent = '▲';
|
|
119
|
+
root.append(pad, btn);
|
|
120
|
+
container.appendChild(root);
|
|
121
|
+
this.root = root;
|
|
122
|
+
// Track by pointerId so a thumb on the stick and a thumb on the button do
|
|
123
|
+
// not fight over one piece of state.
|
|
124
|
+
let stickId = null;
|
|
125
|
+
const setFromEvent = (e) => {
|
|
126
|
+
const r = pad.getBoundingClientRect();
|
|
127
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
128
|
+
const max = r.width / 2;
|
|
129
|
+
let dx = (e.clientX - cx) / max, dy = (e.clientY - cy) / max;
|
|
130
|
+
const len = Math.hypot(dx, dy);
|
|
131
|
+
if (len > 1) {
|
|
132
|
+
dx /= len;
|
|
133
|
+
dy /= len;
|
|
134
|
+
}
|
|
135
|
+
this.stick.x = dx;
|
|
136
|
+
this.stick.z = dy; // screen-down is +z, matching KeyS
|
|
137
|
+
knob.style.left = `${50 + dx * 30}%`;
|
|
138
|
+
knob.style.top = `${50 + dy * 30}%`;
|
|
139
|
+
};
|
|
140
|
+
const reset = () => {
|
|
141
|
+
stickId = null;
|
|
142
|
+
this.stick.x = 0;
|
|
143
|
+
this.stick.z = 0;
|
|
144
|
+
knob.style.left = '50%';
|
|
145
|
+
knob.style.top = '50%';
|
|
146
|
+
};
|
|
147
|
+
const on = (el, ev, fn) => {
|
|
148
|
+
const h = fn;
|
|
149
|
+
el.addEventListener(ev, h);
|
|
150
|
+
this.cleanups.push(() => el.removeEventListener(ev, h));
|
|
151
|
+
};
|
|
152
|
+
on(pad, 'pointerdown', (e) => { stickId = e.pointerId; pad.setPointerCapture(e.pointerId); setFromEvent(e); });
|
|
153
|
+
on(pad, 'pointermove', (e) => { if (e.pointerId === stickId)
|
|
154
|
+
setFromEvent(e); });
|
|
155
|
+
// pointercancel too: a system gesture steals the pointer without an up, and
|
|
156
|
+
// the character would walk forever.
|
|
157
|
+
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
|
|
158
|
+
on(pad, ev, (e) => { if (e.pointerId === stickId)
|
|
159
|
+
reset(); });
|
|
160
|
+
}
|
|
161
|
+
on(btn, 'pointerdown', (e) => { btn.setPointerCapture(e.pointerId); this.touchJump = true; });
|
|
162
|
+
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
|
|
163
|
+
on(btn, ev, () => { this.touchJump = false; });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
44
166
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,11 @@ export type { UmicatInitOptions } from './ThreeUmicat.js';
|
|
|
3
3
|
export * from './scene3d.js';
|
|
4
4
|
export { loadScene3D } from './SceneLoader3D.js';
|
|
5
5
|
export { CharacterController3D } from './CharacterController3D.js';
|
|
6
|
-
export type { CharacterOptions } from './CharacterController3D.js';
|
|
6
|
+
export type { CharacterOptions, CharacterState, CharacterInput } from './CharacterController3D.js';
|
|
7
|
+
export { CharacterAnimator } from './CharacterAnimator.js';
|
|
8
|
+
export type { ClipMap, CharacterAnimatorOptions } from './CharacterAnimator.js';
|
|
7
9
|
export { Input3D } from './Input3D.js';
|
|
10
|
+
export type { Input3DOptions } from './Input3D.js';
|
|
8
11
|
export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
|
|
9
12
|
export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
|
|
10
13
|
export type { Orientation } from '@umicat/platform-sdk/orientation.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { ThreeUmicat } from './ThreeUmicat.js';
|
|
|
7
7
|
export * from './scene3d.js';
|
|
8
8
|
export { loadScene3D } from './SceneLoader3D.js';
|
|
9
9
|
export { CharacterController3D } from './CharacterController3D.js';
|
|
10
|
+
export { CharacterAnimator } from './CharacterAnimator.js';
|
|
10
11
|
export { Input3D } from './Input3D.js';
|
|
11
12
|
// Re-exported so a game imports one package for the common case. A game should
|
|
12
13
|
// not have to know that identity and saves come from a different package than
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,4 +33,4 @@
|
|
|
33
33
|
"dist",
|
|
34
34
|
"README.md"
|
|
35
35
|
]
|
|
36
|
-
}
|
|
36
|
+
}
|