@plasius/renderer 1.0.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 +59 -0
- package/CODE_OF_CONDUCT.md +79 -0
- package/CONTRIBUTORS.md +27 -0
- package/LICENSE +203 -0
- package/README.md +70 -0
- package/SECURITY.md +17 -0
- package/dist/adaptivedpr.d.ts +2 -0
- package/dist/adaptivedpr.d.ts.map +1 -0
- package/dist/adaptivedpr.js +65 -0
- package/dist/camera/cameraRigProfile.d.ts +12 -0
- package/dist/camera/cameraRigProfile.d.ts.map +1 -0
- package/dist/camera/cameraRigProfile.js +18 -0
- package/dist/camera/managedCameraController.d.ts +49 -0
- package/dist/camera/managedCameraController.d.ts.map +1 -0
- package/dist/camera/managedCameraController.js +271 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/landscape.d.ts +2 -0
- package/dist/landscape.d.ts.map +1 -0
- package/dist/landscape.js +120 -0
- package/dist/player/player.d.ts +8 -0
- package/dist/player/player.d.ts.map +1 -0
- package/dist/player/player.js +203 -0
- package/dist/player/playerstore.d.ts +205 -0
- package/dist/player/playerstore.d.ts.map +1 -0
- package/dist/player/playerstore.js +500 -0
- package/dist/renderStateProvider.d.ts +57 -0
- package/dist/renderStateProvider.d.ts.map +1 -0
- package/dist/renderStateProvider.js +50 -0
- package/dist/renderer.d.ts +9 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +165 -0
- package/dist/scene.d.ts +7 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +10 -0
- package/dist/shaders/fragment/landscapeFragmentShader.js +141 -0
- package/dist/shaders/landscapeShader.d.ts +13 -0
- package/dist/shaders/landscapeShader.d.ts.map +1 -0
- package/dist/shaders/landscapeShader.js +25 -0
- package/dist/shaders/vertex/landscapeVertexShader.js +67 -0
- package/dist/styles/renderer.module.css +90 -0
- package/dist/worldSpaceCompositor.d.ts +50 -0
- package/dist/worldSpaceCompositor.d.ts.map +1 -0
- package/dist/worldSpaceCompositor.js +159 -0
- package/dist/xr/rendererXrBridge.d.ts +12 -0
- package/dist/xr/rendererXrBridge.d.ts.map +1 -0
- package/dist/xr/rendererXrBridge.js +17 -0
- package/docs/adrs/adr-0001-renderer-package-scope.md +21 -0
- package/docs/adrs/adr-0002-public-repo-governance.md +24 -0
- package/docs/adrs/adr-0003-world-space-compositor-contracts.md +34 -0
- package/docs/adrs/adr-template.md +35 -0
- package/docs/design/0001-public-package-scope.md +18 -0
- package/docs/tdrs/index.md +3 -0
- package/docs/tdrs/tdr-0001-renderer-public-package-standards-alignment.md +19 -0
- package/legal/CLA-REGISTRY.csv +1 -0
- package/legal/CLA.md +22 -0
- package/legal/CORPORATE_CLA.md +57 -0
- package/legal/INDIVIDUAL_CLA.md +91 -0
- package/package.json +117 -0
- package/src/adaptivedpr.tsx +74 -0
- package/src/camera/cameraRigProfile.ts +29 -0
- package/src/camera/managedCameraController.tsx +401 -0
- package/src/global.d.ts +10 -0
- package/src/index.ts +3 -0
- package/src/landscape.tsx +321 -0
- package/src/player/player.tsx +257 -0
- package/src/player/playerstore.tsx +733 -0
- package/src/renderStateProvider.tsx +121 -0
- package/src/renderer.tsx +294 -0
- package/src/scene.tsx +42 -0
- package/src/shaders/fragment/landscapeFragmentShader.d.ts +4 -0
- package/src/shaders/fragment/landscapeFragmentShader.js +141 -0
- package/src/shaders/landscapeShader.tsx +39 -0
- package/src/shaders/vertex/landscapeVertexShader.d.ts +4 -0
- package/src/shaders/vertex/landscapeVertexShader.js +67 -0
- package/src/styles/renderer.module.css +90 -0
- package/src/worldSpaceCompositor.ts +265 -0
- package/src/xr/rendererXrBridge.ts +44 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createScopedStoreContext } from "@plasius/react-state";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PlayerStore — React Scoped Store for player state (position, stats, inventory, equipment, skills, effects)
|
|
6
|
+
* No external deps. Strongly typed. Designed to be colocated with the renderer/game.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ====== Basic Types ======
|
|
10
|
+
export interface Vec3 {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
z: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AttributeKey =
|
|
17
|
+
// Physical
|
|
18
|
+
| "strength"
|
|
19
|
+
| "dexterity"
|
|
20
|
+
| "endurance"
|
|
21
|
+
// Mental
|
|
22
|
+
| "intellect"
|
|
23
|
+
| "willpower"
|
|
24
|
+
| "creativity"
|
|
25
|
+
// Spiritual
|
|
26
|
+
| "spirit"
|
|
27
|
+
| "wisdom"
|
|
28
|
+
| "charisma";
|
|
29
|
+
|
|
30
|
+
// Triangular domain types
|
|
31
|
+
export interface PhysicalAttributes {
|
|
32
|
+
strength: number;
|
|
33
|
+
dexterity: number;
|
|
34
|
+
endurance: number;
|
|
35
|
+
}
|
|
36
|
+
export interface MentalAttributes {
|
|
37
|
+
intellect: number;
|
|
38
|
+
willpower: number;
|
|
39
|
+
creativity: number;
|
|
40
|
+
}
|
|
41
|
+
export interface SpiritualAttributes {
|
|
42
|
+
spirit: number;
|
|
43
|
+
wisdom: number;
|
|
44
|
+
charisma: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Base layer (earned): each triangle must sum to 99, each attr in [1,49]
|
|
48
|
+
export interface AttributesBase {
|
|
49
|
+
physical: PhysicalAttributes;
|
|
50
|
+
mental: MentalAttributes;
|
|
51
|
+
spiritual: SpiritualAttributes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Optional additive layers (gear/effects): same shape as flat; values can be +/-
|
|
55
|
+
export interface AttributesFlat {
|
|
56
|
+
strength: number;
|
|
57
|
+
dexterity: number;
|
|
58
|
+
endurance: number;
|
|
59
|
+
intellect: number;
|
|
60
|
+
willpower: number;
|
|
61
|
+
creativity: number;
|
|
62
|
+
spirit: number;
|
|
63
|
+
wisdom: number;
|
|
64
|
+
charisma: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Resources {
|
|
68
|
+
health: number; // current
|
|
69
|
+
healthMax: number;
|
|
70
|
+
energy: number; // e.g., stamina/energy/mana — name-agnostic, customize later
|
|
71
|
+
energyMax: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Item {
|
|
75
|
+
id: string; // stable id (instance or template id depending on your pipeline)
|
|
76
|
+
name: string;
|
|
77
|
+
kind:
|
|
78
|
+
| "consumable"
|
|
79
|
+
| "weapon"
|
|
80
|
+
| "armor"
|
|
81
|
+
| "trinket"
|
|
82
|
+
| "material"
|
|
83
|
+
| "quest"
|
|
84
|
+
| "misc";
|
|
85
|
+
weight?: number; // carry weight contribution
|
|
86
|
+
stackable?: boolean; // can the same id stack?
|
|
87
|
+
maxStack?: number; // upper bound if stackable (default 99)
|
|
88
|
+
tags?: string[]; // arbitrary tags for filtering/rules
|
|
89
|
+
// Attribute modifiers applied when equipped (or when consumed if consumable)
|
|
90
|
+
modifiers?: Partial<Record<AttributeKey, number>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface InventorySlot {
|
|
94
|
+
itemId: string; // references Item.id
|
|
95
|
+
qty: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type EquipmentSlot =
|
|
99
|
+
| "head"
|
|
100
|
+
| "chest"
|
|
101
|
+
| "legs"
|
|
102
|
+
| "hands"
|
|
103
|
+
| "feet"
|
|
104
|
+
| "weapon"
|
|
105
|
+
| "offhand"
|
|
106
|
+
| "back"
|
|
107
|
+
| "ring1"
|
|
108
|
+
| "ring2"
|
|
109
|
+
| "amulet";
|
|
110
|
+
|
|
111
|
+
export type Equipment = Partial<Record<EquipmentSlot, string /* itemId */>>;
|
|
112
|
+
|
|
113
|
+
export interface Skill {
|
|
114
|
+
id: string;
|
|
115
|
+
name: string;
|
|
116
|
+
level: number; // proficiency 0–100 (used by ln scaling)
|
|
117
|
+
active?: boolean; // whether the skill is currently active (toggles penalties/bonuses)
|
|
118
|
+
source?: "innate" | "taught" | "researched"; // acquisition path (optional)
|
|
119
|
+
tags?: string[]; // e.g., ["rare", "forbidden", "movement"]
|
|
120
|
+
cooldownMs?: number;
|
|
121
|
+
resourceCost?: number; // cost from energy/mana etc.
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface StatusEffectModifier {
|
|
125
|
+
attributes?: Partial<Record<AttributeKey, number>>; // additive modifiers
|
|
126
|
+
resources?: Partial<Pick<Resources, "healthMax" | "energyMax">>;
|
|
127
|
+
moveSpeedMult?: number; // multiplicative e.g. 0.9 = -10%
|
|
128
|
+
damageMult?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface StatusEffect {
|
|
132
|
+
id: string;
|
|
133
|
+
name: string;
|
|
134
|
+
appliedAt: number; // epoch ms
|
|
135
|
+
durationMs: number; // 0 = infinite
|
|
136
|
+
modifiers?: StatusEffectModifier;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ====== Player State ======
|
|
140
|
+
export interface PlayerState {
|
|
141
|
+
// Transform
|
|
142
|
+
position: Vec3;
|
|
143
|
+
lookAt: Vec3;
|
|
144
|
+
velocity: Vec3;
|
|
145
|
+
|
|
146
|
+
// Core
|
|
147
|
+
attributesBase: AttributesBase; // earned, triangle-constrained
|
|
148
|
+
attributesGear: AttributesFlat; // from equipment totals (additive)
|
|
149
|
+
attributesEffects: AttributesFlat; // from active effects (additive)
|
|
150
|
+
resources: Resources;
|
|
151
|
+
lastActiveAt?: number; // epoch ms for decay logic
|
|
152
|
+
|
|
153
|
+
// Equipment/Inventory
|
|
154
|
+
items: Record<string, Item>; // item registry (by id). Up to the game to hydrate
|
|
155
|
+
inventory: InventorySlot[]; // backpack list
|
|
156
|
+
inventoryCapacity: number; // max slots
|
|
157
|
+
equipment: Equipment; // slot -> itemId
|
|
158
|
+
|
|
159
|
+
// Skills/Effects
|
|
160
|
+
skills: Record<string, Skill>;
|
|
161
|
+
effects: Record<string, StatusEffect>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const defaultAttributesBase: AttributesBase = {
|
|
165
|
+
physical: { strength: 33, dexterity: 33, endurance: 33 },
|
|
166
|
+
mental: { intellect: 33, willpower: 33, creativity: 33 },
|
|
167
|
+
spiritual: { spirit: 33, wisdom: 33, charisma: 33 },
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const zeroFlat: AttributesFlat = {
|
|
171
|
+
strength: 0,
|
|
172
|
+
dexterity: 0,
|
|
173
|
+
endurance: 0,
|
|
174
|
+
intellect: 0,
|
|
175
|
+
willpower: 0,
|
|
176
|
+
creativity: 0,
|
|
177
|
+
spirit: 0,
|
|
178
|
+
wisdom: 0,
|
|
179
|
+
charisma: 0,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const defaultState: PlayerState = {
|
|
183
|
+
position: { x: 0, y: 1.6, z: 0 },
|
|
184
|
+
lookAt: { x: 0, y: 1.6, z: -1 },
|
|
185
|
+
velocity: { x: 0, y: 0, z: 0 },
|
|
186
|
+
attributesBase: { ...defaultAttributesBase },
|
|
187
|
+
attributesGear: { ...zeroFlat },
|
|
188
|
+
attributesEffects: { ...zeroFlat },
|
|
189
|
+
resources: { health: 100, healthMax: 100, energy: 100, energyMax: 100 },
|
|
190
|
+
items: {},
|
|
191
|
+
inventory: [],
|
|
192
|
+
inventoryCapacity: 24,
|
|
193
|
+
equipment: {},
|
|
194
|
+
skills: {},
|
|
195
|
+
effects: {},
|
|
196
|
+
lastActiveAt: undefined,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ====== Actions ======
|
|
200
|
+
export type PlayerAction =
|
|
201
|
+
| { type: "set_position"; payload: Vec3 }
|
|
202
|
+
| { type: "set_look_at"; payload: Vec3 }
|
|
203
|
+
| { type: "set_velocity"; payload: Vec3 }
|
|
204
|
+
| { type: "learn_skill"; payload: Skill }
|
|
205
|
+
| { type: "set_skill_active"; payload: { id: string; active: boolean } }
|
|
206
|
+
| { type: "set_skill_level"; payload: { id: string; level: number } }
|
|
207
|
+
| { type: "forget_skill"; payload: { id: string } }
|
|
208
|
+
| { type: "apply_effect"; payload: StatusEffect }
|
|
209
|
+
| { type: "remove_effect"; payload: { id: string } }
|
|
210
|
+
| { type: "register_item"; payload: Item }
|
|
211
|
+
| { type: "add_item"; payload: { itemId: string; qty: number } }
|
|
212
|
+
| { type: "remove_item"; payload: { itemId: string; qty: number } }
|
|
213
|
+
| { type: "equip"; payload: { slot: EquipmentSlot; itemId: string } }
|
|
214
|
+
| { type: "unequip"; payload: { slot: EquipmentSlot } }
|
|
215
|
+
| { type: "set_resource"; payload: Partial<Resources> }
|
|
216
|
+
| {
|
|
217
|
+
type: "set_attributes_base";
|
|
218
|
+
payload: Partial<Record<AttributeKey, number>>;
|
|
219
|
+
}
|
|
220
|
+
| {
|
|
221
|
+
type: "set_attributes_gear";
|
|
222
|
+
payload: Partial<Record<AttributeKey, number>>;
|
|
223
|
+
}
|
|
224
|
+
| {
|
|
225
|
+
type: "set_attributes_effects";
|
|
226
|
+
payload: Partial<Record<AttributeKey, number>>;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ====== Helpers ======
|
|
230
|
+
const clamp = (v: number, min: number, max: number) =>
|
|
231
|
+
Math.max(min, Math.min(max, v));
|
|
232
|
+
|
|
233
|
+
function stackableCount(item: Item | undefined): number {
|
|
234
|
+
if (!item) return 1;
|
|
235
|
+
if (!item.stackable) return 1;
|
|
236
|
+
return item.maxStack ?? 99;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function addToInventory(
|
|
240
|
+
state: PlayerState,
|
|
241
|
+
itemId: string,
|
|
242
|
+
qty: number
|
|
243
|
+
): PlayerState {
|
|
244
|
+
if (qty <= 0) return state;
|
|
245
|
+
const item = state.items[itemId];
|
|
246
|
+
const maxStack = stackableCount(item);
|
|
247
|
+
let remaining = qty;
|
|
248
|
+
|
|
249
|
+
// Fill existing stacks first
|
|
250
|
+
const inv = state.inventory.map((slot) => {
|
|
251
|
+
if (remaining === 0) return slot;
|
|
252
|
+
if (slot.itemId !== itemId) return slot;
|
|
253
|
+
const space = maxStack - slot.qty;
|
|
254
|
+
if (space <= 0) return slot;
|
|
255
|
+
const add = Math.min(space, remaining);
|
|
256
|
+
remaining -= add;
|
|
257
|
+
return { ...slot, qty: slot.qty + add };
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Create new stacks while capacity allows
|
|
261
|
+
const result = [...inv];
|
|
262
|
+
while (remaining > 0 && result.length < state.inventoryCapacity) {
|
|
263
|
+
const add = Math.min(remaining, maxStack);
|
|
264
|
+
result.push({ itemId, qty: add });
|
|
265
|
+
remaining -= add;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { ...state, inventory: result };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function removeFromInventory(
|
|
272
|
+
state: PlayerState,
|
|
273
|
+
itemId: string,
|
|
274
|
+
qty: number
|
|
275
|
+
): PlayerState {
|
|
276
|
+
if (qty <= 0) return state;
|
|
277
|
+
let remaining = qty;
|
|
278
|
+
const result: InventorySlot[] = [];
|
|
279
|
+
for (const slot of state.inventory) {
|
|
280
|
+
if (slot.itemId !== itemId) {
|
|
281
|
+
result.push(slot);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (remaining === 0) {
|
|
285
|
+
result.push(slot);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (slot.qty > remaining) {
|
|
289
|
+
result.push({ itemId, qty: slot.qty - remaining });
|
|
290
|
+
remaining = 0;
|
|
291
|
+
} else {
|
|
292
|
+
remaining -= slot.qty; // drop this slot
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { ...state, inventory: result };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function clampAttr(n: number) {
|
|
299
|
+
return Math.max(1, Math.min(49, Math.round(n)));
|
|
300
|
+
}
|
|
301
|
+
function sum3(a: number, b: number, c: number) {
|
|
302
|
+
return a + b + c;
|
|
303
|
+
}
|
|
304
|
+
function normalizeTriangle(a: number, b: number, c: number) {
|
|
305
|
+
// Ensure sum=99 and each in [1,49]
|
|
306
|
+
let x = clampAttr(a),
|
|
307
|
+
y = clampAttr(b),
|
|
308
|
+
z = clampAttr(c);
|
|
309
|
+
let total = x + y + z;
|
|
310
|
+
if (total === 99) return [x, y, z] as const;
|
|
311
|
+
// Scale to 99, then clamp again, then adjust leftovers greedily
|
|
312
|
+
const scale = 99 / total;
|
|
313
|
+
x = clampAttr(x * scale);
|
|
314
|
+
y = clampAttr(y * scale);
|
|
315
|
+
z = clampAttr(z * scale);
|
|
316
|
+
total = x + y + z;
|
|
317
|
+
// Distribute deficit/excess (typed tuples + stable resorting)
|
|
318
|
+
let order: Array<["x" | "y" | "z", number]> = [
|
|
319
|
+
["x", x] as ["x", number],
|
|
320
|
+
["y", y] as ["y", number],
|
|
321
|
+
["z", z] as ["z", number],
|
|
322
|
+
];
|
|
323
|
+
const sortDesc = () => {
|
|
324
|
+
order.sort((a, b) => b[1] - a[1]);
|
|
325
|
+
};
|
|
326
|
+
sortDesc();
|
|
327
|
+
|
|
328
|
+
while (total !== 99) {
|
|
329
|
+
if (total > 99) {
|
|
330
|
+
// reduce the largest above 1
|
|
331
|
+
for (const [k, v] of order) {
|
|
332
|
+
if (v > 1) {
|
|
333
|
+
if (k === "x") x--;
|
|
334
|
+
else if (k === "y") y--;
|
|
335
|
+
else z--;
|
|
336
|
+
total--;
|
|
337
|
+
// update order values and resort
|
|
338
|
+
if (k === "x") order[0][1] = x;
|
|
339
|
+
else if (k === "y") order[1][1] = y;
|
|
340
|
+
else order[2][1] = z;
|
|
341
|
+
sortDesc();
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
// increase the smallest below 49
|
|
347
|
+
for (const [k, v] of [...order].reverse()) {
|
|
348
|
+
if (v < 49) {
|
|
349
|
+
if (k === "x") x++;
|
|
350
|
+
else if (k === "y") y++;
|
|
351
|
+
else z++;
|
|
352
|
+
total++;
|
|
353
|
+
// update order values and resort
|
|
354
|
+
if (k === "x") order[0][1] = x;
|
|
355
|
+
else if (k === "y") order[1][1] = y;
|
|
356
|
+
else order[2][1] = z;
|
|
357
|
+
sortDesc();
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return [x, y, z] as const;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function flattenBase(base: AttributesBase): AttributesFlat {
|
|
367
|
+
const [S, D, E] = normalizeTriangle(
|
|
368
|
+
base.physical.strength,
|
|
369
|
+
base.physical.dexterity,
|
|
370
|
+
base.physical.endurance
|
|
371
|
+
);
|
|
372
|
+
const [I, W, C] = normalizeTriangle(
|
|
373
|
+
base.mental.intellect,
|
|
374
|
+
base.mental.willpower,
|
|
375
|
+
base.mental.creativity
|
|
376
|
+
);
|
|
377
|
+
const [Sp, Wi, Ch] = normalizeTriangle(
|
|
378
|
+
base.spiritual.spirit,
|
|
379
|
+
base.spiritual.wisdom,
|
|
380
|
+
base.spiritual.charisma
|
|
381
|
+
);
|
|
382
|
+
return {
|
|
383
|
+
strength: S,
|
|
384
|
+
dexterity: D,
|
|
385
|
+
endurance: E,
|
|
386
|
+
intellect: I,
|
|
387
|
+
willpower: W,
|
|
388
|
+
creativity: C,
|
|
389
|
+
spirit: Sp,
|
|
390
|
+
wisdom: Wi,
|
|
391
|
+
charisma: Ch,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// === Skill-side cross-triangle modifiers ===
|
|
396
|
+
function lnContribution(x: number, s = 25) {
|
|
397
|
+
return Math.log(1 + Math.max(0, x) / s); // safe-guard
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function emptyFlat(): AttributesFlat {
|
|
401
|
+
return {
|
|
402
|
+
strength: 0,
|
|
403
|
+
dexterity: 0,
|
|
404
|
+
endurance: 0,
|
|
405
|
+
intellect: 0,
|
|
406
|
+
willpower: 0,
|
|
407
|
+
creativity: 0,
|
|
408
|
+
spirit: 0,
|
|
409
|
+
wisdom: 0,
|
|
410
|
+
charisma: 0,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Map rare/hidden skills to cross-triangle penalties (and potential future bonuses) using ln scaling
|
|
415
|
+
// Data-driven skill → attribute modifier map (multiplier per ln unit)
|
|
416
|
+
const SKILL_ATTR_MODS: Record<string, Partial<Record<AttributeKey, number>>> = {
|
|
417
|
+
shadowstep: { charisma: -2.0 },
|
|
418
|
+
dreamwalking: { wisdom: -1.0, charisma: -1.0 },
|
|
419
|
+
soulbinding: { creativity: -1.5, endurance: -1.5 },
|
|
420
|
+
chronomancy: { endurance: -1.5, spirit: -0.5 },
|
|
421
|
+
astral_projection: { strength: -1.0, endurance: -1.0 },
|
|
422
|
+
bloodcraft: { charisma: -2.0, spirit: -2.0 },
|
|
423
|
+
telepathy: { charisma: -0.5, wisdom: -0.5 },
|
|
424
|
+
telekinesis: { endurance: -0.5 },
|
|
425
|
+
possession: { spirit: -2.5, charisma: -2.5 },
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
function addFlatInto(dst: AttributesFlat, src: AttributesFlat) {
|
|
429
|
+
dst.strength += src.strength;
|
|
430
|
+
dst.dexterity += src.dexterity;
|
|
431
|
+
dst.endurance += src.endurance;
|
|
432
|
+
dst.intellect += src.intellect;
|
|
433
|
+
dst.willpower += src.willpower;
|
|
434
|
+
dst.creativity += src.creativity;
|
|
435
|
+
dst.spirit += src.spirit;
|
|
436
|
+
dst.wisdom += src.wisdom;
|
|
437
|
+
dst.charisma += src.charisma;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function skillAttributeModifiers(state: PlayerState): AttributesFlat {
|
|
441
|
+
const out = emptyFlat();
|
|
442
|
+
for (const s of Object.values(state.skills)) {
|
|
443
|
+
if (!s || !(s.active ?? false) || (s.level ?? 0) <= 0) continue;
|
|
444
|
+
const lnP = lnContribution(s.level ?? 0, 25);
|
|
445
|
+
const mods = SKILL_ATTR_MODS[s.id.toLowerCase()];
|
|
446
|
+
if (!mods) continue;
|
|
447
|
+
for (const [k, mult] of Object.entries(mods)) {
|
|
448
|
+
const key = k as AttributeKey;
|
|
449
|
+
const delta = Math.trunc((mult as number) * lnP);
|
|
450
|
+
(out as any)[key] = ((out as any)[key] ?? 0) + delta;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function effectiveAttributes(state: PlayerState): AttributesFlat {
|
|
457
|
+
// Start with normalized base
|
|
458
|
+
const base = flattenBase(state.attributesBase);
|
|
459
|
+
// Add gear/effects layers
|
|
460
|
+
const eff: AttributesFlat = { ...base };
|
|
461
|
+
const addFlat = (src: AttributesFlat) => {
|
|
462
|
+
eff.strength += src.strength;
|
|
463
|
+
eff.dexterity += src.dexterity;
|
|
464
|
+
eff.endurance += src.endurance;
|
|
465
|
+
eff.intellect += src.intellect;
|
|
466
|
+
eff.willpower += src.willpower;
|
|
467
|
+
eff.creativity += src.creativity;
|
|
468
|
+
eff.spirit += src.spirit;
|
|
469
|
+
eff.wisdom += src.wisdom;
|
|
470
|
+
eff.charisma += src.charisma;
|
|
471
|
+
};
|
|
472
|
+
addFlat(state.attributesGear);
|
|
473
|
+
addFlat(state.attributesEffects);
|
|
474
|
+
|
|
475
|
+
// Add equipment modifiers
|
|
476
|
+
for (const slot of Object.keys(state.equipment) as EquipmentSlot[]) {
|
|
477
|
+
const itemId = state.equipment[slot];
|
|
478
|
+
if (!itemId) continue;
|
|
479
|
+
const item = state.items[itemId];
|
|
480
|
+
if (item?.modifiers) {
|
|
481
|
+
for (const [k, v] of Object.entries(item.modifiers)) {
|
|
482
|
+
const key = k as AttributeKey;
|
|
483
|
+
const val = v ?? 0;
|
|
484
|
+
(eff as any)[key] = ((eff as any)[key] ?? 0) + val;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Add status effects
|
|
489
|
+
for (const effx of Object.values(state.effects)) {
|
|
490
|
+
const mods = effx.modifiers?.attributes;
|
|
491
|
+
if (!mods) continue;
|
|
492
|
+
for (const [k, v] of Object.entries(mods)) {
|
|
493
|
+
const key = k as AttributeKey;
|
|
494
|
+
const val = v ?? 0;
|
|
495
|
+
(eff as any)[key] = ((eff as any)[key] ?? 0) + val;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// Apply cross-triangle penalties/bonuses derived from active skills (rare/hidden)
|
|
499
|
+
const skillMods = skillAttributeModifiers(state);
|
|
500
|
+
addFlatInto(eff, skillMods);
|
|
501
|
+
return eff;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ====== Reducer ======
|
|
505
|
+
function reducer(state: PlayerState, action: PlayerAction): PlayerState {
|
|
506
|
+
switch (action.type) {
|
|
507
|
+
case "set_position":
|
|
508
|
+
return { ...state, position: { ...action.payload } };
|
|
509
|
+
case "set_look_at":
|
|
510
|
+
return { ...state, lookAt: { ...action.payload } };
|
|
511
|
+
case "set_velocity":
|
|
512
|
+
return { ...state, velocity: { ...action.payload } };
|
|
513
|
+
|
|
514
|
+
case "set_resource": {
|
|
515
|
+
const next: Resources = { ...state.resources, ...action.payload };
|
|
516
|
+
next.health = clamp(next.health, 0, next.healthMax);
|
|
517
|
+
next.energy = clamp(next.energy, 0, next.energyMax);
|
|
518
|
+
return { ...state, resources: next };
|
|
519
|
+
}
|
|
520
|
+
case "set_attributes_base": {
|
|
521
|
+
const next = { ...state.attributesBase };
|
|
522
|
+
const apply = (k: AttributeKey, v: number) => {
|
|
523
|
+
switch (k) {
|
|
524
|
+
case "strength":
|
|
525
|
+
next.physical.strength = v;
|
|
526
|
+
break;
|
|
527
|
+
case "dexterity":
|
|
528
|
+
next.physical.dexterity = v;
|
|
529
|
+
break;
|
|
530
|
+
case "endurance":
|
|
531
|
+
next.physical.endurance = v;
|
|
532
|
+
break;
|
|
533
|
+
case "intellect":
|
|
534
|
+
next.mental.intellect = v;
|
|
535
|
+
break;
|
|
536
|
+
case "willpower":
|
|
537
|
+
next.mental.willpower = v;
|
|
538
|
+
break;
|
|
539
|
+
case "creativity":
|
|
540
|
+
next.mental.creativity = v;
|
|
541
|
+
break;
|
|
542
|
+
case "spirit":
|
|
543
|
+
next.spiritual.spirit = v;
|
|
544
|
+
break;
|
|
545
|
+
case "wisdom":
|
|
546
|
+
next.spiritual.wisdom = v;
|
|
547
|
+
break;
|
|
548
|
+
case "charisma":
|
|
549
|
+
next.spiritual.charisma = v;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
for (const [k, v] of Object.entries(action.payload))
|
|
554
|
+
apply(k as AttributeKey, clampAttr(v as number));
|
|
555
|
+
return { ...state, attributesBase: next };
|
|
556
|
+
}
|
|
557
|
+
case "set_attributes_gear": {
|
|
558
|
+
const next: AttributesFlat = {
|
|
559
|
+
...state.attributesGear,
|
|
560
|
+
} as AttributesFlat;
|
|
561
|
+
for (const [k, v] of Object.entries(action.payload))
|
|
562
|
+
(next as any)[k] = ((next as any)[k] ?? 0) + (v as number);
|
|
563
|
+
return { ...state, attributesGear: next };
|
|
564
|
+
}
|
|
565
|
+
case "set_attributes_effects": {
|
|
566
|
+
const next: AttributesFlat = {
|
|
567
|
+
...state.attributesEffects,
|
|
568
|
+
} as AttributesFlat;
|
|
569
|
+
for (const [k, v] of Object.entries(action.payload))
|
|
570
|
+
(next as any)[k] = ((next as any)[k] ?? 0) + (v as number);
|
|
571
|
+
return { ...state, attributesEffects: next };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
case "register_item": {
|
|
575
|
+
const it = action.payload;
|
|
576
|
+
return { ...state, items: { ...state.items, [it.id]: it } };
|
|
577
|
+
}
|
|
578
|
+
case "add_item":
|
|
579
|
+
return addToInventory(state, action.payload.itemId, action.payload.qty);
|
|
580
|
+
case "remove_item":
|
|
581
|
+
return removeFromInventory(
|
|
582
|
+
state,
|
|
583
|
+
action.payload.itemId,
|
|
584
|
+
action.payload.qty
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
case "equip": {
|
|
588
|
+
const { slot, itemId } = action.payload;
|
|
589
|
+
// Ensure we have the item and it exists in inventory or is otherwise obtainable
|
|
590
|
+
if (!state.items[itemId]) return state;
|
|
591
|
+
return { ...state, equipment: { ...state.equipment, [slot]: itemId } };
|
|
592
|
+
}
|
|
593
|
+
case "unequip": {
|
|
594
|
+
const { slot } = action.payload;
|
|
595
|
+
const eq = { ...state.equipment };
|
|
596
|
+
delete eq[slot];
|
|
597
|
+
return { ...state, equipment: eq };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
case "learn_skill": {
|
|
601
|
+
const s = action.payload;
|
|
602
|
+
return { ...state, skills: { ...state.skills, [s.id]: s } };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
case "set_skill_active": {
|
|
606
|
+
const { id, active } = action.payload;
|
|
607
|
+
const prev = state.skills[id];
|
|
608
|
+
if (!prev) return state;
|
|
609
|
+
return {
|
|
610
|
+
...state,
|
|
611
|
+
skills: { ...state.skills, [id]: { ...prev, active } },
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
case "set_skill_level": {
|
|
616
|
+
const { id, level } = action.payload;
|
|
617
|
+
const prev = state.skills[id];
|
|
618
|
+
if (!prev) return state;
|
|
619
|
+
return {
|
|
620
|
+
...state,
|
|
621
|
+
skills: {
|
|
622
|
+
...state.skills,
|
|
623
|
+
[id]: {
|
|
624
|
+
...prev,
|
|
625
|
+
level: Math.max(0, Math.min(100, Math.round(level))),
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
case "forget_skill": {
|
|
632
|
+
const { id } = action.payload;
|
|
633
|
+
const next = { ...state.skills };
|
|
634
|
+
delete next[id];
|
|
635
|
+
return { ...state, skills: next };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
case "apply_effect": {
|
|
639
|
+
const e = action.payload;
|
|
640
|
+
return { ...state, effects: { ...state.effects, [e.id]: e } };
|
|
641
|
+
}
|
|
642
|
+
case "remove_effect": {
|
|
643
|
+
const { id } = action.payload;
|
|
644
|
+
const next = { ...state.effects };
|
|
645
|
+
delete next[id];
|
|
646
|
+
return { ...state, effects: next };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
default:
|
|
650
|
+
return state;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ====== Scoped Store Wiring (using shared react-state) ======
|
|
655
|
+
export type PlayerStoreContext = ReturnType<
|
|
656
|
+
typeof createScopedStoreContext<PlayerState, PlayerAction>
|
|
657
|
+
>;
|
|
658
|
+
|
|
659
|
+
export const PlayerStore: PlayerStoreContext =
|
|
660
|
+
createScopedStoreContext<PlayerState, PlayerAction>(reducer, defaultState);
|
|
661
|
+
|
|
662
|
+
// Convenience re-exports for common actions (keeps call sites tidy)
|
|
663
|
+
export const PlayerActions = {
|
|
664
|
+
setPosition: (payload: Vec3): PlayerAction => ({
|
|
665
|
+
type: "set_position",
|
|
666
|
+
payload,
|
|
667
|
+
}),
|
|
668
|
+
setLookAt: (payload: Vec3): PlayerAction => ({
|
|
669
|
+
type: "set_look_at",
|
|
670
|
+
payload,
|
|
671
|
+
}),
|
|
672
|
+
setVelocity: (payload: Vec3): PlayerAction => ({
|
|
673
|
+
type: "set_velocity",
|
|
674
|
+
payload,
|
|
675
|
+
}),
|
|
676
|
+
setResource: (payload: Partial<Resources>): PlayerAction => ({
|
|
677
|
+
type: "set_resource",
|
|
678
|
+
payload,
|
|
679
|
+
}),
|
|
680
|
+
setAttributesBase: (
|
|
681
|
+
payload: Partial<Record<AttributeKey, number>>
|
|
682
|
+
): PlayerAction => ({ type: "set_attributes_base", payload }),
|
|
683
|
+
setAttributesGear: (
|
|
684
|
+
payload: Partial<Record<AttributeKey, number>>
|
|
685
|
+
): PlayerAction => ({ type: "set_attributes_gear", payload }),
|
|
686
|
+
setAttributesEffects: (
|
|
687
|
+
payload: Partial<Record<AttributeKey, number>>
|
|
688
|
+
): PlayerAction => ({ type: "set_attributes_effects", payload }),
|
|
689
|
+
registerItem: (payload: Item): PlayerAction => ({
|
|
690
|
+
type: "register_item",
|
|
691
|
+
payload,
|
|
692
|
+
}),
|
|
693
|
+
addItem: (itemId: string, qty: number): PlayerAction => ({
|
|
694
|
+
type: "add_item",
|
|
695
|
+
payload: { itemId, qty },
|
|
696
|
+
}),
|
|
697
|
+
removeItem: (itemId: string, qty: number): PlayerAction => ({
|
|
698
|
+
type: "remove_item",
|
|
699
|
+
payload: { itemId, qty },
|
|
700
|
+
}),
|
|
701
|
+
equip: (slot: EquipmentSlot, itemId: string): PlayerAction => ({
|
|
702
|
+
type: "equip",
|
|
703
|
+
payload: { slot, itemId },
|
|
704
|
+
}),
|
|
705
|
+
unequip: (slot: EquipmentSlot): PlayerAction => ({
|
|
706
|
+
type: "unequip",
|
|
707
|
+
payload: { slot },
|
|
708
|
+
}),
|
|
709
|
+
learnSkill: (payload: Skill): PlayerAction => ({
|
|
710
|
+
type: "learn_skill",
|
|
711
|
+
payload,
|
|
712
|
+
}),
|
|
713
|
+
setSkillActive: (id: string, active: boolean): PlayerAction => ({
|
|
714
|
+
type: "set_skill_active",
|
|
715
|
+
payload: { id, active },
|
|
716
|
+
}),
|
|
717
|
+
setSkillLevel: (id: string, level: number): PlayerAction => ({
|
|
718
|
+
type: "set_skill_level",
|
|
719
|
+
payload: { id, level },
|
|
720
|
+
}),
|
|
721
|
+
forgetSkill: (id: string): PlayerAction => ({
|
|
722
|
+
type: "forget_skill",
|
|
723
|
+
payload: { id },
|
|
724
|
+
}),
|
|
725
|
+
applyEffect: (payload: StatusEffect): PlayerAction => ({
|
|
726
|
+
type: "apply_effect",
|
|
727
|
+
payload,
|
|
728
|
+
}),
|
|
729
|
+
removeEffect: (id: string): PlayerAction => ({
|
|
730
|
+
type: "remove_effect",
|
|
731
|
+
payload: { id },
|
|
732
|
+
}),
|
|
733
|
+
} as const;
|