@jgengine/core 0.3.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.
@@ -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
- return { status: "settled", shotId, at: ballisticSettlePoint(input), hits: [] };
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
- return { status: "settled", shotId, at: settledAt(input, receivable), hits };
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
  }
@@ -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> {
@@ -15,7 +15,23 @@ export interface CameraFollowState {
15
15
  };
16
16
  distance: number;
17
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
+ }
18
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;
19
35
  minDistance?: number;
20
36
  maxDistance?: number;
21
37
  targetHeight?: number;
@@ -41,6 +57,9 @@ export interface GameCameraConfig {
41
57
  targetSmoothing?: number;
42
58
  dragTargetSmoothing?: number;
43
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;
44
63
  }
45
64
  export interface EntitySpriteConfig {
46
65
  url: string;
@@ -48,6 +67,11 @@ export interface EntitySpriteConfig {
48
67
  height: number;
49
68
  y: number;
50
69
  }
70
+ export interface ModelConfig {
71
+ url: string;
72
+ scale?: number;
73
+ y?: number;
74
+ }
51
75
  export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
52
76
  game: GameDefinition;
53
77
  content: GameContextContent;
@@ -57,10 +81,18 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
57
81
  WorldOverlay?: TWorldOverlay;
58
82
  /** Billboard sprites keyed by entity kind name; unmatched entities get primitive markers. */
59
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>;
60
88
  /** Optional scroll-selected hotbar index for primary ability (mouse0). */
61
89
  hotbarSelection?: () => number;
62
90
  /** Positioned proximity prompts for the interact key + HUD; single source shared with useActivePrompt. */
63
91
  prompts?: (ctx: GameContext) => readonly PositionedPrompt[];
64
- /** Third-person orbit camera tuning for the dev game player shell. */
92
+ /** Camera tuning (perspective, orbit, first-person) for the dev game player shell. */
65
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
+ };
66
98
  }
@@ -63,6 +63,13 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
63
63
  export interface SceneObjectContext extends ObjectStore {
64
64
  catalog(instanceId: string): GameContextObjectEntry | null;
65
65
  }
66
+ export interface FloatTextInput {
67
+ instanceId?: string;
68
+ position?: [number, number, number];
69
+ text?: string;
70
+ kind?: string;
71
+ amount?: number;
72
+ }
66
73
  export interface SceneEntityContext {
67
74
  spawn(name: string, options?: SpawnOptions): string;
68
75
  despawn(instanceId: string): boolean;
@@ -70,6 +77,7 @@ export interface SceneEntityContext {
70
77
  get(instanceId: string): SceneEntity | null;
71
78
  list(): readonly SceneEntity[];
72
79
  stats: EntityStatsApi;
80
+ floatText(input: FloatTextInput): void;
73
81
  setTarget(fromId: string, toId: string | null): void;
74
82
  getTarget(fromId: string): string | null;
75
83
  cycleTarget(fromId: string, options?: CycleTargetOptions): string | null;
@@ -280,11 +280,67 @@ export function createGameContext(options) {
280
280
  }
281
281
  },
282
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
+ };
283
330
  const projectiles = notifyAfter(createProjectileSystem({
284
- effects,
331
+ effects: floatingEffects,
285
332
  spatial: combatSpatial,
286
333
  getStat: weapon.getStat,
287
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
+ },
288
344
  }), ["fireProjectile", "settleProjectile"], signal.notify);
289
345
  const sceneObjects = {
290
346
  ...objects,
@@ -300,12 +356,13 @@ export function createGameContext(options) {
300
356
  get: entities.get,
301
357
  list: entities.list,
302
358
  stats: entityStats,
359
+ floatText: emitFloatText,
303
360
  setTarget: targeting.setTarget,
304
361
  getTarget: targeting.getTarget,
305
362
  cycleTarget: targeting.cycleTarget,
306
363
  canReceive: effects.canReceive,
307
364
  preview: effects.preview,
308
- effect: effects.applyEffect,
365
+ effect: applyEffectAndFloat,
309
366
  willHitProjectile: projectiles.willHitProjectile,
310
367
  fireProjectile: projectiles.fireProjectile,
311
368
  settleProjectile: projectiles.settleProjectile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.3.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": "tsc -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
26
- "check-types": "tsc --noEmit -p tsconfig.json",
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": {