@jgengine/shell 0.1.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/LICENSE +661 -0
- package/README.md +16 -0
- package/dist/GamePlayerShell.d.ts +6 -0
- package/dist/GamePlayerShell.js +383 -0
- package/dist/GameUiPreview.d.ts +8 -0
- package/dist/GameUiPreview.js +63 -0
- package/dist/camera/GameOrbitCamera.d.ts +17 -0
- package/dist/camera/GameOrbitCamera.js +86 -0
- package/dist/camera/index.d.ts +2 -0
- package/dist/camera/index.js +2 -0
- package/dist/camera/orbitCameraMath.d.ts +96 -0
- package/dist/camera/orbitCameraMath.js +130 -0
- package/dist/demo/demoGame.d.ts +2 -0
- package/dist/demo/demoGame.js +200 -0
- package/dist/multiplayer.d.ts +16 -0
- package/dist/multiplayer.js +21 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Horizontal facing derived from an orbit camera orbiting a target on the XZ plane. */
|
|
2
|
+
export declare function orbitYawFromCamera(cameraX: number, cameraZ: number, targetX: number, targetZ: number): number;
|
|
3
|
+
export interface Vec3 {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
z: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CameraFollowState {
|
|
9
|
+
entityId: string;
|
|
10
|
+
target: Vec3;
|
|
11
|
+
camera: Vec3;
|
|
12
|
+
distance: number;
|
|
13
|
+
}
|
|
14
|
+
export interface OrbitCameraConfig {
|
|
15
|
+
minDistance?: number;
|
|
16
|
+
maxDistance?: number;
|
|
17
|
+
targetHeight?: number;
|
|
18
|
+
initialDistance?: number;
|
|
19
|
+
initialHeight?: number;
|
|
20
|
+
/** Extra offset applied on top of entity position for the orbit target. */
|
|
21
|
+
targetOffset?: Partial<Vec3>;
|
|
22
|
+
/** Keep orbit radius locked while the follow target moves. */
|
|
23
|
+
followLock?: boolean;
|
|
24
|
+
/** When false, orbit target stays fixed (cinematic / debug). Default true. */
|
|
25
|
+
followEnabled?: boolean;
|
|
26
|
+
/** Orbit drag sensitivity (lower = smoother). Default OrbitControls is 1. */
|
|
27
|
+
rotateSpeed?: number;
|
|
28
|
+
zoomSpeed?: number;
|
|
29
|
+
/** Inertia decay while orbiting (lower = longer coast). */
|
|
30
|
+
dampingFactor?: number;
|
|
31
|
+
/** Exponential smoothing when the follow target moves. Higher = snappier. */
|
|
32
|
+
targetSmoothing?: number;
|
|
33
|
+
/** Faster target follow while orbiting (defaults to ~1.8× targetSmoothing). */
|
|
34
|
+
dragTargetSmoothing?: number;
|
|
35
|
+
/** Exponential smoothing when re-locking orbit distance. */
|
|
36
|
+
distanceSmoothing?: number;
|
|
37
|
+
}
|
|
38
|
+
/** Fully resolved shell config after merging with DEFAULT_ORBIT_CAMERA. */
|
|
39
|
+
export interface ResolvedOrbitCameraConfig {
|
|
40
|
+
minDistance: number;
|
|
41
|
+
maxDistance: number;
|
|
42
|
+
targetHeight: number;
|
|
43
|
+
initialDistance: number;
|
|
44
|
+
initialHeight: number;
|
|
45
|
+
targetOffset?: Partial<Vec3>;
|
|
46
|
+
followLock: boolean;
|
|
47
|
+
followEnabled: boolean;
|
|
48
|
+
rotateSpeed: number;
|
|
49
|
+
zoomSpeed: number;
|
|
50
|
+
dampingFactor: number;
|
|
51
|
+
targetSmoothing: number;
|
|
52
|
+
dragTargetSmoothing: number;
|
|
53
|
+
distanceSmoothing: number;
|
|
54
|
+
}
|
|
55
|
+
export declare const DEFAULT_ORBIT_CAMERA: ResolvedOrbitCameraConfig;
|
|
56
|
+
/** Run simulation/movement before orbit follow so poses are current. */
|
|
57
|
+
export declare const GAME_SIM_FRAME_PRIORITY = 0;
|
|
58
|
+
/** Orbit follow reads the latest entity pose after GAME_SIM_FRAME_PRIORITY. */
|
|
59
|
+
export declare const ORBIT_CAMERA_FRAME_PRIORITY = -1;
|
|
60
|
+
export interface OrbitFollowRuntimeState {
|
|
61
|
+
target: Vec3;
|
|
62
|
+
camera: Vec3;
|
|
63
|
+
lockedDistance: number | null;
|
|
64
|
+
}
|
|
65
|
+
/** Frame-rate independent exponential smoothing factor in [0, 1]. */
|
|
66
|
+
export declare function smoothBlend(deltaSeconds: number, speed: number): number;
|
|
67
|
+
export declare function resolveOrbitCameraConfig(patch?: OrbitCameraConfig): ResolvedOrbitCameraConfig;
|
|
68
|
+
export declare function resolveTargetSmoothing(config: ResolvedOrbitCameraConfig, dragging: boolean): number;
|
|
69
|
+
export declare function resolveFollowTargetFromPosition(position: readonly [number, number, number], config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset">): Vec3;
|
|
70
|
+
export declare function lerpVec3(from: Vec3, to: Vec3, blend: number): Vec3;
|
|
71
|
+
export declare function distanceBetween(a: Vec3, b: Vec3): number;
|
|
72
|
+
export declare function seedOrbitFollowState(input: {
|
|
73
|
+
entityPosition: readonly [number, number, number];
|
|
74
|
+
config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset" | "initialDistance" | "initialHeight">;
|
|
75
|
+
}): OrbitFollowRuntimeState;
|
|
76
|
+
/**
|
|
77
|
+
* Pure orbit follow step — smooth target tracking, camera carries target delta
|
|
78
|
+
* (OrbitControls alone keeps camera fixed when only target moves), optional
|
|
79
|
+
* distance lock. Call each frame before OrbitControls.update() in the shell.
|
|
80
|
+
*/
|
|
81
|
+
export declare function orbitFollowStep(input: {
|
|
82
|
+
state: OrbitFollowRuntimeState;
|
|
83
|
+
desiredTarget: Vec3;
|
|
84
|
+
deltaSeconds: number;
|
|
85
|
+
config: ResolvedOrbitCameraConfig;
|
|
86
|
+
dragging: boolean;
|
|
87
|
+
}): OrbitFollowRuntimeState & {
|
|
88
|
+
distance: number;
|
|
89
|
+
};
|
|
90
|
+
/** @deprecated Use orbitFollowStep — kept for older call sites. */
|
|
91
|
+
export declare function cameraFollowStep(input: {
|
|
92
|
+
camera: Vec3;
|
|
93
|
+
target: Vec3;
|
|
94
|
+
previousTarget: Vec3 | null;
|
|
95
|
+
lockedDistance: number | null;
|
|
96
|
+
}): CameraFollowState;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Horizontal facing derived from an orbit camera orbiting a target on the XZ plane. */
|
|
2
|
+
export function orbitYawFromCamera(cameraX, cameraZ, targetX, targetZ) {
|
|
3
|
+
const dx = targetX - cameraX;
|
|
4
|
+
const dz = targetZ - cameraZ;
|
|
5
|
+
return Math.atan2(dx, dz);
|
|
6
|
+
}
|
|
7
|
+
export const DEFAULT_ORBIT_CAMERA = {
|
|
8
|
+
minDistance: 4,
|
|
9
|
+
maxDistance: 28,
|
|
10
|
+
targetHeight: 1.2,
|
|
11
|
+
initialDistance: 9,
|
|
12
|
+
initialHeight: 5.5,
|
|
13
|
+
followLock: true,
|
|
14
|
+
followEnabled: true,
|
|
15
|
+
rotateSpeed: 0.22,
|
|
16
|
+
zoomSpeed: 0.45,
|
|
17
|
+
dampingFactor: 0.035,
|
|
18
|
+
targetSmoothing: 8,
|
|
19
|
+
dragTargetSmoothing: 11,
|
|
20
|
+
distanceSmoothing: 5,
|
|
21
|
+
};
|
|
22
|
+
/** Run simulation/movement before orbit follow so poses are current. */
|
|
23
|
+
export const GAME_SIM_FRAME_PRIORITY = 0;
|
|
24
|
+
/** Orbit follow reads the latest entity pose after GAME_SIM_FRAME_PRIORITY. */
|
|
25
|
+
export const ORBIT_CAMERA_FRAME_PRIORITY = -1;
|
|
26
|
+
/** Frame-rate independent exponential smoothing factor in [0, 1]. */
|
|
27
|
+
export function smoothBlend(deltaSeconds, speed) {
|
|
28
|
+
return 1 - Math.exp(-speed * deltaSeconds);
|
|
29
|
+
}
|
|
30
|
+
export function resolveOrbitCameraConfig(patch) {
|
|
31
|
+
return { ...DEFAULT_ORBIT_CAMERA, ...patch };
|
|
32
|
+
}
|
|
33
|
+
export function resolveTargetSmoothing(config, dragging) {
|
|
34
|
+
if (dragging) {
|
|
35
|
+
return config.dragTargetSmoothing;
|
|
36
|
+
}
|
|
37
|
+
return config.targetSmoothing;
|
|
38
|
+
}
|
|
39
|
+
export function resolveFollowTargetFromPosition(position, config) {
|
|
40
|
+
const offset = config.targetOffset;
|
|
41
|
+
return {
|
|
42
|
+
x: position[0] + (offset?.x ?? 0),
|
|
43
|
+
y: position[1] + config.targetHeight + (offset?.y ?? 0),
|
|
44
|
+
z: position[2] + (offset?.z ?? 0),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function lerpVec3(from, to, blend) {
|
|
48
|
+
return {
|
|
49
|
+
x: from.x + (to.x - from.x) * blend,
|
|
50
|
+
y: from.y + (to.y - from.y) * blend,
|
|
51
|
+
z: from.z + (to.z - from.z) * blend,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function distanceBetween(a, b) {
|
|
55
|
+
const dx = a.x - b.x;
|
|
56
|
+
const dy = a.y - b.y;
|
|
57
|
+
const dz = a.z - b.z;
|
|
58
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
59
|
+
}
|
|
60
|
+
export function seedOrbitFollowState(input) {
|
|
61
|
+
const target = resolveFollowTargetFromPosition(input.entityPosition, input.config);
|
|
62
|
+
return {
|
|
63
|
+
target,
|
|
64
|
+
camera: {
|
|
65
|
+
x: target.x,
|
|
66
|
+
y: input.config.initialHeight,
|
|
67
|
+
z: target.z - input.config.initialDistance,
|
|
68
|
+
},
|
|
69
|
+
lockedDistance: input.config.initialDistance,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Pure orbit follow step — smooth target tracking, camera carries target delta
|
|
74
|
+
* (OrbitControls alone keeps camera fixed when only target moves), optional
|
|
75
|
+
* distance lock. Call each frame before OrbitControls.update() in the shell.
|
|
76
|
+
*/
|
|
77
|
+
export function orbitFollowStep(input) {
|
|
78
|
+
const target = { ...input.state.target };
|
|
79
|
+
const camera = { ...input.state.camera };
|
|
80
|
+
let lockedDistance = input.state.lockedDistance;
|
|
81
|
+
const followEnabled = input.config.followEnabled !== false;
|
|
82
|
+
const desiredTarget = followEnabled ? input.desiredTarget : target;
|
|
83
|
+
const previousTarget = { ...target };
|
|
84
|
+
const targetBlend = smoothBlend(input.deltaSeconds, resolveTargetSmoothing(input.config, input.dragging));
|
|
85
|
+
const lerpedTarget = lerpVec3(target, desiredTarget, targetBlend);
|
|
86
|
+
target.x = lerpedTarget.x;
|
|
87
|
+
target.y = lerpedTarget.y;
|
|
88
|
+
target.z = lerpedTarget.z;
|
|
89
|
+
// OrbitControls preserves camera world position when only the target moves.
|
|
90
|
+
camera.x += target.x - previousTarget.x;
|
|
91
|
+
camera.y += target.y - previousTarget.y;
|
|
92
|
+
camera.z += target.z - previousTarget.z;
|
|
93
|
+
const followLock = input.config.followLock !== false;
|
|
94
|
+
if (followLock && !input.dragging && lockedDistance !== null) {
|
|
95
|
+
const currentDistance = distanceBetween(camera, target);
|
|
96
|
+
if (currentDistance > 0.0001) {
|
|
97
|
+
const scale = lockedDistance / currentDistance;
|
|
98
|
+
const idealCamera = {
|
|
99
|
+
x: target.x + (camera.x - target.x) * scale,
|
|
100
|
+
y: target.y + (camera.y - target.y) * scale,
|
|
101
|
+
z: target.z + (camera.z - target.z) * scale,
|
|
102
|
+
};
|
|
103
|
+
const distanceBlend = smoothBlend(input.deltaSeconds, input.config.distanceSmoothing);
|
|
104
|
+
const smoothedCamera = lerpVec3(camera, idealCamera, distanceBlend);
|
|
105
|
+
camera.x = smoothedCamera.x;
|
|
106
|
+
camera.y = smoothedCamera.y;
|
|
107
|
+
camera.z = smoothedCamera.z;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!input.dragging) {
|
|
111
|
+
lockedDistance = distanceBetween(camera, target);
|
|
112
|
+
}
|
|
113
|
+
return { target, camera, lockedDistance, distance: distanceBetween(camera, target) };
|
|
114
|
+
}
|
|
115
|
+
/** @deprecated Use orbitFollowStep — kept for older call sites. */
|
|
116
|
+
export function cameraFollowStep(input) {
|
|
117
|
+
const state = {
|
|
118
|
+
target: input.previousTarget ?? input.target,
|
|
119
|
+
camera: input.camera,
|
|
120
|
+
lockedDistance: input.lockedDistance,
|
|
121
|
+
};
|
|
122
|
+
const stepped = orbitFollowStep({
|
|
123
|
+
state,
|
|
124
|
+
desiredTarget: input.target,
|
|
125
|
+
deltaSeconds: 1,
|
|
126
|
+
config: { ...DEFAULT_ORBIT_CAMERA, targetSmoothing: 1000, distanceSmoothing: 1000 },
|
|
127
|
+
dragging: false,
|
|
128
|
+
});
|
|
129
|
+
return { entityId: "", target: stepped.target, camera: stepped.camera, distance: stepped.distance };
|
|
130
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { defineGame } from "@jgengine/core/game/defineGame";
|
|
3
|
+
import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog";
|
|
4
|
+
import { CurrencyPill, HealthBar, SlotGrid } from "@jgengine/react/components";
|
|
5
|
+
import { useFeed, useGameStore, usePlayer, useTarget } from "@jgengine/react/hooks";
|
|
6
|
+
const HERO = "hero";
|
|
7
|
+
const MOB = "gloomSlime";
|
|
8
|
+
const LOOT_TABLE = "slime-drops";
|
|
9
|
+
const GOLD = "gold";
|
|
10
|
+
const HOTBAR = "hotbar";
|
|
11
|
+
const MELEE_DAMAGE = 6;
|
|
12
|
+
const MELEE_RANGE = 1.9;
|
|
13
|
+
const MELEE_COOLDOWN = 1.2;
|
|
14
|
+
const AGGRO_RADIUS = 11;
|
|
15
|
+
const MOB_SPAWNS = [
|
|
16
|
+
[7, 0, -6],
|
|
17
|
+
[-8, 0, 5],
|
|
18
|
+
[6, 0, 8],
|
|
19
|
+
];
|
|
20
|
+
const entityCatalog = {
|
|
21
|
+
[HERO]: {
|
|
22
|
+
stats: { health: { max: 100 }, mana: { max: 60 } },
|
|
23
|
+
receive: { damage: { order: ["health"] } },
|
|
24
|
+
movement: { poses: ["standing", "running", "crouch"], walkSpeed: 2.4 },
|
|
25
|
+
},
|
|
26
|
+
[MOB]: {
|
|
27
|
+
stats: { health: { max: 60 } },
|
|
28
|
+
receive: { damage: { order: ["health"] } },
|
|
29
|
+
onDeath: { drops: LOOT_TABLE },
|
|
30
|
+
role: "enemy",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const itemCatalog = {
|
|
34
|
+
sword: { use: "swingSword", weapon: { damage: 22 } },
|
|
35
|
+
zap: { use: "castZap", weapon: { damage: 35, manaCost: 12, range: 18 } },
|
|
36
|
+
};
|
|
37
|
+
const game = defineGame({
|
|
38
|
+
name: "gameplayer-demo",
|
|
39
|
+
assets: createAssetCatalog(),
|
|
40
|
+
multiplayer: null,
|
|
41
|
+
inventories: { [HOTBAR]: { slots: 4, hud: "hotbar" } },
|
|
42
|
+
input: {
|
|
43
|
+
moveForward: ["KeyW"],
|
|
44
|
+
moveBack: ["KeyS"],
|
|
45
|
+
moveLeft: ["KeyA"],
|
|
46
|
+
moveRight: ["KeyD"],
|
|
47
|
+
jump: ["Space"],
|
|
48
|
+
sprint: ["Shift"],
|
|
49
|
+
turnLeft: ["KeyQ"],
|
|
50
|
+
turnRight: ["KeyE"],
|
|
51
|
+
tabTarget: ["Tab"],
|
|
52
|
+
clearTarget: ["Escape"],
|
|
53
|
+
slot1: ["Digit1"],
|
|
54
|
+
slot2: ["Digit2"],
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
let elapsed = 0;
|
|
58
|
+
let nextSpawnIndex = 0;
|
|
59
|
+
const meleeReadyAt = new Map();
|
|
60
|
+
function spawnMob(ctx) {
|
|
61
|
+
const at = MOB_SPAWNS[nextSpawnIndex % MOB_SPAWNS.length];
|
|
62
|
+
nextSpawnIndex += 1;
|
|
63
|
+
ctx.scene.entity.spawn(MOB, { position: at, role: "npc" });
|
|
64
|
+
}
|
|
65
|
+
function spawnHero(ctx) {
|
|
66
|
+
ctx.scene.entity.spawn(HERO, {
|
|
67
|
+
id: ctx.player.userId,
|
|
68
|
+
position: [0, 0, 0],
|
|
69
|
+
role: "player",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function onInit(ctx) {
|
|
73
|
+
elapsed = 0;
|
|
74
|
+
nextSpawnIndex = 0;
|
|
75
|
+
meleeReadyAt.clear();
|
|
76
|
+
ctx.game.loot.register({ id: LOOT_TABLE, entries: [{ currency: GOLD, count: [2, 5], weight: 1 }] });
|
|
77
|
+
ctx.game.feed.bind("entity.died");
|
|
78
|
+
ctx.game.events.on("entity.died", (event) => {
|
|
79
|
+
if (event.catalogId === MOB) {
|
|
80
|
+
meleeReadyAt.delete(event.instanceId);
|
|
81
|
+
spawnMob(ctx);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
ctx.item.use.register({
|
|
85
|
+
swingSword: {
|
|
86
|
+
apply(state, input) {
|
|
87
|
+
const hits = state.scene.entity
|
|
88
|
+
.queryArc({ from: input.from, aim: input.aim ?? { yaw: 0, pitch: 0 }, radius: 3.2, halfAngleDeg: 70 })
|
|
89
|
+
.filter((id) => state.scene.entity.get(id)?.name === MOB);
|
|
90
|
+
for (const id of hits) {
|
|
91
|
+
state.scene.entity.effect({ from: input.from, to: id, effect: "damage", via: { item: input.itemId } });
|
|
92
|
+
}
|
|
93
|
+
return { state };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
castZap: {
|
|
97
|
+
can(state, input) {
|
|
98
|
+
const targetId = state.scene.entity.getTarget(input.from);
|
|
99
|
+
if (targetId === null)
|
|
100
|
+
return { reason: "no-target" };
|
|
101
|
+
const range = state.item.weapon.getStat(input.itemId, "range") ?? Number.POSITIVE_INFINITY;
|
|
102
|
+
const distance = state.scene.entity.distance(input.from, targetId);
|
|
103
|
+
if (distance === null || distance > range)
|
|
104
|
+
return { reason: "out-of-range" };
|
|
105
|
+
const cost = state.item.weapon.getStat(input.itemId, "manaCost") ?? 0;
|
|
106
|
+
const mana = state.scene.entity.stats.get(input.from, "mana");
|
|
107
|
+
if (mana === null || mana.current < cost)
|
|
108
|
+
return { reason: "no-mana" };
|
|
109
|
+
return null;
|
|
110
|
+
},
|
|
111
|
+
apply(state, input) {
|
|
112
|
+
const targetId = state.scene.entity.getTarget(input.from);
|
|
113
|
+
if (targetId === null)
|
|
114
|
+
return { state, error: "no-target" };
|
|
115
|
+
const cost = state.item.weapon.getStat(input.itemId, "manaCost") ?? 0;
|
|
116
|
+
state.scene.entity.stats.delta(input.from, "mana", -cost);
|
|
117
|
+
state.scene.entity.effect({ from: input.from, to: targetId, effect: "damage", via: { item: input.itemId } });
|
|
118
|
+
return { state };
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function onNewPlayer(ctx) {
|
|
124
|
+
spawnHero(ctx);
|
|
125
|
+
ctx.player.inventory.put(HOTBAR, "sword", 1);
|
|
126
|
+
ctx.player.inventory.put(HOTBAR, "zap", 1);
|
|
127
|
+
ctx.game.economy.grant(ctx.player.userId, GOLD, 10);
|
|
128
|
+
for (let index = 0; index < MOB_SPAWNS.length; index += 1)
|
|
129
|
+
spawnMob(ctx);
|
|
130
|
+
}
|
|
131
|
+
function onTick(ctx, dt) {
|
|
132
|
+
elapsed += dt;
|
|
133
|
+
const playerId = ctx.player.userId;
|
|
134
|
+
const player = ctx.scene.entity.get(playerId);
|
|
135
|
+
if (player === null) {
|
|
136
|
+
spawnHero(ctx);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const mana = ctx.scene.entity.stats.get(playerId, "mana");
|
|
140
|
+
if (mana !== null && mana.current < mana.max)
|
|
141
|
+
ctx.scene.entity.stats.delta(playerId, "mana", 5 * dt);
|
|
142
|
+
for (const mob of ctx.scene.entity.list()) {
|
|
143
|
+
if (mob.name !== MOB)
|
|
144
|
+
continue;
|
|
145
|
+
const distance = ctx.scene.entity.distance(mob.id, playerId);
|
|
146
|
+
if (distance === null || distance > AGGRO_RADIUS)
|
|
147
|
+
continue;
|
|
148
|
+
if (distance > MELEE_RANGE) {
|
|
149
|
+
const next = ctx.scene.entity.moveToward(mob.id, playerId, {
|
|
150
|
+
speed: 2,
|
|
151
|
+
stopDistance: MELEE_RANGE * 0.8,
|
|
152
|
+
dt,
|
|
153
|
+
});
|
|
154
|
+
if (next !== null) {
|
|
155
|
+
ctx.scene.entity.setPose(mob.id, {
|
|
156
|
+
position: next,
|
|
157
|
+
rotationY: Math.atan2(player.position[0] - next[0], player.position[2] - next[2]),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if ((meleeReadyAt.get(mob.id) ?? 0) <= elapsed) {
|
|
162
|
+
meleeReadyAt.set(mob.id, elapsed + MELEE_COOLDOWN);
|
|
163
|
+
ctx.scene.entity.effect({ from: mob.id, to: playerId, effect: "damage", via: { amount: MELEE_DAMAGE } });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function VitalsPanel({ userId }) {
|
|
168
|
+
return (_jsxs("div", { className: "rounded bg-black/60 p-2", children: [_jsx(HealthBar, { instanceId: userId, statId: "health", className: "h-2.5 overflow-hidden rounded bg-white/15", fillClassName: "bg-emerald-400" }), _jsx(HealthBar, { instanceId: userId, statId: "mana", className: "mt-1 h-1.5 overflow-hidden rounded bg-white/15", fillClassName: "bg-sky-400" })] }));
|
|
169
|
+
}
|
|
170
|
+
function TargetPanel({ userId }) {
|
|
171
|
+
const targetId = useTarget(userId);
|
|
172
|
+
const targetName = useGameStore((ctx) => targetId === null ? null : (ctx.scene.entity.get(targetId)?.name ?? null));
|
|
173
|
+
if (targetId === null || targetName === null) {
|
|
174
|
+
return _jsx("p", { className: "rounded bg-black/50 px-2 py-1 text-center text-xs text-white/50", children: "Tab: target a mob" });
|
|
175
|
+
}
|
|
176
|
+
return (_jsxs("div", { className: "rounded bg-black/60 p-2", children: [_jsx("p", { className: "text-xs uppercase tracking-wide text-red-300", children: targetName }), _jsx(HealthBar, { instanceId: targetId, statId: "health", className: "mt-1 h-2 overflow-hidden rounded bg-white/15", fillClassName: "bg-red-400" })] }));
|
|
177
|
+
}
|
|
178
|
+
function KillFeedPanel() {
|
|
179
|
+
const kills = useFeed({ action: "entity.died", limit: 5 });
|
|
180
|
+
return (_jsx("ul", { className: "space-y-0.5 text-right text-xs text-white/70", children: kills
|
|
181
|
+
.slice()
|
|
182
|
+
.reverse()
|
|
183
|
+
.map((entry, index) => (_jsxs("li", { children: [entry.data.catalogId ?? "?", " down"] }, `${entry.at}-${index}`))) }));
|
|
184
|
+
}
|
|
185
|
+
function HotbarPanel() {
|
|
186
|
+
return (_jsx(SlotGrid, { inventoryId: HOTBAR, className: "flex gap-1.5", renderSlot: (slot, index) => (_jsxs("div", { className: "w-16 rounded border border-white/20 bg-black/60 px-1.5 py-1 text-center text-[11px]", children: [_jsx("span", { className: "text-white/50", children: index + 1 }), _jsx("p", { className: "truncate text-white", children: slot === null ? "—" : slot.itemId })] })) }));
|
|
187
|
+
}
|
|
188
|
+
function DemoGameUI() {
|
|
189
|
+
const player = usePlayer();
|
|
190
|
+
return (_jsxs("div", { className: "pointer-events-none absolute inset-0 font-mono text-white", children: [_jsx("div", { className: "absolute left-1/2 top-4 w-56 -translate-x-1/2", children: _jsx(TargetPanel, { userId: player.userId }) }), _jsxs("div", { className: "absolute right-4 top-4 flex flex-col items-end gap-2", children: [_jsx(CurrencyPill, { currencyId: GOLD, className: "rounded bg-black/60 px-2 py-1 text-sm text-amber-300" }), _jsx(KillFeedPanel, {})] }), _jsx("div", { className: "absolute bottom-4 left-4 w-64", children: _jsx(VitalsPanel, { userId: player.userId }) }), _jsx("div", { className: "absolute bottom-4 left-1/2 -translate-x-1/2", children: _jsx(HotbarPanel, {}) }), _jsx("div", { className: "absolute bottom-4 right-4 max-w-[220px] text-right text-[11px] leading-4 text-white/40", children: "WASD move \u00B7 Space jump \u00B7 Shift sprint \u00B7 Q/E turn \u00B7 Tab target \u00B7 Esc clear \u00B7 1 sword \u00B7 2 zap" })] }));
|
|
191
|
+
}
|
|
192
|
+
export const demoGame = {
|
|
193
|
+
game,
|
|
194
|
+
content: {
|
|
195
|
+
itemById: (itemId) => itemCatalog[itemId] ?? null,
|
|
196
|
+
entityById: (catalogId) => entityCatalog[catalogId] ?? null,
|
|
197
|
+
},
|
|
198
|
+
loop: { onInit, onNewPlayer, onTick },
|
|
199
|
+
GameUI: DemoGameUI,
|
|
200
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GameDefinition } from "@jgengine/core/game/defineGame";
|
|
2
|
+
import { type WsBackend } from "@jgengine/ws/createWsBackend";
|
|
3
|
+
export type ShellMultiplayer = {
|
|
4
|
+
gameId: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
backend: WsBackend;
|
|
7
|
+
feedActions: string[];
|
|
8
|
+
};
|
|
9
|
+
export declare function resolveShellMultiplayer(args: {
|
|
10
|
+
game: GameDefinition;
|
|
11
|
+
gameId: string;
|
|
12
|
+
url?: string;
|
|
13
|
+
userId?: string;
|
|
14
|
+
force?: boolean;
|
|
15
|
+
feedActions?: string[];
|
|
16
|
+
}): ShellMultiplayer | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createWsBackend } from "@jgengine/ws/createWsBackend";
|
|
2
|
+
function adapterKind(multiplayer) {
|
|
3
|
+
if (typeof multiplayer !== "object" || multiplayer === null)
|
|
4
|
+
return null;
|
|
5
|
+
const direct = multiplayer.kind;
|
|
6
|
+
if (typeof direct === "string")
|
|
7
|
+
return direct;
|
|
8
|
+
const nested = multiplayer.adapter?.kind;
|
|
9
|
+
return typeof nested === "string" ? nested : null;
|
|
10
|
+
}
|
|
11
|
+
export function resolveShellMultiplayer(args) {
|
|
12
|
+
if (args.force !== true && adapterKind(args.game.multiplayer) !== "ws")
|
|
13
|
+
return null;
|
|
14
|
+
const userId = args.userId ?? `player-${Math.random().toString(36).slice(2, 10)}`;
|
|
15
|
+
return {
|
|
16
|
+
gameId: args.gameId,
|
|
17
|
+
userId,
|
|
18
|
+
backend: createWsBackend({ url: args.url ?? "ws://localhost:8080/ws", userId }),
|
|
19
|
+
feedActions: args.feedActions ?? ["entity.died"],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { PlayableGame as EnginePlayableGame } from "@jgengine/core/game/playableGame";
|
|
3
|
+
export type PlayableGame = EnginePlayableGame<ComponentType, ComponentType>;
|
|
4
|
+
export type GameRegistry = Record<string, () => Promise<PlayableGame>>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jgengine/shell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Noisemaker111/jgengine.git",
|
|
11
|
+
"directory": "packages/shell"
|
|
12
|
+
},
|
|
13
|
+
"files": ["dist"],
|
|
14
|
+
"exports": {
|
|
15
|
+
"./*": {
|
|
16
|
+
"types": "./dist/*.d.ts",
|
|
17
|
+
"default": "./dist/*.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
|
|
22
|
+
"check-types": "tsc --noEmit -p tsconfig.json",
|
|
23
|
+
"test": "bun test src"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@jgengine/core": "^0.1.1",
|
|
27
|
+
"@jgengine/react": "^0.1.0",
|
|
28
|
+
"@jgengine/ws": "^0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@react-three/drei": "^10.0.0",
|
|
32
|
+
"@react-three/fiber": "^9.0.0",
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"three": ">=0.170.0",
|
|
35
|
+
"three-stdlib": "^2.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@react-three/drei": "^10.7.7",
|
|
39
|
+
"@react-three/fiber": "^9.5.0",
|
|
40
|
+
"@types/react": "^19",
|
|
41
|
+
"@types/three": "^0.182.0",
|
|
42
|
+
"react": "19.2.3",
|
|
43
|
+
"three": "^0.182.0",
|
|
44
|
+
"three-stdlib": "^2.35.0"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|