@jgengine/core 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.
Files changed (119) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +11 -0
  3. package/dist/assets/spriteSheet.d.ts +46 -0
  4. package/dist/assets/spriteSheet.js +86 -0
  5. package/dist/combat/death.d.ts +61 -0
  6. package/dist/combat/death.js +61 -0
  7. package/dist/combat/effects.d.ts +61 -0
  8. package/dist/combat/effects.js +108 -0
  9. package/dist/combat/projectiles.d.ts +45 -0
  10. package/dist/combat/projectiles.js +156 -0
  11. package/dist/commands/commandRegistry.d.ts +23 -0
  12. package/dist/commands/commandRegistry.js +26 -0
  13. package/dist/economy/currency.d.ts +26 -0
  14. package/dist/economy/currency.js +28 -0
  15. package/dist/economy/wallet.d.ts +16 -0
  16. package/dist/economy/wallet.js +49 -0
  17. package/dist/game/defineGame.d.ts +41 -0
  18. package/dist/game/defineGame.js +7 -0
  19. package/dist/game/events.d.ts +87 -0
  20. package/dist/game/events.js +24 -0
  21. package/dist/game/feed.d.ts +19 -0
  22. package/dist/game/feed.js +47 -0
  23. package/dist/game/leaderboard.d.ts +46 -0
  24. package/dist/game/leaderboard.js +60 -0
  25. package/dist/game/loadout.d.ts +47 -0
  26. package/dist/game/loadout.js +36 -0
  27. package/dist/game/lootTable.d.ts +27 -0
  28. package/dist/game/lootTable.js +78 -0
  29. package/dist/game/playableGame.d.ts +55 -0
  30. package/dist/game/playableGame.js +1 -0
  31. package/dist/game/quest.d.ts +93 -0
  32. package/dist/game/quest.js +228 -0
  33. package/dist/game/social.d.ts +78 -0
  34. package/dist/game/social.js +227 -0
  35. package/dist/game/trade.d.ts +35 -0
  36. package/dist/game/trade.js +53 -0
  37. package/dist/game/unlocks.d.ts +13 -0
  38. package/dist/game/unlocks.js +34 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.js +6 -0
  41. package/dist/input/actionBindings.d.ts +41 -0
  42. package/dist/input/actionBindings.js +115 -0
  43. package/dist/input/lookChannel.d.ts +29 -0
  44. package/dist/input/lookChannel.js +44 -0
  45. package/dist/input/touchGestures.d.ts +31 -0
  46. package/dist/input/touchGestures.js +72 -0
  47. package/dist/interaction/proximityPrompt.d.ts +51 -0
  48. package/dist/interaction/proximityPrompt.js +76 -0
  49. package/dist/inventory/inventoryModel.d.ts +57 -0
  50. package/dist/inventory/inventoryModel.js +208 -0
  51. package/dist/item/use.d.ts +25 -0
  52. package/dist/item/use.js +40 -0
  53. package/dist/item/weapon.d.ts +8 -0
  54. package/dist/item/weapon.js +18 -0
  55. package/dist/movement/avatarGait.d.ts +14 -0
  56. package/dist/movement/avatarGait.js +20 -0
  57. package/dist/movement/cameraRig.d.ts +15 -0
  58. package/dist/movement/cameraRig.js +43 -0
  59. package/dist/movement/movementModel.d.ts +96 -0
  60. package/dist/movement/movementModel.js +171 -0
  61. package/dist/movement/poseState.d.ts +23 -0
  62. package/dist/movement/poseState.js +61 -0
  63. package/dist/multiplayer/poseSyncGate.d.ts +18 -0
  64. package/dist/multiplayer/poseSyncGate.js +30 -0
  65. package/dist/multiplayer/presenceContract.d.ts +60 -0
  66. package/dist/multiplayer/presenceContract.js +19 -0
  67. package/dist/multiplayer/presenceModel.d.ts +60 -0
  68. package/dist/multiplayer/presenceModel.js +51 -0
  69. package/dist/runtime/adapter.d.ts +24 -0
  70. package/dist/runtime/adapter.js +12 -0
  71. package/dist/runtime/commandRunner.d.ts +15 -0
  72. package/dist/runtime/commandRunner.js +25 -0
  73. package/dist/runtime/gameContext.d.ts +138 -0
  74. package/dist/runtime/gameContext.js +371 -0
  75. package/dist/runtime/gameRuntime.d.ts +42 -0
  76. package/dist/runtime/gameRuntime.js +95 -0
  77. package/dist/runtime/hostPersistence.d.ts +105 -0
  78. package/dist/runtime/hostPersistence.js +117 -0
  79. package/dist/runtime/save.d.ts +9 -0
  80. package/dist/runtime/save.js +29 -0
  81. package/dist/runtime/snapshot.d.ts +87 -0
  82. package/dist/runtime/snapshot.js +75 -0
  83. package/dist/runtime/transport.d.ts +60 -0
  84. package/dist/runtime/transport.js +1 -0
  85. package/dist/scene/assetCatalog.d.ts +10 -0
  86. package/dist/scene/assetCatalog.js +17 -0
  87. package/dist/scene/assetPreload.d.ts +7 -0
  88. package/dist/scene/assetPreload.js +22 -0
  89. package/dist/scene/behaviors.d.ts +19 -0
  90. package/dist/scene/behaviors.js +18 -0
  91. package/dist/scene/entityStats.d.ts +38 -0
  92. package/dist/scene/entityStats.js +65 -0
  93. package/dist/scene/entityStore.d.ts +52 -0
  94. package/dist/scene/entityStore.js +84 -0
  95. package/dist/scene/objectStore.d.ts +28 -0
  96. package/dist/scene/objectStore.js +69 -0
  97. package/dist/scene/spatial.d.ts +34 -0
  98. package/dist/scene/spatial.js +96 -0
  99. package/dist/scene/targeting.d.ts +20 -0
  100. package/dist/scene/targeting.js +56 -0
  101. package/dist/stats/statModifiers.d.ts +18 -0
  102. package/dist/stats/statModifiers.js +56 -0
  103. package/dist/store/changeSignal.d.ts +7 -0
  104. package/dist/store/changeSignal.js +32 -0
  105. package/dist/store/engineStore.d.ts +22 -0
  106. package/dist/store/engineStore.js +50 -0
  107. package/dist/store/features.d.ts +10 -0
  108. package/dist/store/features.js +23 -0
  109. package/dist/store/observableKeyedStore.d.ts +10 -0
  110. package/dist/store/observableKeyedStore.js +42 -0
  111. package/dist/time/calendarClock.d.ts +17 -0
  112. package/dist/time/calendarClock.js +42 -0
  113. package/dist/time/gameClock.d.ts +9 -0
  114. package/dist/time/gameClock.js +20 -0
  115. package/dist/time/serverTick.d.ts +16 -0
  116. package/dist/time/serverTick.js +18 -0
  117. package/dist/world/features.d.ts +39 -0
  118. package/dist/world/features.js +15 -0
  119. package/package.json +32 -0
