@jgengine/core 0.2.0 → 0.4.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/dist/combat/projectiles.d.ts +8 -0
- package/dist/combat/projectiles.js +7 -2
- package/dist/game/events.d.ts +16 -0
- package/dist/game/playableGame.d.ts +36 -1
- package/dist/input/actionBindings.d.ts +10 -0
- package/dist/input/actionBindings.js +23 -0
- package/dist/runtime/gameContext.d.ts +24 -3
- package/dist/runtime/gameContext.js +68 -3
- package/package.json +3 -3
|
@@ -13,12 +13,20 @@ export interface RaycastHit {
|
|
|
13
13
|
at: EntityPosition;
|
|
14
14
|
}
|
|
15
15
|
export type Raycast = (from: string, aim: Aim, range: number) => RaycastHit[];
|
|
16
|
+
export interface ProjectileSettleReport {
|
|
17
|
+
from: string;
|
|
18
|
+
origin: EntityPosition;
|
|
19
|
+
at: EntityPosition;
|
|
20
|
+
effect: string;
|
|
21
|
+
hit: boolean;
|
|
22
|
+
}
|
|
16
23
|
export interface ProjectileSystemDeps {
|
|
17
24
|
effects: EffectSystem;
|
|
18
25
|
spatial: CombatSpatialDeps;
|
|
19
26
|
getStat(itemId: string, stat: string): number | null;
|
|
20
27
|
raycast?: Raycast;
|
|
21
28
|
now?: () => number;
|
|
29
|
+
onSettle?(report: ProjectileSettleReport): void;
|
|
22
30
|
}
|
|
23
31
|
export interface ProjectilePrediction {
|
|
24
32
|
hits: {
|
|
@@ -132,8 +132,11 @@ export function createProjectileSystem(deps) {
|
|
|
132
132
|
return { status: "rejected", shotId, reason: "already-settled" };
|
|
133
133
|
shot.settled = true;
|
|
134
134
|
const { input } = shot;
|
|
135
|
+
const origin = shotOrigin(input.from, input.aim) ?? [0, 0, 0];
|
|
135
136
|
if (isBallistic(input.via)) {
|
|
136
|
-
|
|
137
|
+
const at = ballisticSettlePoint(input);
|
|
138
|
+
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: false });
|
|
139
|
+
return { status: "settled", shotId, at, hits: [] };
|
|
137
140
|
}
|
|
138
141
|
const { visible } = predictHits(input);
|
|
139
142
|
const receivable = visible.filter((hit) => deps.effects.canReceive(hit.instanceId, input.effect) === null);
|
|
@@ -150,7 +153,9 @@ export function createProjectileSystem(deps) {
|
|
|
150
153
|
}));
|
|
151
154
|
}
|
|
152
155
|
}
|
|
153
|
-
|
|
156
|
+
const at = settledAt(input, receivable);
|
|
157
|
+
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: receivable.length > 0 });
|
|
158
|
+
return { status: "settled", shotId, at, hits };
|
|
154
159
|
},
|
|
155
160
|
};
|
|
156
161
|
}
|
package/dist/game/events.d.ts
CHANGED
|
@@ -66,8 +66,23 @@ export interface StatLevelUpEvent {
|
|
|
66
66
|
stat: string;
|
|
67
67
|
level: number;
|
|
68
68
|
}
|
|
69
|
+
export interface EntityFloatTextEvent {
|
|
70
|
+
instanceId?: string;
|
|
71
|
+
position: [number, number, number];
|
|
72
|
+
text: string;
|
|
73
|
+
kind: string;
|
|
74
|
+
amount?: number;
|
|
75
|
+
}
|
|
76
|
+
export interface ProjectileSettledEvent {
|
|
77
|
+
from: string;
|
|
78
|
+
origin: [number, number, number];
|
|
79
|
+
at: [number, number, number];
|
|
80
|
+
effect: string;
|
|
81
|
+
hit: boolean;
|
|
82
|
+
}
|
|
69
83
|
export interface GameEventMap {
|
|
70
84
|
"entity.died": EntityDiedEvent;
|
|
85
|
+
"entity.floatText": EntityFloatTextEvent;
|
|
71
86
|
"loot.granted": LootGrantedEvent;
|
|
72
87
|
"inventory.added": InventoryAddedEvent;
|
|
73
88
|
"quest.accepted": QuestAcceptedEvent;
|
|
@@ -77,6 +92,7 @@ export interface GameEventMap {
|
|
|
77
92
|
"social.party.joined": SocialPartyJoinedEvent;
|
|
78
93
|
"social.party.left": SocialPartyLeftEvent;
|
|
79
94
|
"stat.levelUp": StatLevelUpEvent;
|
|
95
|
+
"projectile.settled": ProjectileSettledEvent;
|
|
80
96
|
}
|
|
81
97
|
export type GameEventHandler<TPayload> = (payload: TPayload) => void;
|
|
82
98
|
export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PositionedPrompt } from "../interaction/proximityPrompt.js";
|
|
1
2
|
import type { GameContext, GameContextContent } from "../runtime/gameContext.js";
|
|
2
3
|
import type { GameDefinition, GameLoop } from "./defineGame.js";
|
|
3
4
|
export interface CameraFollowState {
|
|
@@ -14,7 +15,23 @@ export interface CameraFollowState {
|
|
|
14
15
|
};
|
|
15
16
|
distance: number;
|
|
16
17
|
}
|
|
18
|
+
export interface FirstPersonCameraConfig {
|
|
19
|
+
/** Camera height above the followed entity's origin. Default 1.6. */
|
|
20
|
+
eyeHeight?: number;
|
|
21
|
+
/** Mouse-look radians per pixel of pointer movement. Default 0.0025. */
|
|
22
|
+
sensitivity?: number;
|
|
23
|
+
/** Clamp on look pitch in radians (± from horizon). Default 1.45. */
|
|
24
|
+
maxPitch?: number;
|
|
25
|
+
/** Show the centered crosshair overlay. Default true. */
|
|
26
|
+
reticle?: boolean;
|
|
27
|
+
/** Render a simple first-person weapon in view. Default true. */
|
|
28
|
+
viewmodel?: boolean;
|
|
29
|
+
}
|
|
17
30
|
export interface GameCameraConfig {
|
|
31
|
+
/** "third" mounts the orbit camera (default); "first" mounts pointer-lock mouse-look. */
|
|
32
|
+
perspective?: "third" | "first";
|
|
33
|
+
/** First-person tuning; only read when perspective is "first". */
|
|
34
|
+
firstPerson?: FirstPersonCameraConfig;
|
|
18
35
|
minDistance?: number;
|
|
19
36
|
maxDistance?: number;
|
|
20
37
|
targetHeight?: number;
|
|
@@ -40,6 +57,9 @@ export interface GameCameraConfig {
|
|
|
40
57
|
targetSmoothing?: number;
|
|
41
58
|
dragTargetSmoothing?: number;
|
|
42
59
|
distanceSmoothing?: number;
|
|
60
|
+
/** Vertical look clamp (three.js polar angle, radians): 0 = top-down over the head, PI/2 = level, >PI/2 = look up from below. Widen for top-down or vertical aim; unset keeps the standard chase feel. */
|
|
61
|
+
minPolarAngle?: number;
|
|
62
|
+
maxPolarAngle?: number;
|
|
43
63
|
}
|
|
44
64
|
export interface EntitySpriteConfig {
|
|
45
65
|
url: string;
|
|
@@ -47,6 +67,11 @@ export interface EntitySpriteConfig {
|
|
|
47
67
|
height: number;
|
|
48
68
|
y: number;
|
|
49
69
|
}
|
|
70
|
+
export interface ModelConfig {
|
|
71
|
+
url: string;
|
|
72
|
+
scale?: number;
|
|
73
|
+
y?: number;
|
|
74
|
+
}
|
|
50
75
|
export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
|
|
51
76
|
game: GameDefinition;
|
|
52
77
|
content: GameContextContent;
|
|
@@ -56,8 +81,18 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
|
|
|
56
81
|
WorldOverlay?: TWorldOverlay;
|
|
57
82
|
/** Billboard sprites keyed by entity kind name; unmatched entities get primitive markers. */
|
|
58
83
|
entitySprites?: Record<string, EntitySpriteConfig>;
|
|
84
|
+
/** GLB models keyed by entity kind name; a string resolves as an asset-catalog key via game.assets, a ModelConfig renders directly. Takes priority over sprites, then primitives. */
|
|
85
|
+
entityModels?: Record<string, string | ModelConfig>;
|
|
86
|
+
/** GLB models keyed by object catalog id; a string resolves via game.assets, a ModelConfig renders directly. Replaces the colored box when present. */
|
|
87
|
+
objectModels?: Record<string, string | ModelConfig>;
|
|
59
88
|
/** Optional scroll-selected hotbar index for primary ability (mouse0). */
|
|
60
89
|
hotbarSelection?: () => number;
|
|
61
|
-
/**
|
|
90
|
+
/** Positioned proximity prompts for the interact key + HUD; single source shared with useActivePrompt. */
|
|
91
|
+
prompts?: (ctx: GameContext) => readonly PositionedPrompt[];
|
|
92
|
+
/** Camera tuning (perspective, orbit, first-person) for the dev game player shell. */
|
|
62
93
|
camera?: GameCameraConfig;
|
|
94
|
+
/** Opt in to world-space health bars floating over non-local entities that carry the stat. */
|
|
95
|
+
worldHealthBars?: boolean | {
|
|
96
|
+
statId?: string;
|
|
97
|
+
};
|
|
63
98
|
}
|
|
@@ -33,6 +33,16 @@ export declare function toActionStateBindingMap<TAction extends string, TCode ex
|
|
|
33
33
|
export declare function hotbarSlotBindings(count: number, options?: {
|
|
34
34
|
action?: (slot: number) => string;
|
|
35
35
|
}): ActionCodesMap;
|
|
36
|
+
/** 0-based slot index for a hotbar-slot action name (`slot1`, `hotbarSlot1`), or null. */
|
|
37
|
+
export declare function hotbarSlotActionIndex(action: string): number | null;
|
|
38
|
+
export declare function isHotbarSlotAction(action: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Convention mapping a bound input action to the command it fires: the action's
|
|
41
|
+
* own name when a command by that name exists, otherwise a `ui.<action>`
|
|
42
|
+
* fallback. Reserved actions (consumed natively by the shell) and hotbar slots
|
|
43
|
+
* yield null so the shell can handle them itself.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveActionCommand(action: string, has: (command: string) => boolean, reserved: ReadonlySet<string>): string | null;
|
|
36
46
|
export declare function bindingLabel(code: string): string;
|
|
37
47
|
/** Short display label for an action's first bound code, or null when unbound. */
|
|
38
48
|
export declare function actionLabel(map: ActionCodesMap, action: string): string | null;
|
|
@@ -67,6 +67,29 @@ export function hotbarSlotBindings(count, options) {
|
|
|
67
67
|
}
|
|
68
68
|
return map;
|
|
69
69
|
}
|
|
70
|
+
const HOTBAR_SLOT_ACTION = /^(?:hotbar)?slot(\d+)$/i;
|
|
71
|
+
/** 0-based slot index for a hotbar-slot action name (`slot1`, `hotbarSlot1`), or null. */
|
|
72
|
+
export function hotbarSlotActionIndex(action) {
|
|
73
|
+
const match = HOTBAR_SLOT_ACTION.exec(action);
|
|
74
|
+
return match === null ? null : Number(match[1]) - 1;
|
|
75
|
+
}
|
|
76
|
+
export function isHotbarSlotAction(action) {
|
|
77
|
+
return HOTBAR_SLOT_ACTION.test(action);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Convention mapping a bound input action to the command it fires: the action's
|
|
81
|
+
* own name when a command by that name exists, otherwise a `ui.<action>`
|
|
82
|
+
* fallback. Reserved actions (consumed natively by the shell) and hotbar slots
|
|
83
|
+
* yield null so the shell can handle them itself.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveActionCommand(action, has, reserved) {
|
|
86
|
+
if (reserved.has(action) || isHotbarSlotAction(action))
|
|
87
|
+
return null;
|
|
88
|
+
if (has(action))
|
|
89
|
+
return action;
|
|
90
|
+
const uiCommand = `ui.${action}`;
|
|
91
|
+
return has(uiCommand) ? uiCommand : null;
|
|
92
|
+
}
|
|
70
93
|
const CODE_LABELS = {
|
|
71
94
|
mouse0: "LMB",
|
|
72
95
|
mouse1: "MMB",
|
|
@@ -12,7 +12,8 @@ import { type QuestJournal } from "../game/quest.js";
|
|
|
12
12
|
import { type Social } from "../game/social.js";
|
|
13
13
|
import { type TradeField, type TradeSystem } from "../game/trade.js";
|
|
14
14
|
import { type Unlocks } from "../game/unlocks.js";
|
|
15
|
-
import { type InventorySet } from "../inventory/inventoryModel.js";
|
|
15
|
+
import { type InventoryLayout, type InventorySet } from "../inventory/inventoryModel.js";
|
|
16
|
+
import type { ProximityPrompt } from "../interaction/proximityPrompt.js";
|
|
16
17
|
import { type ItemUseHandler, type ItemUseInput, type ItemUseRejection, type ItemUseResult } from "../item/use.js";
|
|
17
18
|
import { type WeaponStats } from "../item/weapon.js";
|
|
18
19
|
import { type PoseAllowedStates, type PoseState } from "../movement/poseState.js";
|
|
@@ -28,6 +29,7 @@ export interface GameContextItemEntry {
|
|
|
28
29
|
weapon?: Record<string, unknown>;
|
|
29
30
|
trade?: TradeField;
|
|
30
31
|
}
|
|
32
|
+
export type CatalogEntityRole = "player" | "enemy" | "hostile" | "npc" | "vehicle";
|
|
31
33
|
export interface GameContextEntityEntry {
|
|
32
34
|
stats?: StatCatalog;
|
|
33
35
|
receive?: ReceiveMap;
|
|
@@ -35,11 +37,19 @@ export interface GameContextEntityEntry {
|
|
|
35
37
|
movement?: PoseAllowedStates & {
|
|
36
38
|
walkSpeed?: number;
|
|
37
39
|
};
|
|
38
|
-
role?:
|
|
40
|
+
role?: CatalogEntityRole;
|
|
41
|
+
}
|
|
42
|
+
export interface GameContextObjectEntry {
|
|
43
|
+
proximityPrompt?: ProximityPrompt;
|
|
44
|
+
breakable?: false | {
|
|
45
|
+
baseBreakTime: number;
|
|
46
|
+
};
|
|
47
|
+
slotInventory?: InventoryLayout;
|
|
39
48
|
}
|
|
40
49
|
export interface GameContextContent {
|
|
41
50
|
itemById?(itemId: string): GameContextItemEntry | null | undefined;
|
|
42
51
|
entityById?(catalogId: string): GameContextEntityEntry | null | undefined;
|
|
52
|
+
objectById?(catalogId: string): GameContextObjectEntry | null | undefined;
|
|
43
53
|
}
|
|
44
54
|
export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> {
|
|
45
55
|
definition: GameDefinition<TAssetRef, TMultiplayer>;
|
|
@@ -50,6 +60,16 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
|
|
|
50
60
|
};
|
|
51
61
|
now?: () => number;
|
|
52
62
|
}
|
|
63
|
+
export interface SceneObjectContext extends ObjectStore {
|
|
64
|
+
catalog(instanceId: string): GameContextObjectEntry | null;
|
|
65
|
+
}
|
|
66
|
+
export interface FloatTextInput {
|
|
67
|
+
instanceId?: string;
|
|
68
|
+
position?: [number, number, number];
|
|
69
|
+
text?: string;
|
|
70
|
+
kind?: string;
|
|
71
|
+
amount?: number;
|
|
72
|
+
}
|
|
53
73
|
export interface SceneEntityContext {
|
|
54
74
|
spawn(name: string, options?: SpawnOptions): string;
|
|
55
75
|
despawn(instanceId: string): boolean;
|
|
@@ -57,6 +77,7 @@ export interface SceneEntityContext {
|
|
|
57
77
|
get(instanceId: string): SceneEntity | null;
|
|
58
78
|
list(): readonly SceneEntity[];
|
|
59
79
|
stats: EntityStatsApi;
|
|
80
|
+
floatText(input: FloatTextInput): void;
|
|
60
81
|
setTarget(fromId: string, toId: string | null): void;
|
|
61
82
|
getTarget(fromId: string): string | null;
|
|
62
83
|
cycleTarget(fromId: string, options?: CycleTargetOptions): string | null;
|
|
@@ -102,7 +123,7 @@ export interface GameContextItemUse {
|
|
|
102
123
|
}
|
|
103
124
|
export interface GameContext {
|
|
104
125
|
scene: {
|
|
105
|
-
object:
|
|
126
|
+
object: SceneObjectContext;
|
|
106
127
|
entity: SceneEntityContext;
|
|
107
128
|
};
|
|
108
129
|
game: {
|
|
@@ -44,6 +44,10 @@ export function createGameContext(options) {
|
|
|
44
44
|
const entity = entities.get(instanceId);
|
|
45
45
|
return entity === null ? undefined : content.entityById?.(entity.name);
|
|
46
46
|
}
|
|
47
|
+
function catalogObject(instanceId) {
|
|
48
|
+
const object = objects.get(instanceId);
|
|
49
|
+
return object === null ? undefined : content.objectById?.(object.catalogId);
|
|
50
|
+
}
|
|
47
51
|
const spatial = createSpatialApi({
|
|
48
52
|
resolvePosition: (instanceId) => entities.get(instanceId)?.position,
|
|
49
53
|
candidates: () => entities.list().map((entity) => entity.id),
|
|
@@ -276,15 +280,75 @@ export function createGameContext(options) {
|
|
|
276
280
|
}
|
|
277
281
|
},
|
|
278
282
|
}), ["applyEffect"], signal.notify);
|
|
283
|
+
function emitFloatText(input) {
|
|
284
|
+
const position = input.position ??
|
|
285
|
+
(input.instanceId === undefined ? undefined : entities.get(input.instanceId)?.position);
|
|
286
|
+
if (position === undefined)
|
|
287
|
+
return;
|
|
288
|
+
const text = input.text ?? (input.amount === undefined ? "" : String(Math.round(input.amount)));
|
|
289
|
+
const event = {
|
|
290
|
+
position: [position[0], position[1], position[2]],
|
|
291
|
+
text,
|
|
292
|
+
kind: input.kind ?? "info",
|
|
293
|
+
};
|
|
294
|
+
if (input.instanceId !== undefined)
|
|
295
|
+
event.instanceId = input.instanceId;
|
|
296
|
+
if (input.amount !== undefined)
|
|
297
|
+
event.amount = input.amount;
|
|
298
|
+
events.emit("entity.floatText", event);
|
|
299
|
+
}
|
|
300
|
+
function applyEffectAndFloat(input) {
|
|
301
|
+
const positionsBefore = new Map();
|
|
302
|
+
for (const entity of entities.list())
|
|
303
|
+
positionsBefore.set(entity.id, entity.position);
|
|
304
|
+
const results = effects.applyEffect(input);
|
|
305
|
+
for (const result of results) {
|
|
306
|
+
let total = 0;
|
|
307
|
+
for (const delta of result.applied)
|
|
308
|
+
total += delta.delta;
|
|
309
|
+
if (total === 0)
|
|
310
|
+
continue;
|
|
311
|
+
const position = entities.get(result.instanceId)?.position ?? positionsBefore.get(result.instanceId);
|
|
312
|
+
if (position === undefined)
|
|
313
|
+
continue;
|
|
314
|
+
const magnitude = Math.abs(total);
|
|
315
|
+
emitFloatText({
|
|
316
|
+
instanceId: result.instanceId,
|
|
317
|
+
position: [position[0], position[1], position[2]],
|
|
318
|
+
text: String(Math.round(magnitude)),
|
|
319
|
+
kind: total < 0 ? "damage" : "heal",
|
|
320
|
+
amount: magnitude,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return results;
|
|
324
|
+
}
|
|
325
|
+
const floatingEffects = {
|
|
326
|
+
canReceive: effects.canReceive,
|
|
327
|
+
preview: effects.preview,
|
|
328
|
+
applyEffect: applyEffectAndFloat,
|
|
329
|
+
};
|
|
279
330
|
const projectiles = notifyAfter(createProjectileSystem({
|
|
280
|
-
effects,
|
|
331
|
+
effects: floatingEffects,
|
|
281
332
|
spatial: combatSpatial,
|
|
282
333
|
getStat: weapon.getStat,
|
|
283
334
|
now,
|
|
335
|
+
onSettle(report) {
|
|
336
|
+
events.emit("projectile.settled", {
|
|
337
|
+
from: report.from,
|
|
338
|
+
origin: [report.origin[0], report.origin[1], report.origin[2]],
|
|
339
|
+
at: [report.at[0], report.at[1], report.at[2]],
|
|
340
|
+
effect: report.effect,
|
|
341
|
+
hit: report.hit,
|
|
342
|
+
});
|
|
343
|
+
},
|
|
284
344
|
}), ["fireProjectile", "settleProjectile"], signal.notify);
|
|
345
|
+
const sceneObjects = {
|
|
346
|
+
...objects,
|
|
347
|
+
catalog: (instanceId) => catalogObject(instanceId) ?? null,
|
|
348
|
+
};
|
|
285
349
|
const ctx = {
|
|
286
350
|
scene: {
|
|
287
|
-
object:
|
|
351
|
+
object: sceneObjects,
|
|
288
352
|
entity: {
|
|
289
353
|
spawn: spawnEntity,
|
|
290
354
|
despawn: despawnEntity,
|
|
@@ -292,12 +356,13 @@ export function createGameContext(options) {
|
|
|
292
356
|
get: entities.get,
|
|
293
357
|
list: entities.list,
|
|
294
358
|
stats: entityStats,
|
|
359
|
+
floatText: emitFloatText,
|
|
295
360
|
setTarget: targeting.setTarget,
|
|
296
361
|
getTarget: targeting.getTarget,
|
|
297
362
|
cycleTarget: targeting.cycleTarget,
|
|
298
363
|
canReceive: effects.canReceive,
|
|
299
364
|
preview: effects.preview,
|
|
300
|
-
effect:
|
|
365
|
+
effect: applyEffectAndFloat,
|
|
301
366
|
willHitProjectile: projectiles.willHitProjectile,
|
|
302
367
|
fireProjectile: projectiles.fireProjectile,
|
|
303
368
|
settleProjectile: projectiles.settleProjectile,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Genre-agnostic TypeScript game engine SDK: game runtime, state store, entity scene, combat, movement, inventory, economy, quests, multiplayer primitives.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "
|
|
26
|
-
"check-types": "
|
|
25
|
+
"build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
|
|
26
|
+
"check-types": "tsgo --noEmit -p tsconfig.json",
|
|
27
27
|
"test": "bun test src"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|