@jgengine/shell 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +167 -2
- package/dist/GamePlayerShell.js +210 -39
- package/dist/audio/AudioComponents.d.ts +12 -0
- package/dist/audio/AudioComponents.js +53 -0
- package/dist/audio/audioEngine.d.ts +23 -0
- package/dist/audio/audioEngine.js +110 -0
- package/dist/camera/GameCameraRig.d.ts +11 -0
- package/dist/camera/GameCameraRig.js +42 -0
- package/dist/camera/GameOrbitCamera.d.ts +3 -1
- package/dist/camera/GameOrbitCamera.js +4 -2
- package/dist/camera/cameraRigs.d.ts +22 -0
- package/dist/camera/cameraRigs.js +485 -0
- package/dist/camera/index.d.ts +5 -0
- package/dist/camera/index.js +5 -0
- package/dist/camera/rigMath.d.ts +130 -0
- package/dist/camera/rigMath.js +338 -0
- package/dist/camera/shakeChannel.d.ts +29 -0
- package/dist/camera/shakeChannel.js +38 -0
- package/dist/demo/builderDemo.d.ts +2 -0
- package/dist/demo/builderDemo.js +189 -0
- package/dist/demo/mapDemo.d.ts +2 -0
- package/dist/demo/mapDemo.js +206 -0
- package/dist/demo/pointerDemo.d.ts +2 -0
- package/dist/demo/pointerDemo.js +151 -0
- package/dist/demo/sensorShowcase.d.ts +2 -0
- package/dist/demo/sensorShowcase.js +131 -0
- package/dist/demo/survivalDemo.d.ts +2 -0
- package/dist/demo/survivalDemo.js +291 -0
- package/dist/map/MapMarkerBeacons.d.ts +12 -0
- package/dist/map/MapMarkerBeacons.js +16 -0
- package/dist/map/index.d.ts +2 -0
- package/dist/map/index.js +2 -0
- package/dist/map/terrainMap.d.ts +23 -0
- package/dist/map/terrainMap.js +79 -0
- package/dist/pointer/PointerOverlays.d.ts +12 -0
- package/dist/pointer/PointerOverlays.js +17 -0
- package/dist/pointer/PointerProbe.d.ts +4 -0
- package/dist/pointer/PointerProbe.js +27 -0
- package/dist/pointer/pointerService.d.ts +24 -0
- package/dist/pointer/pointerService.js +68 -0
- package/dist/replay/useSessionRecorder.d.ts +14 -0
- package/dist/replay/useSessionRecorder.js +22 -0
- package/dist/structures/PlacementGhost.d.ts +8 -0
- package/dist/structures/PlacementGhost.js +11 -0
- package/dist/structures/index.d.ts +2 -0
- package/dist/structures/index.js +2 -0
- package/dist/structures.d.ts +1 -0
- package/dist/structures.js +1 -0
- package/dist/terrain/CarvedTerrain.d.ts +22 -0
- package/dist/terrain/CarvedTerrain.js +19 -0
- package/dist/terrain/EditableGround.d.ts +11 -0
- package/dist/terrain/EditableGround.js +38 -0
- package/dist/terrain/TerraformBrushCursor.d.ts +8 -0
- package/dist/terrain/TerraformBrushCursor.js +13 -0
- package/dist/terrain/index.d.ts +4 -1
- package/dist/terrain/index.js +4 -1
- package/dist/terrain/terrainMath.d.ts +14 -1
- package/dist/terrain/terrainMath.js +80 -0
- package/dist/vision/FrustumSensorHud.d.ts +21 -0
- package/dist/vision/FrustumSensorHud.js +67 -0
- package/dist/vision/HiddenStateProbeHud.d.ts +11 -0
- package/dist/vision/HiddenStateProbeHud.js +11 -0
- package/dist/vision/RevealVision.d.ts +28 -0
- package/dist/vision/RevealVision.js +53 -0
- package/dist/weather/FireSpreadLayer.d.ts +14 -0
- package/dist/weather/FireSpreadLayer.js +68 -0
- package/dist/weather/index.d.ts +1 -0
- package/dist/weather/index.js +1 -0
- package/dist/world/InstancedJoints.d.ts +14 -0
- package/dist/world/InstancedJoints.js +32 -0
- package/dist/world/WorldHud.d.ts +2 -0
- package/dist/world/WorldHud.js +70 -12
- package/dist/world/WorldItems.d.ts +5 -0
- package/dist/world/WorldItems.js +31 -0
- package/dist/world/floatTextStyle.d.ts +14 -0
- package/dist/world/floatTextStyle.js +39 -0
- package/package.json +8 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { defineGame } from "@jgengine/core/game/defineGame";
|
|
4
|
+
import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog";
|
|
5
|
+
import { localPlayerEntity, useGameStore } from "@jgengine/react/hooks";
|
|
6
|
+
import { useGameContext } from "@jgengine/react/provider";
|
|
7
|
+
import { useSessionRecorder } from "../replay/useSessionRecorder.js";
|
|
8
|
+
import { FrustumSensorReadout } from "../vision/FrustumSensorHud.js";
|
|
9
|
+
import { SensorReadoutMeter, useHiddenStateProbe } from "../vision/HiddenStateProbeHud.js";
|
|
10
|
+
import { RevealHighlights, RevealScreenTint } from "../vision/RevealVision.js";
|
|
11
|
+
const HERO = "hero";
|
|
12
|
+
const CULPRIT = "culprit";
|
|
13
|
+
const CLUE = "clue-mote";
|
|
14
|
+
const WALL = "sensor-wall";
|
|
15
|
+
const CULPRIT_ID = "sensor-showcase-culprit";
|
|
16
|
+
const TAGS_BY_CATALOG_ID = {
|
|
17
|
+
[CULPRIT]: ["culprit"],
|
|
18
|
+
[CLUE]: ["clue"],
|
|
19
|
+
};
|
|
20
|
+
const HIDDEN_SOURCES = [
|
|
21
|
+
{ id: "cold-spot", position: [0, 0, -9], variables: { ghostType: "poltergeist", activity: 0.85 } },
|
|
22
|
+
];
|
|
23
|
+
const entityCatalog = {
|
|
24
|
+
[HERO]: { movement: { walkSpeed: 2.6 } },
|
|
25
|
+
[CULPRIT]: {},
|
|
26
|
+
[CLUE]: {},
|
|
27
|
+
};
|
|
28
|
+
const game = defineGame({
|
|
29
|
+
name: "sensor-showcase",
|
|
30
|
+
assets: createAssetCatalog(),
|
|
31
|
+
multiplayer: null,
|
|
32
|
+
input: {
|
|
33
|
+
moveForward: ["KeyW"],
|
|
34
|
+
moveBack: ["KeyS"],
|
|
35
|
+
moveLeft: ["KeyA"],
|
|
36
|
+
moveRight: ["KeyD"],
|
|
37
|
+
jump: ["Space"],
|
|
38
|
+
sprint: ["ShiftLeft"],
|
|
39
|
+
turnLeft: ["KeyQ"],
|
|
40
|
+
turnRight: ["KeyE"],
|
|
41
|
+
darkSight: ["KeyV"],
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
let elapsed = 0;
|
|
45
|
+
function onInit(ctx) {
|
|
46
|
+
elapsed = 0;
|
|
47
|
+
ctx.game.commands.define("darkSight", {
|
|
48
|
+
apply(state) {
|
|
49
|
+
const toggle = darkSightStateFor(state);
|
|
50
|
+
toggle.on = !toggle.on;
|
|
51
|
+
for (const listener of toggle.listeners)
|
|
52
|
+
listener();
|
|
53
|
+
return state;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function onNewPlayer(ctx) {
|
|
58
|
+
ctx.scene.entity.spawn(HERO, { id: ctx.player.userId, position: [0, 0, 4], role: "player" });
|
|
59
|
+
ctx.scene.entity.spawn(CULPRIT, { id: CULPRIT_ID, position: [0, 0, -9], role: "npc" });
|
|
60
|
+
ctx.scene.entity.spawn(CLUE, { position: [4, 0, -6], role: "prop" });
|
|
61
|
+
for (let x = -3; x <= 3; x += 1) {
|
|
62
|
+
for (let y = 0; y <= 1; y += 1) {
|
|
63
|
+
ctx.scene.object.place(WALL, x, y, -2);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function onTick(ctx, dt) {
|
|
68
|
+
elapsed += dt;
|
|
69
|
+
const culprit = ctx.scene.entity.get(CULPRIT_ID);
|
|
70
|
+
if (culprit === null)
|
|
71
|
+
return;
|
|
72
|
+
const x = Math.sin(elapsed * 0.35) * 5;
|
|
73
|
+
ctx.scene.entity.setPose(CULPRIT_ID, { position: [x, 0, -9], dt });
|
|
74
|
+
}
|
|
75
|
+
function tagsForEntity(entity) {
|
|
76
|
+
return TAGS_BY_CATALOG_ID[entity.name] ?? [];
|
|
77
|
+
}
|
|
78
|
+
const darkSightByContext = new WeakMap();
|
|
79
|
+
function darkSightStateFor(ctx) {
|
|
80
|
+
let state = darkSightByContext.get(ctx);
|
|
81
|
+
if (state === undefined) {
|
|
82
|
+
state = { on: false, listeners: new Set() };
|
|
83
|
+
darkSightByContext.set(ctx, state);
|
|
84
|
+
}
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
/** Dark Sight toggle shared between the WorldOverlay (3D reveal) and GameUI (screen tint + button) — both mount as separate React trees over the same GameContext. */
|
|
88
|
+
function useDarkSight() {
|
|
89
|
+
const ctx = useGameContext();
|
|
90
|
+
const state = darkSightStateFor(ctx);
|
|
91
|
+
const [, forceRender] = useState(0);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const listener = () => forceRender((count) => count + 1);
|
|
94
|
+
state.listeners.add(listener);
|
|
95
|
+
return () => {
|
|
96
|
+
state.listeners.delete(listener);
|
|
97
|
+
};
|
|
98
|
+
}, [state]);
|
|
99
|
+
return [state.on, () => ctx.game.commands.run("darkSight", {})];
|
|
100
|
+
}
|
|
101
|
+
function GhostTrail() {
|
|
102
|
+
const buffer = useSessionRecorder(CULPRIT_ID, { maxDurationSeconds: 6 });
|
|
103
|
+
const frames = buffer.frames().filter((_, index) => index % 6 === 0);
|
|
104
|
+
return (_jsx(_Fragment, { children: frames.map((frame, index) => (_jsxs("mesh", { position: [frame.data.position[0], frame.data.position[1] + 0.2, frame.data.position[2]], children: [_jsx("sphereGeometry", { args: [0.08, 8, 8] }), _jsx("meshBasicMaterial", { color: "#a78bfa", transparent: true, opacity: 0.45, depthTest: false })] }, index))) }));
|
|
105
|
+
}
|
|
106
|
+
function SensorWorldOverlay() {
|
|
107
|
+
const [darkSight] = useDarkSight();
|
|
108
|
+
return (_jsxs(_Fragment, { children: [_jsx(RevealHighlights, { enabled: darkSight, radius: 20, tags: ["culprit", "clue"], resolveTags: tagsForEntity }), _jsx(FrustumSensorReadout, { subjectIds: [CULPRIT_ID], subjectRadius: 0.6, idealDistance: 7 }), _jsx(GhostTrail, {})] }));
|
|
109
|
+
}
|
|
110
|
+
function SensorHud() {
|
|
111
|
+
const [darkSight, toggleDarkSight] = useDarkSight();
|
|
112
|
+
const playerPosition = useGameStore((ctx) => localPlayerEntity(ctx)?.position ?? [0, 0, 0]);
|
|
113
|
+
const reading = useHiddenStateProbe(playerPosition, HIDDEN_SOURCES, { range: 18, variableId: "activity" });
|
|
114
|
+
return (_jsxs("div", { className: "pointer-events-none absolute inset-0 font-mono text-white", children: [_jsx(RevealScreenTint, { enabled: darkSight }), _jsx("div", { className: "absolute left-4 top-4 flex flex-col gap-2", children: _jsx(SensorReadoutMeter, { label: "EMF reader", reading: reading }) }), _jsx("div", { className: "pointer-events-auto absolute right-4 top-4", children: _jsx("button", { onClick: toggleDarkSight, className: `rounded border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${darkSight
|
|
115
|
+
? "border-sky-300 bg-sky-400/20 text-sky-100"
|
|
116
|
+
: "border-white/25 bg-black/60 text-white/70 hover:bg-white/10"}`, children: "Dark Sight (V)" }) }), _jsx("div", { className: "absolute bottom-4 left-4 max-w-xs text-[11px] leading-4 text-white/50", children: "WASD move \u00B7 Q/E turn \u00B7 V toggles Dark Sight (reveals tagged entities through the wall) \u00B7 EMF reader tracks the culprit's cold spot \u00B7 bottom-right shows framing/dwell for the culprit in your camera view." })] }));
|
|
117
|
+
}
|
|
118
|
+
export const sensorShowcaseGame = {
|
|
119
|
+
game,
|
|
120
|
+
content: {
|
|
121
|
+
entityById: (catalogId) => entityCatalog[catalogId] ?? null,
|
|
122
|
+
},
|
|
123
|
+
loop: { onInit, onNewPlayer, onTick },
|
|
124
|
+
GameUI: SensorHud,
|
|
125
|
+
WorldOverlay: SensorWorldOverlay,
|
|
126
|
+
camera: {
|
|
127
|
+
cinematic: {
|
|
128
|
+
keyframes: [{ position: { x: 0, y: 1.6, z: 3 }, lookAt: { x: 0, y: 1, z: -9 }, fov: 55 }],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { Fragment as _Fragment, 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 { createDecayMeterSet, } from "@jgengine/core/survival/decayMeter";
|
|
5
|
+
import { createMoodleStack, stackMoodles, } from "@jgengine/core/survival/moodle";
|
|
6
|
+
import { createMultiRegionHealth, } from "@jgengine/core/survival/regionHealth";
|
|
7
|
+
import { environment, rain, terrain } from "@jgengine/core/world/features";
|
|
8
|
+
import { createEnvironmentField, } from "@jgengine/core/world/envField";
|
|
9
|
+
import { resolveTerrainField } from "@jgengine/core/world/terrain";
|
|
10
|
+
import { createFireGrid, resolveWeather, } from "@jgengine/core/world/weather";
|
|
11
|
+
import { useEngineState } from "@jgengine/react/engineStore";
|
|
12
|
+
import { EnvironmentScene } from "../environment/EnvironmentScene.js";
|
|
13
|
+
import { FireSpreadLayer } from "../weather/FireSpreadLayer.js";
|
|
14
|
+
const HERO = "survivor";
|
|
15
|
+
const HOTBAR = "hotbar";
|
|
16
|
+
const DAY_LENGTH = 24 * 3600;
|
|
17
|
+
const FIRE_ORIGIN = [-16, -16];
|
|
18
|
+
const FIRE_CELL = 2;
|
|
19
|
+
const CAMPFIRE = { x: 8, z: 0, radius: 9, strength: 26 };
|
|
20
|
+
const weatherTable = {
|
|
21
|
+
clear: {},
|
|
22
|
+
rain: { grip: 0.65, visibility: 0.7, spread: 0.35, chill: -4 },
|
|
23
|
+
storm: { grip: 0.45, visibility: 0.4, structureDamage: 4, ignition: 0.15, spread: 0.25, chill: -8 },
|
|
24
|
+
};
|
|
25
|
+
function createSurvivalSim() {
|
|
26
|
+
const meters = createDecayMeterSet([
|
|
27
|
+
{
|
|
28
|
+
id: "hunger",
|
|
29
|
+
max: 100,
|
|
30
|
+
rate: 0.9,
|
|
31
|
+
thresholds: [
|
|
32
|
+
{ id: "peckish", label: "Peckish", at: 55, when: "below", severity: "neutral" },
|
|
33
|
+
{ id: "hungry", label: "Hungry", at: 30, when: "below", severity: "warning" },
|
|
34
|
+
{ id: "starving", label: "Starving", at: 12, when: "below", severity: "critical" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "thirst",
|
|
39
|
+
max: 100,
|
|
40
|
+
rate: 1.3,
|
|
41
|
+
thresholds: [
|
|
42
|
+
{ id: "thirsty", label: "Thirsty", at: 35, when: "below", severity: "warning" },
|
|
43
|
+
{ id: "dehydrated", label: "Dehydrated", at: 12, when: "below", severity: "critical" },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "warmth",
|
|
48
|
+
max: 100,
|
|
49
|
+
start: 72,
|
|
50
|
+
rate: 0.6,
|
|
51
|
+
thresholds: [
|
|
52
|
+
{ id: "chilly", label: "Chilly", at: 45, when: "below", severity: "neutral" },
|
|
53
|
+
{ id: "freezing", label: "Freezing", at: 20, when: "below", severity: "critical" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "stamina",
|
|
58
|
+
max: 100,
|
|
59
|
+
rate: 0,
|
|
60
|
+
thresholds: [{ id: "winded", label: "Winded", at: 20, when: "below", severity: "warning" }],
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
const health = createMultiRegionHealth({
|
|
64
|
+
regions: [
|
|
65
|
+
{ id: "head", label: "Head", max: 35, vital: true, vulnerability: 2 },
|
|
66
|
+
{ id: "torso", label: "Torso", max: 85, vital: true },
|
|
67
|
+
{ id: "leftArm", label: "L. Arm", max: 55 },
|
|
68
|
+
{ id: "rightArm", label: "R. Arm", max: 55 },
|
|
69
|
+
{ id: "legs", label: "Legs", max: 70 },
|
|
70
|
+
],
|
|
71
|
+
ailments: {
|
|
72
|
+
bleed: {
|
|
73
|
+
id: "bleed",
|
|
74
|
+
label: "Bleeding",
|
|
75
|
+
region: "torso",
|
|
76
|
+
severity: "critical",
|
|
77
|
+
drainPerSecond: 1.4,
|
|
78
|
+
stacking: true,
|
|
79
|
+
treatedBy: ["bandage"],
|
|
80
|
+
},
|
|
81
|
+
fracture: {
|
|
82
|
+
id: "fracture",
|
|
83
|
+
label: "Fractured Leg",
|
|
84
|
+
region: "legs",
|
|
85
|
+
severity: "warning",
|
|
86
|
+
treatedBy: ["splint"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const buffs = createMoodleStack();
|
|
91
|
+
const fire = createFireGrid({
|
|
92
|
+
cols: 16,
|
|
93
|
+
rows: 16,
|
|
94
|
+
cellSize: FIRE_CELL,
|
|
95
|
+
origin: FIRE_ORIGIN,
|
|
96
|
+
spreadRate: 0.9,
|
|
97
|
+
burnRate: 0.05,
|
|
98
|
+
wind: [1, 0.35],
|
|
99
|
+
windBias: 0.7,
|
|
100
|
+
});
|
|
101
|
+
let weather = { kind: "rain", intensity: 0.55, wind: [1, 0.35] };
|
|
102
|
+
const field = createEnvironmentField({
|
|
103
|
+
dayLength: DAY_LENGTH,
|
|
104
|
+
baseTemperature: 14,
|
|
105
|
+
nightDrop: 14,
|
|
106
|
+
heatSources: [CAMPFIRE],
|
|
107
|
+
rain: () => (weather.kind === "clear" ? 0 : weather.intensity),
|
|
108
|
+
occluders: [{ x: 20, z: 18, w: 10, d: 10, shade: 1 }],
|
|
109
|
+
});
|
|
110
|
+
let version = 0;
|
|
111
|
+
let currentTime = 0;
|
|
112
|
+
const listeners = new Set();
|
|
113
|
+
function computeSnapshot() {
|
|
114
|
+
const resolved = resolveWeather(weather, weatherTable);
|
|
115
|
+
const temperature = field.temperature(0, 0, currentTime, 0);
|
|
116
|
+
return {
|
|
117
|
+
version,
|
|
118
|
+
meters: meters.snapshot(),
|
|
119
|
+
moodles: stackMoodles(buffs.list(), meters.moodles(), health.ailmentMoodles()),
|
|
120
|
+
regions: health.regions(),
|
|
121
|
+
overall: health.overall(),
|
|
122
|
+
temperature,
|
|
123
|
+
wetness: field.wetness(0, 0, currentTime),
|
|
124
|
+
weather,
|
|
125
|
+
visibility: resolved.visibility,
|
|
126
|
+
grip: resolved.grip,
|
|
127
|
+
burning: fire.burning,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
let snapshot = computeSnapshot();
|
|
131
|
+
function notify() {
|
|
132
|
+
version += 1;
|
|
133
|
+
snapshot = computeSnapshot();
|
|
134
|
+
for (const listener of listeners)
|
|
135
|
+
listener(snapshot);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
store: {
|
|
139
|
+
getState: () => snapshot,
|
|
140
|
+
subscribe: (listener) => {
|
|
141
|
+
listeners.add(listener);
|
|
142
|
+
return () => listeners.delete(listener);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
fire,
|
|
146
|
+
field,
|
|
147
|
+
bootstrap() {
|
|
148
|
+
health.damage("head", 9);
|
|
149
|
+
health.damage("legs", 26);
|
|
150
|
+
health.applyAilment("bleed");
|
|
151
|
+
health.applyAilment("bleed");
|
|
152
|
+
health.applyAilment("fracture");
|
|
153
|
+
buffs.add({ id: "cookedMeat", label: "Cooked Meat", duration: 320, severity: "good", note: "+HP regen" });
|
|
154
|
+
buffs.add({ id: "berries", label: "Berries", duration: 180, severity: "good" });
|
|
155
|
+
meters.refill("hunger", -58);
|
|
156
|
+
meters.refill("thirst", -66);
|
|
157
|
+
meters.refill("warmth", -34);
|
|
158
|
+
meters.refill("stamina", -70);
|
|
159
|
+
fire.ignite(-4, 4);
|
|
160
|
+
fire.ignite(-2, 4);
|
|
161
|
+
fire.ignite(-4, 6);
|
|
162
|
+
fire.ignite(0, 6);
|
|
163
|
+
notify();
|
|
164
|
+
},
|
|
165
|
+
tick(_ctx, dt, gameTime) {
|
|
166
|
+
currentTime = gameTime;
|
|
167
|
+
const sample = field.sample(0, 0, gameTime, 0);
|
|
168
|
+
const coldPenalty = sample.temperature < 8 ? 2.4 : sample.temperature < 16 ? 1.4 : 0.6;
|
|
169
|
+
meters.setRateModifier("warmth", coldPenalty);
|
|
170
|
+
meters.tick(dt);
|
|
171
|
+
buffs.tick(dt);
|
|
172
|
+
health.tick(dt);
|
|
173
|
+
const resolved = resolveWeather(weather, weatherTable);
|
|
174
|
+
const wetnessAt = (col, row) => field.wetness(FIRE_ORIGIN[0] + col * FIRE_CELL, FIRE_ORIGIN[1] + row * FIRE_CELL, gameTime);
|
|
175
|
+
fire.step(dt, { spread: resolved.spread, wetnessAt });
|
|
176
|
+
notify();
|
|
177
|
+
},
|
|
178
|
+
treat(itemId) {
|
|
179
|
+
health.treat(itemId);
|
|
180
|
+
notify();
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
let sim = createSurvivalSim();
|
|
185
|
+
const entityCatalog = {
|
|
186
|
+
[HERO]: {
|
|
187
|
+
stats: { health: { max: 100 }, stamina: { max: 100 } },
|
|
188
|
+
receive: { damage: { order: ["health"] } },
|
|
189
|
+
movement: { poses: ["standing", "running", "crouch"], walkSpeed: 2.4 },
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
const game = defineGame({
|
|
193
|
+
name: "survival-demo",
|
|
194
|
+
assets: createAssetCatalog(),
|
|
195
|
+
multiplayer: null,
|
|
196
|
+
inventories: { [HOTBAR]: { slots: 4, hud: "hotbar" } },
|
|
197
|
+
time: { start: 6 * 3600, dayLength: DAY_LENGTH, speeds: [1, 2, 3, 4] },
|
|
198
|
+
input: {
|
|
199
|
+
moveForward: ["KeyW"],
|
|
200
|
+
moveBack: ["KeyS"],
|
|
201
|
+
moveLeft: ["KeyA"],
|
|
202
|
+
moveRight: ["KeyD"],
|
|
203
|
+
jump: ["Space"],
|
|
204
|
+
sprint: ["Shift"],
|
|
205
|
+
slot1: ["Digit1"],
|
|
206
|
+
slot2: ["Digit2"],
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
function onInit(ctx) {
|
|
210
|
+
sim = createSurvivalSim();
|
|
211
|
+
sim.bootstrap();
|
|
212
|
+
ctx.item.use.register({
|
|
213
|
+
treatWound: {
|
|
214
|
+
apply(state, input) {
|
|
215
|
+
sim.treat(input.itemId);
|
|
216
|
+
return { state };
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function onNewPlayer(ctx) {
|
|
222
|
+
ctx.scene.entity.spawn(HERO, { id: ctx.player.userId, position: [0, 0, 0], role: "player" });
|
|
223
|
+
ctx.player.inventory.put(HOTBAR, "bandage", 3);
|
|
224
|
+
ctx.player.inventory.put(HOTBAR, "splint", 1);
|
|
225
|
+
}
|
|
226
|
+
function onTick(ctx, dt) {
|
|
227
|
+
sim.tick(ctx, dt, ctx.time.now());
|
|
228
|
+
}
|
|
229
|
+
const survivalWorld = environment({
|
|
230
|
+
terrain: terrain({ bounds: { w: 160, d: 160 }, height: 3, frequency: 0.03, seed: "survival" }),
|
|
231
|
+
weather: rain({ area: { w: 160, d: 120, h: 60 }, density: 0.5, wind: [1.2, 0.3] }),
|
|
232
|
+
});
|
|
233
|
+
const survivalField = resolveTerrainField(survivalWorld.terrain);
|
|
234
|
+
function SurvivalWorld() {
|
|
235
|
+
return (_jsxs(_Fragment, { children: [_jsx(EnvironmentScene, { feature: survivalWorld }), _jsx(FireSpreadLayer, { grid: sim.fire, cellSize: FIRE_CELL, origin: FIRE_ORIGIN, heightAt: survivalField.sampleHeight })] }));
|
|
236
|
+
}
|
|
237
|
+
const SEVERITY_STYLE = {
|
|
238
|
+
good: "border-emerald-400/70 bg-emerald-500/15 text-emerald-200",
|
|
239
|
+
neutral: "border-sky-400/60 bg-sky-500/10 text-sky-200",
|
|
240
|
+
warning: "border-amber-400/70 bg-amber-500/15 text-amber-200",
|
|
241
|
+
critical: "border-rose-500/80 bg-rose-600/20 text-rose-200",
|
|
242
|
+
};
|
|
243
|
+
function MoodleChip({ moodle }) {
|
|
244
|
+
return (_jsxs("div", { className: `flex min-w-[86px] items-center gap-2 rounded-md border px-2 py-1.5 shadow-lg shadow-black/40 backdrop-blur-sm ${SEVERITY_STYLE[moodle.severity]}`, children: [_jsxs("div", { className: "relative flex h-9 w-9 shrink-0 items-center justify-center rounded bg-black/40 text-base font-bold", children: [moodle.icon ?? moodle.label.slice(0, 1), moodle.stacks > 1 ? (_jsx("span", { className: "absolute -bottom-1 -right-1 rounded-full bg-black/80 px-1 text-[10px] font-bold text-white", children: moodle.stacks })) : null] }), _jsxs("div", { className: "leading-tight", children: [_jsx("p", { className: "text-xs font-semibold", children: moodle.label }), moodle.note !== undefined ? _jsx("p", { className: "text-[10px] opacity-80", children: moodle.note }) : null, moodle.fraction !== undefined ? (_jsx("div", { className: "mt-1 h-1 w-16 overflow-hidden rounded bg-black/50", children: _jsx("div", { className: "h-full bg-current", style: { width: `${Math.round(moodle.fraction * 100)}%` } }) })) : null] })] }));
|
|
245
|
+
}
|
|
246
|
+
function MoodleStackPanel() {
|
|
247
|
+
const state = useEngineState(sim.store);
|
|
248
|
+
return (_jsx("div", { className: "flex max-w-[220px] flex-wrap gap-1.5", children: state.moodles.map((moodle) => (_jsx(MoodleChip, { moodle: moodle }, moodle.id))) }));
|
|
249
|
+
}
|
|
250
|
+
const METER_STYLE = {
|
|
251
|
+
hunger: { label: "Hunger", fill: "bg-orange-400" },
|
|
252
|
+
thirst: { label: "Thirst", fill: "bg-sky-400" },
|
|
253
|
+
warmth: { label: "Warmth", fill: "bg-rose-400" },
|
|
254
|
+
stamina: { label: "Stamina", fill: "bg-lime-400" },
|
|
255
|
+
};
|
|
256
|
+
function MetersPanel() {
|
|
257
|
+
const state = useEngineState(sim.store);
|
|
258
|
+
return (_jsxs("div", { className: "w-60 rounded-lg border border-white/15 bg-black/70 p-3 shadow-xl shadow-black/50", children: [_jsx("p", { className: "mb-2 text-[11px] font-bold uppercase tracking-widest text-white/60", children: "Condition" }), _jsx("div", { className: "space-y-2", children: Object.entries(METER_STYLE).map(([id, style]) => {
|
|
259
|
+
const meter = state.meters[id];
|
|
260
|
+
if (meter === undefined)
|
|
261
|
+
return null;
|
|
262
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "mb-0.5 flex justify-between text-[11px] text-white/70", children: [_jsx("span", { children: style.label }), _jsx("span", { className: "tabular-nums", children: Math.round(meter.value) })] }), _jsx("div", { className: "h-2.5 overflow-hidden rounded bg-white/10", children: _jsx("div", { className: `h-full rounded ${style.fill}`, style: { width: `${Math.round(meter.fraction * 100)}%` } }) })] }, id));
|
|
263
|
+
}) })] }));
|
|
264
|
+
}
|
|
265
|
+
function RegionHealthPanel() {
|
|
266
|
+
const state = useEngineState(sim.store);
|
|
267
|
+
const barColor = (fraction) => fraction > 0.6 ? "bg-emerald-400" : fraction > 0.3 ? "bg-amber-400" : "bg-rose-500";
|
|
268
|
+
return (_jsxs("div", { className: "w-56 rounded-lg border border-white/15 bg-black/70 p-3 shadow-xl shadow-black/50", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsx("p", { className: "text-[11px] font-bold uppercase tracking-widest text-white/60", children: "Body" }), _jsxs("span", { className: "text-[11px] tabular-nums text-white/70", children: [Math.round(state.overall * 100), "%"] })] }), _jsx("div", { className: "space-y-1.5", children: state.regions.map((region) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: `w-14 shrink-0 text-[11px] ${region.vital ? "text-white" : "text-white/70"}`, children: region.label }), _jsx("div", { className: "h-2 flex-1 overflow-hidden rounded bg-white/10", children: _jsx("div", { className: `h-full rounded ${barColor(region.fraction)}`, style: { width: `${Math.round(region.fraction * 100)}%` } }) }), _jsx("span", { className: "w-8 shrink-0 text-right text-[10px] tabular-nums text-white/60", children: Math.round(region.current) })] }, region.id))) })] }));
|
|
269
|
+
}
|
|
270
|
+
function EnvironmentReadout() {
|
|
271
|
+
const state = useEngineState(sim.store);
|
|
272
|
+
return (_jsxs("div", { className: "rounded-lg border border-white/15 bg-black/70 px-3 py-2 text-right shadow-xl shadow-black/50", children: [_jsx("p", { className: "text-[11px] font-bold uppercase tracking-widest text-white/60", children: "Weather" }), _jsxs("p", { className: "mt-1 text-sm font-semibold capitalize text-sky-200", children: [state.weather.kind, " \u00B7 ", Math.round(state.weather.intensity * 100), "%"] }), _jsxs("div", { className: "mt-1 space-y-0.5 text-[11px] tabular-nums text-white/70", children: [_jsxs("p", { children: ["Temp ", state.temperature.toFixed(1), "\u00B0"] }), _jsxs("p", { children: ["Wet ", Math.round(state.wetness * 100), "%"] }), _jsxs("p", { children: ["Grip ", Math.round(state.grip * 100), "% \u00B7 Vis ", Math.round(state.visibility * 100), "%"] }), state.burning > 0 ? _jsxs("p", { className: "text-orange-300", children: [state.burning, " cells ablaze"] }) : null] })] }));
|
|
273
|
+
}
|
|
274
|
+
function SurvivalGameUI() {
|
|
275
|
+
return (_jsxs("div", { className: "pointer-events-none absolute inset-0 font-sans text-white", children: [_jsx("div", { className: "absolute left-4 top-4", children: _jsx(MoodleStackPanel, {}) }), _jsx("div", { className: "absolute right-4 top-4", children: _jsx(EnvironmentReadout, {}) }), _jsx("div", { className: "absolute bottom-4 left-4", children: _jsx(MetersPanel, {}) }), _jsx("div", { className: "absolute bottom-4 right-4", children: _jsx(RegionHealthPanel, {}) })] }));
|
|
276
|
+
}
|
|
277
|
+
const itemCatalog = {
|
|
278
|
+
bandage: { use: "treatWound" },
|
|
279
|
+
splint: { use: "treatWound" },
|
|
280
|
+
};
|
|
281
|
+
export const survivalDemoGame = {
|
|
282
|
+
game,
|
|
283
|
+
content: {
|
|
284
|
+
entityById: (catalogId) => entityCatalog[catalogId] ?? null,
|
|
285
|
+
itemById: (itemId) => (itemId in itemCatalog ? itemCatalog[itemId] : null),
|
|
286
|
+
},
|
|
287
|
+
loop: { onInit, onNewPlayer, onTick },
|
|
288
|
+
GameUI: SurvivalGameUI,
|
|
289
|
+
environment: SurvivalWorld,
|
|
290
|
+
camera: { minDistance: 8, maxDistance: 40, initialDistance: 26, targetHeight: 1.4 },
|
|
291
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
|
|
2
|
+
export interface MapMarkerBeaconsProps {
|
|
3
|
+
markers: MarkerSet;
|
|
4
|
+
kindStyles?: Record<string, MarkerKindStyle>;
|
|
5
|
+
height?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* World-space beacons for map markers (the visible in-world side of a ping):
|
|
9
|
+
* a floating diamond over a soft light beam, colored by marker kind. Wire it
|
|
10
|
+
* through `PlayableGame.WorldOverlay`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function MapMarkerBeacons({ markers, kindStyles, height }: MapMarkerBeaconsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { markerKindStyle } from "@jgengine/core/world/markers";
|
|
3
|
+
import { useMarkers } from "@jgengine/react/map";
|
|
4
|
+
/**
|
|
5
|
+
* World-space beacons for map markers (the visible in-world side of a ping):
|
|
6
|
+
* a floating diamond over a soft light beam, colored by marker kind. Wire it
|
|
7
|
+
* through `PlayableGame.WorldOverlay`.
|
|
8
|
+
*/
|
|
9
|
+
export function MapMarkerBeacons({ markers, kindStyles, height = 5 }) {
|
|
10
|
+
const list = useMarkers(markers);
|
|
11
|
+
return (_jsx(_Fragment, { children: list.map((marker) => {
|
|
12
|
+
const style = markerKindStyle(marker.kind, kindStyles);
|
|
13
|
+
const baseY = marker.position[1];
|
|
14
|
+
return (_jsxs("group", { position: [marker.position[0], baseY, marker.position[2]], children: [_jsxs("mesh", { "position-y": height / 2, children: [_jsx("cylinderGeometry", { args: [0.09, 0.09, height, 6] }), _jsx("meshBasicMaterial", { color: style.color, transparent: true, opacity: 0.4 })] }), _jsxs("mesh", { "position-y": height + 0.5, rotation: [0, Math.PI / 4, 0], children: [_jsx("octahedronGeometry", { args: [0.55, 0] }), _jsx("meshBasicMaterial", { color: style.color })] }), _jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.06, children: [_jsx("ringGeometry", { args: [0.7, 0.95, 24] }), _jsx("meshBasicMaterial", { color: style.color, transparent: true, opacity: 0.75 })] })] }, marker.id));
|
|
15
|
+
}) }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TerrainField } from "@jgengine/core/world/terrain";
|
|
2
|
+
export interface MapBakeBounds {
|
|
3
|
+
minX: number;
|
|
4
|
+
minZ: number;
|
|
5
|
+
maxX: number;
|
|
6
|
+
maxZ: number;
|
|
7
|
+
}
|
|
8
|
+
export interface BakeTerrainMapOptions {
|
|
9
|
+
resolution?: number;
|
|
10
|
+
landLow?: readonly [number, number, number];
|
|
11
|
+
landHigh?: readonly [number, number, number];
|
|
12
|
+
water?: readonly [number, number, number];
|
|
13
|
+
}
|
|
14
|
+
export interface BakedMap {
|
|
15
|
+
url: string;
|
|
16
|
+
bounds: MapBakeBounds;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Bake a top-down image of a `TerrainField` (or `RegionField`) over `bounds`
|
|
20
|
+
* for the react `Minimap` / `WorldMap` background. Runs in the browser via a
|
|
21
|
+
* 2D canvas; renderer-side, so it lives in the shell.
|
|
22
|
+
*/
|
|
23
|
+
export declare function bakeTerrainMap(field: TerrainField, bounds: MapBakeBounds, options?: BakeTerrainMapOptions): BakedMap | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isRegionField } from "@jgengine/core/world/regions";
|
|
2
|
+
function mix(a, b, t) {
|
|
3
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
4
|
+
}
|
|
5
|
+
const DEFAULT_LAND_LOW = [46, 74, 45];
|
|
6
|
+
const DEFAULT_LAND_HIGH = [180, 172, 128];
|
|
7
|
+
const DEFAULT_WATER = [28, 74, 108];
|
|
8
|
+
/**
|
|
9
|
+
* Bake a top-down image of a `TerrainField` (or `RegionField`) over `bounds`
|
|
10
|
+
* for the react `Minimap` / `WorldMap` background. Runs in the browser via a
|
|
11
|
+
* 2D canvas; renderer-side, so it lives in the shell.
|
|
12
|
+
*/
|
|
13
|
+
export function bakeTerrainMap(field, bounds, options = {}) {
|
|
14
|
+
const res = options.resolution ?? 160;
|
|
15
|
+
if (typeof document === "undefined")
|
|
16
|
+
return null;
|
|
17
|
+
const canvas = document.createElement("canvas");
|
|
18
|
+
canvas.width = res;
|
|
19
|
+
canvas.height = res;
|
|
20
|
+
const context = canvas.getContext("2d");
|
|
21
|
+
if (context === null)
|
|
22
|
+
return null;
|
|
23
|
+
const region = isRegionField(field) ? field : null;
|
|
24
|
+
const waterLevel = field.waterLevel ?? Number.NEGATIVE_INFINITY;
|
|
25
|
+
const worldW = bounds.maxX - bounds.minX;
|
|
26
|
+
const worldD = bounds.maxZ - bounds.minZ;
|
|
27
|
+
const landLow = options.landLow ?? DEFAULT_LAND_LOW;
|
|
28
|
+
const landHigh = options.landHigh ?? DEFAULT_LAND_HIGH;
|
|
29
|
+
const water = options.water ?? DEFAULT_WATER;
|
|
30
|
+
const heights = new Float32Array(res * res);
|
|
31
|
+
let minHeight = Number.POSITIVE_INFINITY;
|
|
32
|
+
let maxHeight = Number.NEGATIVE_INFINITY;
|
|
33
|
+
for (let py = 0; py < res; py += 1) {
|
|
34
|
+
for (let px = 0; px < res; px += 1) {
|
|
35
|
+
const worldX = bounds.minX + ((px + 0.5) / res) * worldW;
|
|
36
|
+
const worldZ = bounds.minZ + ((py + 0.5) / res) * worldD;
|
|
37
|
+
const height = field.sampleHeight(worldX, worldZ);
|
|
38
|
+
heights[py * res + px] = height;
|
|
39
|
+
if (height < minHeight)
|
|
40
|
+
minHeight = height;
|
|
41
|
+
if (height > maxHeight)
|
|
42
|
+
maxHeight = height;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const span = maxHeight - minHeight || 1;
|
|
46
|
+
const image = context.createImageData(res, res);
|
|
47
|
+
for (let py = 0; py < res; py += 1) {
|
|
48
|
+
for (let px = 0; px < res; px += 1) {
|
|
49
|
+
const index = py * res + px;
|
|
50
|
+
const height = heights[index];
|
|
51
|
+
const normalized = (height - minHeight) / span;
|
|
52
|
+
let color;
|
|
53
|
+
if (height < waterLevel) {
|
|
54
|
+
color = [...water];
|
|
55
|
+
}
|
|
56
|
+
else if (region !== null) {
|
|
57
|
+
const worldX = bounds.minX + ((px + 0.5) / res) * worldW;
|
|
58
|
+
const worldZ = bounds.minZ + ((py + 0.5) / res) * worldD;
|
|
59
|
+
const sample = region.sampleRegion(worldX, worldZ);
|
|
60
|
+
const shade = 0.55 + normalized * 0.5;
|
|
61
|
+
color = [
|
|
62
|
+
Math.min(255, sample.tint[0] * 255 * shade),
|
|
63
|
+
Math.min(255, sample.tint[1] * 255 * shade),
|
|
64
|
+
Math.min(255, sample.tint[2] * 255 * shade),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
color = mix(landLow, landHigh, normalized);
|
|
69
|
+
}
|
|
70
|
+
const offset = index * 4;
|
|
71
|
+
image.data[offset] = color[0];
|
|
72
|
+
image.data[offset + 1] = color[1];
|
|
73
|
+
image.data[offset + 2] = color[2];
|
|
74
|
+
image.data[offset + 3] = 255;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
context.putImageData(image, 0, 0);
|
|
78
|
+
return { url: canvas.toDataURL("image/png"), bounds };
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ContextMenu, ContextVerb } from "@jgengine/core/interaction/contextMenu";
|
|
2
|
+
import type { ScreenRect } from "@jgengine/core/scene/selection";
|
|
3
|
+
export declare function MarqueeBox({ rect }: {
|
|
4
|
+
rect: ScreenRect;
|
|
5
|
+
}): import("react").JSX.Element;
|
|
6
|
+
export declare function ContextMenuView({ menu, x, y, onPick, onClose, }: {
|
|
7
|
+
menu: ContextMenu;
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
onPick: (verb: ContextVerb) => void;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export function MarqueeBox({ rect }) {
|
|
3
|
+
return (_jsx("div", { className: "pointer-events-none absolute z-40 rounded-sm border border-emerald-300/80 bg-emerald-300/15", style: {
|
|
4
|
+
left: rect.minX,
|
|
5
|
+
top: rect.minY,
|
|
6
|
+
width: rect.maxX - rect.minX,
|
|
7
|
+
height: rect.maxY - rect.minY,
|
|
8
|
+
} }));
|
|
9
|
+
}
|
|
10
|
+
export function ContextMenuView({ menu, x, y, onPick, onClose, }) {
|
|
11
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { className: "pointer-events-auto absolute z-50 min-w-40 overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 py-1 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: menu.kind === "entity" ? "Unit" : "Object" }), menu.verbs.map((verb, index) => (_jsx("button", { type: "button", disabled: verb.disabled === true, onPointerDown: (event) => {
|
|
12
|
+
event.stopPropagation();
|
|
13
|
+
if (verb.disabled === true)
|
|
14
|
+
return;
|
|
15
|
+
onPick(verb);
|
|
16
|
+
}, className: "flex w-full items-center px-3 py-1.5 text-left text-[13px] text-white/85 hover:bg-emerald-400/20 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent", children: verb.label }, `${verb.command}-${index}`)))] })] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useThree } from "@react-three/fiber";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
export function PointerProbe({ service }) {
|
|
4
|
+
const camera = useThree((state) => state.camera);
|
|
5
|
+
const scene = useThree((state) => state.scene);
|
|
6
|
+
const gl = useThree((state) => state.gl);
|
|
7
|
+
const size = useThree((state) => state.size);
|
|
8
|
+
service.bind({ camera, scene, width: size.width, height: size.height });
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const el = gl.domElement;
|
|
11
|
+
const onMove = (event) => {
|
|
12
|
+
const rect = el.getBoundingClientRect();
|
|
13
|
+
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
14
|
+
const y = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
|
|
15
|
+
service.setCursor(x, y, true);
|
|
16
|
+
};
|
|
17
|
+
const onLeave = () => service.setCursor(0, 0, false);
|
|
18
|
+
el.addEventListener("pointermove", onMove);
|
|
19
|
+
el.addEventListener("pointerleave", onLeave);
|
|
20
|
+
return () => {
|
|
21
|
+
el.removeEventListener("pointermove", onMove);
|
|
22
|
+
el.removeEventListener("pointerleave", onLeave);
|
|
23
|
+
service.bind(null);
|
|
24
|
+
};
|
|
25
|
+
}, [gl, service]);
|
|
26
|
+
return null;
|
|
27
|
+
}
|