@@ -0,0 +1,46 @@
1
+ export interface SpriteSheetGridConfig {
2
+ columns: number;
3
+ rows: number;
4
+ marginX: number;
5
+ marginY: number;
6
+ gapX: number;
7
+ gapY: number;
8
+ cellWidth?: number;
9
+ cellHeight?: number;
10
+ }
11
+ export interface SpriteSheetSliceBox {
12
+ id: string;
13
+ name: string;
14
+ row: number;
15
+ column: number;
16
+ x: number;
17
+ y: number;
18
+ width: number;
19
+ height: number;
20
+ }
21
+ export interface SpriteSheetManifest {
22
+ version: 1;
23
+ source: {
24
+ name: string;
25
+ width: number;
26
+ height: number;
27
+ };
28
+ grid: SpriteSheetGridConfig;
29
+ slices: SpriteSheetSliceBox[];
30
+ }
31
+ export declare function sanitizeAssetName(value: string): string;
32
+ export declare function normalizeSpriteSheetGridConfig(imageWidth: number, imageHeight: number, config: SpriteSheetGridConfig): Required<SpriteSheetGridConfig>;
33
+ export declare function buildSpriteSheetGridBoxes({ imageWidth, imageHeight, config, namePrefix, names, }: {
34
+ imageWidth: number;
35
+ imageHeight: number;
36
+ config: SpriteSheetGridConfig;
37
+ namePrefix: string;
38
+ names?: readonly string[];
39
+ }): SpriteSheetSliceBox[];
40
+ export declare function buildSpriteSheetManifest({ sourceName, imageWidth, imageHeight, config, slices, }: {
41
+ sourceName: string;
42
+ imageWidth: number;
43
+ imageHeight: number;
44
+ config: SpriteSheetGridConfig;
45
+ slices: SpriteSheetSliceBox[];
46
+ }): SpriteSheetManifest;
@@ -0,0 +1,86 @@
1
+ export function sanitizeAssetName(value) {
2
+ const normalized = value
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "");
7
+ return normalized.length > 0 ? normalized : "asset";
8
+ }
9
+ function requirePositiveInteger(value, label) {
10
+ if (!Number.isInteger(value) || value <= 0) {
11
+ throw new Error(`${label} must be a positive integer.`);
12
+ }
13
+ }
14
+ function requireNonNegativeInteger(value, label) {
15
+ if (!Number.isInteger(value) || value < 0) {
16
+ throw new Error(`${label} must be a non-negative integer.`);
17
+ }
18
+ }
19
+ export function normalizeSpriteSheetGridConfig(imageWidth, imageHeight, config) {
20
+ requirePositiveInteger(imageWidth, "imageWidth");
21
+ requirePositiveInteger(imageHeight, "imageHeight");
22
+ requirePositiveInteger(config.columns, "columns");
23
+ requirePositiveInteger(config.rows, "rows");
24
+ requireNonNegativeInteger(config.marginX, "marginX");
25
+ requireNonNegativeInteger(config.marginY, "marginY");
26
+ requireNonNegativeInteger(config.gapX, "gapX");
27
+ requireNonNegativeInteger(config.gapY, "gapY");
28
+ const availableWidth = imageWidth - config.marginX * 2 - config.gapX * (config.columns - 1);
29
+ const availableHeight = imageHeight - config.marginY * 2 - config.gapY * (config.rows - 1);
30
+ const cellWidth = config.cellWidth ?? Math.floor(availableWidth / config.columns);
31
+ const cellHeight = config.cellHeight ?? Math.floor(availableHeight / config.rows);
32
+ requirePositiveInteger(cellWidth, "cellWidth");
33
+ requirePositiveInteger(cellHeight, "cellHeight");
34
+ const usedWidth = config.marginX * 2 + cellWidth * config.columns + config.gapX * (config.columns - 1);
35
+ const usedHeight = config.marginY * 2 + cellHeight * config.rows + config.gapY * (config.rows - 1);
36
+ if (usedWidth > imageWidth) {
37
+ throw new Error(`grid width ${usedWidth} exceeds image width ${imageWidth}.`);
38
+ }
39
+ if (usedHeight > imageHeight) {
40
+ throw new Error(`grid height ${usedHeight} exceeds image height ${imageHeight}.`);
41
+ }
42
+ return {
43
+ columns: config.columns,
44
+ rows: config.rows,
45
+ marginX: config.marginX,
46
+ marginY: config.marginY,
47
+ gapX: config.gapX,
48
+ gapY: config.gapY,
49
+ cellWidth,
50
+ cellHeight,
51
+ };
52
+ }
53
+ export function buildSpriteSheetGridBoxes({ imageWidth, imageHeight, config, namePrefix, names, }) {
54
+ const grid = normalizeSpriteSheetGridConfig(imageWidth, imageHeight, config);
55
+ const prefix = sanitizeAssetName(namePrefix);
56
+ const boxes = [];
57
+ for (let row = 0; row < grid.rows; row += 1) {
58
+ for (let column = 0; column < grid.columns; column += 1) {
59
+ const index = row * grid.columns + column;
60
+ const name = sanitizeAssetName(names?.[index] ?? `${prefix}-${String(index + 1).padStart(2, "0")}`);
61
+ boxes.push({
62
+ id: `${row}-${column}`,
63
+ name,
64
+ row,
65
+ column,
66
+ x: grid.marginX + column * (grid.cellWidth + grid.gapX),
67
+ y: grid.marginY + row * (grid.cellHeight + grid.gapY),
68
+ width: grid.cellWidth,
69
+ height: grid.cellHeight,
70
+ });
71
+ }
72
+ }
73
+ return boxes;
74
+ }
75
+ export function buildSpriteSheetManifest({ sourceName, imageWidth, imageHeight, config, slices, }) {
76
+ return {
77
+ version: 1,
78
+ source: {
79
+ name: sourceName,
80
+ width: imageWidth,
81
+ height: imageHeight,
82
+ },
83
+ grid: normalizeSpriteSheetGridConfig(imageWidth, imageHeight, config),
84
+ slices,
85
+ };
86
+ }
@@ -0,0 +1,61 @@
1
+ import type { DeathReason, GameEvents } from "../game/events.js";
2
+ import type { Drop } from "../game/lootTable.js";
3
+ import type { EffectVia } from "./effects.js";
4
+ export type DeathReasonKind = DeathReason["kind"];
5
+ export interface OnDeathDropRule {
6
+ table: string;
7
+ when?: {
8
+ reason: DeathReasonKind;
9
+ };
10
+ }
11
+ export interface OnDeathCommandRule {
12
+ name: string;
13
+ args?: unknown;
14
+ when?: {
15
+ reason: DeathReasonKind;
16
+ };
17
+ }
18
+ export interface OnDeathSpec {
19
+ drops?: string | OnDeathDropRule[];
20
+ command?: string | OnDeathCommandRule;
21
+ }
22
+ export interface NormalizedOnDeath {
23
+ drops: OnDeathDropRule[];
24
+ command: OnDeathCommandRule | null;
25
+ }
26
+ export declare function normalizeOnDeath(spec: OnDeathSpec | null | undefined): NormalizedOnDeath;
27
+ export interface DeathIdentity {
28
+ catalogId: string;
29
+ userId?: string;
30
+ displayName?: string;
31
+ position: [number, number, number];
32
+ }
33
+ export interface DeathSystemDeps {
34
+ resolveOnDeath(instanceId: string): OnDeathSpec | null | undefined;
35
+ resolveIdentity(instanceId: string): DeathIdentity | null;
36
+ loot: {
37
+ roll(tableId: string): Drop[];
38
+ };
39
+ events: GameEvents;
40
+ runCommand?(name: string, args: unknown): void;
41
+ despawn(instanceId: string): void;
42
+ }
43
+ export type DeathResolution = {
44
+ status: "resolved";
45
+ drops: Drop[];
46
+ ranCommand: string | null;
47
+ } | {
48
+ status: "rejected";
49
+ reason: string;
50
+ };
51
+ export interface DeathSystem {
52
+ resolveDeath(instanceId: string, reason: DeathReason): DeathResolution;
53
+ revive(instanceId: string): boolean;
54
+ }
55
+ export declare function createDeathSystem(deps: DeathSystemDeps): DeathSystem;
56
+ export interface EffectDeathContext {
57
+ from: string;
58
+ via?: EffectVia;
59
+ userIdOf?(instanceId: string): string | undefined;
60
+ }
61
+ export declare function deathReasonFromEffect(ctx: EffectDeathContext): DeathReason;
@@ -0,0 +1,61 @@
1
+ export function normalizeOnDeath(spec) {
2
+ if (!spec)
3
+ return { drops: [], command: null };
4
+ const drops = spec.drops === undefined ? [] : typeof spec.drops === "string" ? [{ table: spec.drops }] : spec.drops;
5
+ const command = spec.command === undefined ? null : typeof spec.command === "string" ? { name: spec.command } : spec.command;
6
+ return { drops, command };
7
+ }
8
+ function matchesReason(when, reason) {
9
+ return when === undefined || when.reason === reason.kind;
10
+ }
11
+ export function createDeathSystem(deps) {
12
+ const dead = new Set();
13
+ return {
14
+ resolveDeath(instanceId, reason) {
15
+ if (dead.has(instanceId))
16
+ return { status: "rejected", reason: "already-dead" };
17
+ const identity = deps.resolveIdentity(instanceId);
18
+ if (identity === null)
19
+ return { status: "rejected", reason: "unknown-instance" };
20
+ dead.add(instanceId);
21
+ const event = {
22
+ instanceId,
23
+ catalogId: identity.catalogId,
24
+ reason,
25
+ position: identity.position,
26
+ };
27
+ if (identity.userId !== undefined)
28
+ event.userId = identity.userId;
29
+ if (identity.displayName !== undefined)
30
+ event.displayName = identity.displayName;
31
+ deps.events.emit("entity.died", event);
32
+ const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId));
33
+ const drops = [];
34
+ for (const rule of onDeath.drops) {
35
+ if (matchesReason(rule.when, reason))
36
+ drops.push(...deps.loot.roll(rule.table));
37
+ }
38
+ let ranCommand = null;
39
+ if (onDeath.command !== null &&
40
+ matchesReason(onDeath.command.when, reason) &&
41
+ deps.runCommand !== undefined) {
42
+ deps.runCommand(onDeath.command.name, onDeath.command.args);
43
+ ranCommand = onDeath.command.name;
44
+ }
45
+ deps.despawn(instanceId);
46
+ return { status: "resolved", drops, ranCommand };
47
+ },
48
+ revive(instanceId) {
49
+ return dead.delete(instanceId);
50
+ },
51
+ };
52
+ }
53
+ export function deathReasonFromEffect(ctx) {
54
+ const killerUserId = ctx.userIdOf?.(ctx.from);
55
+ if (killerUserId !== undefined) {
56
+ return ctx.via?.item !== undefined
57
+ ? { kind: "player_kill", killerUserId, via: { item: ctx.via.item } }
58
+ : { kind: "player_kill", killerUserId };
59
+ }
60
+ return { kind: "environment", source: ctx.via?.item ?? "effect" };
61
+ }
@@ -0,0 +1,61 @@
1
+ import type { EntityPosition } from "../scene/entityStore.js";
2
+ import { type PoolStatMap } from "../scene/entityStats.js";
3
+ export interface ReceiveRule {
4
+ order: string[];
5
+ modifiers?: Record<string, number>;
6
+ }
7
+ export type ReceiveMap = Record<string, ReceiveRule>;
8
+ export interface EffectVia {
9
+ item?: string;
10
+ amount?: number;
11
+ }
12
+ export interface SingleTargetEffectInput {
13
+ from: string;
14
+ to: string;
15
+ effect: string;
16
+ via?: EffectVia;
17
+ }
18
+ export interface AreaEffectInput {
19
+ from: string;
20
+ effect: string;
21
+ via?: EffectVia;
22
+ at: EntityPosition;
23
+ radius: number;
24
+ falloff?: "linear" | "none";
25
+ los?: boolean;
26
+ }
27
+ export type EffectInput = SingleTargetEffectInput | AreaEffectInput;
28
+ export interface AppliedPoolDelta {
29
+ statId: string;
30
+ delta: number;
31
+ }
32
+ export interface EffectResult {
33
+ instanceId: string;
34
+ effect: string;
35
+ applied: AppliedPoolDelta[];
36
+ lethal: boolean;
37
+ }
38
+ export interface LethalContext {
39
+ from: string;
40
+ via?: EffectVia;
41
+ effect: string;
42
+ }
43
+ export interface CombatSpatialDeps {
44
+ inRadius(center: EntityPosition, radius: number): string[];
45
+ hasLineOfSight(from: EntityPosition | string, to: string): boolean;
46
+ positionOf(instanceId: string): EntityPosition | undefined;
47
+ }
48
+ export interface EffectSystemDeps {
49
+ resolveReceive(instanceId: string): ReceiveMap | null | undefined;
50
+ resolveStats(instanceId: string): PoolStatMap | undefined;
51
+ getStat(itemId: string, stat: string): number | null;
52
+ spatial: CombatSpatialDeps;
53
+ drainStatByEffect?: Record<string, string>;
54
+ onLethal?(instanceId: string, ctx: LethalContext): void;
55
+ }
56
+ export interface EffectSystem {
57
+ canReceive(instanceId: string, effect: string): string | null;
58
+ preview(input: SingleTargetEffectInput): number;
59
+ applyEffect(input: EffectInput): EffectResult[];
60
+ }
61
+ export declare function createEffectSystem(deps: EffectSystemDeps): EffectSystem;
@@ -0,0 +1,108 @@
1
+ import { applyPoolDelta } from "../scene/entityStats.js";
2
+ import { distanceBetween } from "../scene/spatial.js";
3
+ const DEFAULT_DRAIN_STAT = "damage";
4
+ export function createEffectSystem(deps) {
5
+ function resolveRule(instanceId, effect) {
6
+ return deps.resolveReceive(instanceId)?.[effect] ?? null;
7
+ }
8
+ function baseDrainMagnitude(effect, via) {
9
+ if (via?.amount !== undefined)
10
+ return via.amount;
11
+ if (via?.item === undefined)
12
+ return 0;
13
+ const statName = deps.drainStatByEffect?.[effect] ?? DEFAULT_DRAIN_STAT;
14
+ return deps.getStat(via.item, statName) ?? 0;
15
+ }
16
+ function modifiedDrainMagnitude(magnitude, rule) {
17
+ let result = magnitude;
18
+ for (const reduction of Object.values(rule.modifiers ?? {})) {
19
+ result *= 1 - reduction;
20
+ }
21
+ return result;
22
+ }
23
+ function canReceive(instanceId, effect) {
24
+ const rule = resolveRule(instanceId, effect);
25
+ if (rule === null)
26
+ return "not-receivable";
27
+ const stats = deps.resolveStats(instanceId);
28
+ if (stats === undefined)
29
+ return "unknown-instance";
30
+ const anyPoolAboveMin = rule.order.some((statId) => {
31
+ const stat = stats[statId];
32
+ return stat !== undefined && stat.current > stat.min;
33
+ });
34
+ if (!anyPoolAboveMin)
35
+ return "pools-depleted";
36
+ return null;
37
+ }
38
+ function drainPools(instanceId, effect, rule, stats, drainMagnitude) {
39
+ const applied = [];
40
+ const lastStatId = rule.order[rule.order.length - 1];
41
+ let remaining = drainMagnitude;
42
+ let lethal = false;
43
+ for (const statId of rule.order) {
44
+ if (remaining === 0)
45
+ break;
46
+ const before = stats[statId];
47
+ if (before === undefined)
48
+ continue;
49
+ const result = applyPoolDelta(stats, statId, -remaining);
50
+ if (result.status === "rejected")
51
+ continue;
52
+ stats[statId] = result.stat;
53
+ const delta = result.stat.current - before.current;
54
+ if (delta !== 0)
55
+ applied.push({ statId, delta });
56
+ remaining += delta;
57
+ if (statId === lastStatId && drainMagnitude > 0 && result.hitMin)
58
+ lethal = true;
59
+ }
60
+ return { instanceId, effect, applied, lethal };
61
+ }
62
+ function applyTo(instanceId, effect, via, from, scale) {
63
+ if (canReceive(instanceId, effect) !== null)
64
+ return null;
65
+ const rule = resolveRule(instanceId, effect);
66
+ const stats = deps.resolveStats(instanceId);
67
+ if (rule === null || stats === undefined)
68
+ return null;
69
+ const drainMagnitude = modifiedDrainMagnitude(baseDrainMagnitude(effect, via) * scale, rule);
70
+ const result = drainPools(instanceId, effect, rule, stats, drainMagnitude);
71
+ if (result.lethal)
72
+ deps.onLethal?.(instanceId, { from, via, effect });
73
+ return result;
74
+ }
75
+ return {
76
+ canReceive,
77
+ preview(input) {
78
+ const rule = resolveRule(input.to, input.effect);
79
+ if (rule === null)
80
+ return 0;
81
+ return modifiedDrainMagnitude(baseDrainMagnitude(input.effect, input.via), rule);
82
+ },
83
+ applyEffect(input) {
84
+ if ("to" in input) {
85
+ const result = applyTo(input.to, input.effect, input.via, input.from, 1);
86
+ return result === null ? [] : [result];
87
+ }
88
+ const falloff = input.falloff ?? "none";
89
+ const los = input.los ?? true;
90
+ const results = [];
91
+ for (const instanceId of deps.spatial.inRadius(input.at, input.radius)) {
92
+ if (los && !deps.spatial.hasLineOfSight(input.at, instanceId))
93
+ continue;
94
+ let scale = 1;
95
+ if (falloff === "linear") {
96
+ const position = deps.spatial.positionOf(instanceId);
97
+ if (position === undefined)
98
+ continue;
99
+ scale = Math.max(0, 1 - distanceBetween(input.at, position) / input.radius);
100
+ }
101
+ const result = applyTo(instanceId, input.effect, input.via, input.from, scale);
102
+ if (result !== null)
103
+ results.push(result);
104
+ }
105
+ return results;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,45 @@
1
+ import type { EntityPosition } from "../scene/entityStore.js";
2
+ import type { Aim } from "../scene/spatial.js";
3
+ import type { CombatSpatialDeps, EffectResult, EffectSystem, EffectVia } from "./effects.js";
4
+ export interface ProjectileShotInput {
5
+ from: string;
6
+ via: EffectVia;
7
+ aim: Aim;
8
+ effect: string;
9
+ }
10
+ export interface RaycastHit {
11
+ instanceId: string;
12
+ distance: number;
13
+ at: EntityPosition;
14
+ }
15
+ export type Raycast = (from: string, aim: Aim, range: number) => RaycastHit[];
16
+ export interface ProjectileSystemDeps {
17
+ effects: EffectSystem;
18
+ spatial: CombatSpatialDeps;
19
+ getStat(itemId: string, stat: string): number | null;
20
+ raycast?: Raycast;
21
+ now?: () => number;
22
+ }
23
+ export interface ProjectilePrediction {
24
+ hits: {
25
+ instanceId: string;
26
+ distance: number;
27
+ }[];
28
+ blocked?: boolean;
29
+ }
30
+ export type SettleResult = {
31
+ status: "settled";
32
+ shotId: string;
33
+ at: [number, number, number];
34
+ hits: EffectResult[];
35
+ } | {
36
+ status: "rejected";
37
+ shotId: string;
38
+ reason: string;
39
+ };
40
+ export interface ProjectileSystem {
41
+ willHitProjectile(input: ProjectileShotInput): ProjectilePrediction;
42
+ fireProjectile(input: ProjectileShotInput): string;
43
+ settleProjectile(shotId: string): SettleResult;
44
+ }
45
+ export declare function createProjectileSystem(deps: ProjectileSystemDeps): ProjectileSystem;
@@ -0,0 +1,156 @@
1
+ const DEFAULT_RANGE = 100;
2
+ const DEFAULT_PROJECTILE_SPEED = 15;
3
+ const GRAVITY = 9.8;
4
+ const BASE_HIT_RADIUS = 0.5;
5
+ function normalize(vector) {
6
+ const length = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]);
7
+ if (length === 0)
8
+ return null;
9
+ return [vector[0] / length, vector[1] / length, vector[2] / length];
10
+ }
11
+ function aimDirection(aim) {
12
+ if ("origin" in aim)
13
+ return normalize(aim.direction);
14
+ const cosPitch = Math.cos(aim.pitch);
15
+ return [Math.sin(aim.yaw) * cosPitch, Math.sin(aim.pitch), Math.cos(aim.yaw) * cosPitch];
16
+ }
17
+ function aimSpreadDeg(aim) {
18
+ return "origin" in aim ? 0 : aim.spread ?? 0;
19
+ }
20
+ export function createProjectileSystem(deps) {
21
+ const shots = new Map();
22
+ let shotCounter = 0;
23
+ const now = deps.now ?? (() => Date.now());
24
+ function itemStat(via, stat) {
25
+ return via.item === undefined ? null : deps.getStat(via.item, stat);
26
+ }
27
+ function shotOrigin(from, aim) {
28
+ return "origin" in aim ? aim.origin : deps.spatial.positionOf(from);
29
+ }
30
+ function withWeaponSpread(via, aim) {
31
+ if ("origin" in aim || aim.spread !== undefined)
32
+ return aim;
33
+ const spread = itemStat(via, "spread");
34
+ return spread === null ? aim : { ...aim, spread };
35
+ }
36
+ const raycast = deps.raycast ??
37
+ ((from, aim, range) => {
38
+ const origin = shotOrigin(from, aim);
39
+ if (origin === undefined)
40
+ return [];
41
+ const direction = aimDirection(aim);
42
+ if (direction === null)
43
+ return [];
44
+ const spreadRad = (aimSpreadDeg(aim) * Math.PI) / 180;
45
+ const hits = [];
46
+ for (const instanceId of deps.spatial.inRadius(origin, range)) {
47
+ if (instanceId === from)
48
+ continue;
49
+ const position = deps.spatial.positionOf(instanceId);
50
+ if (position === undefined)
51
+ continue;
52
+ const dx = position[0] - origin[0];
53
+ const dy = position[1] - origin[1];
54
+ const dz = position[2] - origin[2];
55
+ const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
56
+ if (along <= 0 || along > range)
57
+ continue;
58
+ const px = dx - direction[0] * along;
59
+ const py = dy - direction[1] * along;
60
+ const pz = dz - direction[2] * along;
61
+ const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
62
+ if (perpendicular > BASE_HIT_RADIUS + Math.tan(spreadRad) * along)
63
+ continue;
64
+ hits.push({ instanceId, distance: along, at: position });
65
+ }
66
+ return hits.sort((a, b) => a.distance - b.distance);
67
+ });
68
+ function resolveRange(via) {
69
+ return itemStat(via, "range") ?? DEFAULT_RANGE;
70
+ }
71
+ function isBallistic(via) {
72
+ return itemStat(via, "projectile.fuseTime") !== null || itemStat(via, "explosion.radius") !== null;
73
+ }
74
+ function ballisticSettlePoint(input) {
75
+ const origin = shotOrigin(input.from, input.aim) ?? [0, 0, 0];
76
+ const direction = aimDirection(input.aim) ?? [0, 0, 1];
77
+ const speed = itemStat(input.via, "projectile.speed") ?? DEFAULT_PROJECTILE_SPEED;
78
+ const gravityScale = itemStat(input.via, "projectile.gravity") ?? 1;
79
+ const fuseTime = itemStat(input.via, "projectile.fuseTime");
80
+ const gravity = GRAVITY * gravityScale;
81
+ const verticalSpeed = direction[1] * speed;
82
+ const flightCap = fuseTime ?? resolveRange(input.via) / speed;
83
+ const impactTime = gravity > 0
84
+ ? (verticalSpeed + Math.sqrt(verticalSpeed * verticalSpeed + 2 * gravity * Math.max(0, origin[1]))) / gravity
85
+ : flightCap;
86
+ const flightTime = Math.min(impactTime, flightCap);
87
+ const settledY = Math.max(0, origin[1] + verticalSpeed * flightTime - 0.5 * gravity * flightTime * flightTime);
88
+ return [
89
+ origin[0] + direction[0] * speed * flightTime,
90
+ settledY,
91
+ origin[2] + direction[2] * speed * flightTime,
92
+ ];
93
+ }
94
+ function predictHits(input) {
95
+ const aim = withWeaponSpread(input.via, input.aim);
96
+ const rawHits = raycast(input.from, aim, resolveRange(input.via));
97
+ const visible = rawHits.filter((hit) => deps.spatial.hasLineOfSight(input.from, hit.instanceId));
98
+ return { rawHits, visible };
99
+ }
100
+ function settledAt(input, hits) {
101
+ const first = hits[0];
102
+ if (first !== undefined)
103
+ return [first.at[0], first.at[1], first.at[2]];
104
+ const origin = shotOrigin(input.from, input.aim);
105
+ const direction = origin === undefined ? null : aimDirection(input.aim);
106
+ if (origin === undefined || direction === null)
107
+ return [0, 0, 0];
108
+ const range = resolveRange(input.via);
109
+ return [origin[0] + direction[0] * range, origin[1] + direction[1] * range, origin[2] + direction[2] * range];
110
+ }
111
+ return {
112
+ willHitProjectile(input) {
113
+ const { rawHits, visible } = predictHits(input);
114
+ const prediction = {
115
+ hits: visible.map(({ instanceId, distance }) => ({ instanceId, distance })),
116
+ };
117
+ if (rawHits.length > 0 && visible.length === 0)
118
+ prediction.blocked = true;
119
+ return prediction;
120
+ },
121
+ fireProjectile(input) {
122
+ shotCounter += 1;
123
+ const shotId = `shot_${shotCounter}`;
124
+ shots.set(shotId, { input, firedAt: now(), settled: false });
125
+ return shotId;
126
+ },
127
+ settleProjectile(shotId) {
128
+ const shot = shots.get(shotId);
129
+ if (shot === undefined)
130
+ return { status: "rejected", shotId, reason: "unknown-shot" };
131
+ if (shot.settled)
132
+ return { status: "rejected", shotId, reason: "already-settled" };
133
+ shot.settled = true;
134
+ const { input } = shot;
135
+ if (isBallistic(input.via)) {
136
+ return { status: "settled", shotId, at: ballisticSettlePoint(input), hits: [] };
137
+ }
138
+ const { visible } = predictHits(input);
139
+ const receivable = visible.filter((hit) => deps.effects.canReceive(hit.instanceId, input.effect) === null);
140
+ const pellets = Math.max(1, Math.round(itemStat(input.via, "pellets") ?? 1));
141
+ const hits = [];
142
+ if (receivable.length > 0) {
143
+ for (let pellet = 0; pellet < pellets; pellet++) {
144
+ const target = receivable[pellet % receivable.length];
145
+ hits.push(...deps.effects.applyEffect({
146
+ from: input.from,
147
+ to: target.instanceId,
148
+ effect: input.effect,
149
+ via: input.via,
150
+ }));
151
+ }
152
+ }
153
+ return { status: "settled", shotId, at: settledAt(input, receivable), hits };
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,23 @@
1
+ export interface CommandRejection {
2
+ reason: string;
3
+ }
4
+ export interface CommandDefinition<TState, TInput> {
5
+ validate?(state: TState, input: TInput): CommandRejection | null;
6
+ apply(state: TState, input: TInput): TState;
7
+ }
8
+ export type CommandResult<TState> = {
9
+ status: "applied";
10
+ state: TState;
11
+ } | {
12
+ status: "rejected";
13
+ reason: string;
14
+ } | {
15
+ status: "unknown-command";
16
+ };
17
+ export interface CommandRegistry<TState> {
18
+ define<TInput>(name: string, definition: CommandDefinition<TState, TInput>): void;
19
+ has(name: string): boolean;
20
+ names(): string[];
21
+ run(state: TState, name: string, input: unknown): CommandResult<TState>;
22
+ }
23
+ export declare function createCommandRegistry<TState>(): CommandRegistry<TState>;
@@ -0,0 +1,26 @@
1
+ export function createCommandRegistry() {
2
+ const definitions = new Map();
3
+ return {
4
+ define(name, definition) {
5
+ if (definitions.has(name)) {
6
+ throw new Error(`Command "${name}" is already defined.`);
7
+ }
8
+ definitions.set(name, definition);
9
+ },
10
+ has(name) {
11
+ return definitions.has(name);
12
+ },
13
+ names() {
14
+ return Array.from(definitions.keys());
15
+ },
16
+ run(state, name, input) {
17
+ const definition = definitions.get(name);
18
+ if (!definition)
19
+ return { status: "unknown-command" };
20
+ const rejection = definition.validate?.(state, input) ?? null;
21
+ if (rejection)
22
+ return { status: "rejected", reason: rejection.reason };
23
+ return { status: "applied", state: definition.apply(state, input) };
24
+ },
25
+ };
26
+ }