@jgengine/core 0.14.0 → 0.15.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 +500 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +145 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** The out-of-the-box accessibility state (all assistance off, `textScale` 1). */
|
|
2
|
+
export const DEFAULT_ACCESSIBILITY = {
|
|
3
|
+
reducedMotion: false,
|
|
4
|
+
highContrast: false,
|
|
5
|
+
textScale: 1,
|
|
6
|
+
colorblind: "none",
|
|
7
|
+
captions: false,
|
|
8
|
+
};
|
|
9
|
+
/** Minimum UI text scale. */
|
|
10
|
+
export const TEXT_SCALE_MIN = 0.75;
|
|
11
|
+
/** Maximum UI text scale. */
|
|
12
|
+
export const TEXT_SCALE_MAX = 2;
|
|
13
|
+
/** Clamp a text-scale multiplier into the supported `[TEXT_SCALE_MIN, TEXT_SCALE_MAX]` range. */
|
|
14
|
+
export function clampTextScale(scale) {
|
|
15
|
+
if (Number.isNaN(scale))
|
|
16
|
+
return 1;
|
|
17
|
+
return Math.max(TEXT_SCALE_MIN, Math.min(TEXT_SCALE_MAX, scale));
|
|
18
|
+
}
|
|
19
|
+
/** Return `ms` when motion is allowed, or `reduced` (default 0) when `reducedMotion` is set — the animation-duration gate. */
|
|
20
|
+
export function reducedMotionDuration(state, ms, reduced = 0) {
|
|
21
|
+
return state.reducedMotion ? reduced : ms;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `feColorMatrix` `values` strings for each colorblind mode — simulate how a
|
|
25
|
+
* viewer with that condition perceives color (and a grayscale mode). `"none"`
|
|
26
|
+
* maps to `null` (no filter). Consumed by the React `ColorblindFilters` defs.
|
|
27
|
+
*/
|
|
28
|
+
export const COLORBLIND_MATRICES = {
|
|
29
|
+
none: null,
|
|
30
|
+
protanopia: "0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0",
|
|
31
|
+
deuteranopia: "0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0",
|
|
32
|
+
tritanopia: "0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0",
|
|
33
|
+
grayscale: "0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Serializable, observable store of accessibility preferences (reduced motion,
|
|
37
|
+
* high contrast, text scale, colorblind mode, captions). Genre-agnostic plumbing
|
|
38
|
+
* like input rebinding — a game binds these to its settings UI and adapts its
|
|
39
|
+
* presentation; `snapshot`/`restore` round-trip through a save or settings blob.
|
|
40
|
+
*
|
|
41
|
+
* @capability accessibility-store serializable, observable accessibility preferences (reduced motion, high contrast, text scale, colorblind mode, captions) a game binds to its settings and presentation
|
|
42
|
+
*/
|
|
43
|
+
export function createAccessibilityStore(initial = {}) {
|
|
44
|
+
let state = normalize({ ...DEFAULT_ACCESSIBILITY, ...initial });
|
|
45
|
+
const listeners = new Set();
|
|
46
|
+
function normalize(next) {
|
|
47
|
+
return { ...next, textScale: clampTextScale(next.textScale) };
|
|
48
|
+
}
|
|
49
|
+
function commit(next) {
|
|
50
|
+
const normalized = normalize(next);
|
|
51
|
+
if (normalized.reducedMotion === state.reducedMotion &&
|
|
52
|
+
normalized.highContrast === state.highContrast &&
|
|
53
|
+
normalized.textScale === state.textScale &&
|
|
54
|
+
normalized.colorblind === state.colorblind &&
|
|
55
|
+
normalized.captions === state.captions) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
state = normalized;
|
|
59
|
+
for (const listener of listeners)
|
|
60
|
+
listener();
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
get() {
|
|
64
|
+
return state;
|
|
65
|
+
},
|
|
66
|
+
set(patch) {
|
|
67
|
+
commit({ ...state, ...patch });
|
|
68
|
+
},
|
|
69
|
+
reset() {
|
|
70
|
+
commit({ ...DEFAULT_ACCESSIBILITY });
|
|
71
|
+
},
|
|
72
|
+
subscribe(listener) {
|
|
73
|
+
listeners.add(listener);
|
|
74
|
+
return () => listeners.delete(listener);
|
|
75
|
+
},
|
|
76
|
+
snapshot() {
|
|
77
|
+
return state;
|
|
78
|
+
},
|
|
79
|
+
restore(snapshot) {
|
|
80
|
+
commit({ ...DEFAULT_ACCESSIBILITY, ...snapshot });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Where a coach-mark callout sits relative to its anchor (or `"center"` for an unanchored callout). */
|
|
2
|
+
export type CoachMarkPlacement = "top" | "bottom" | "left" | "right" | "center";
|
|
3
|
+
/** One onboarding hint: an ordered, optionally-anchored, optionally-gated callout. */
|
|
4
|
+
export interface CoachMarkStep {
|
|
5
|
+
/** Stable id — also the key tracked in the persisted "seen" set. */
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
body?: string;
|
|
9
|
+
/**
|
|
10
|
+
* CSS selector (or a logical key the presenter resolves) for the element the
|
|
11
|
+
* callout points at. Omitted → the step renders as a centered callout.
|
|
12
|
+
*/
|
|
13
|
+
anchor?: string;
|
|
14
|
+
/** Preferred side of the anchor. Default `"bottom"` when anchored, `"center"` otherwise. */
|
|
15
|
+
placement?: CoachMarkPlacement;
|
|
16
|
+
/**
|
|
17
|
+
* Serializable trigger id. While set, the step stays hidden until that id is
|
|
18
|
+
* in the sequence's satisfied set (see {@link CoachMarkSequence.satisfy}).
|
|
19
|
+
* Kept data-first — a string, not a function — so a whole sequence serializes.
|
|
20
|
+
*/
|
|
21
|
+
condition?: string;
|
|
22
|
+
}
|
|
23
|
+
/** The active step plus its position, returned by {@link CoachMarkSequence.current}. */
|
|
24
|
+
export interface CoachMarkView {
|
|
25
|
+
/** The step to render. */
|
|
26
|
+
step: CoachMarkStep;
|
|
27
|
+
/** 0-based position of this step within the full ordered list. */
|
|
28
|
+
index: number;
|
|
29
|
+
/** Total number of steps in the sequence. */
|
|
30
|
+
total: number;
|
|
31
|
+
/** Un-seen steps left, including this one (for an "N of M" style counter). */
|
|
32
|
+
remaining: number;
|
|
33
|
+
}
|
|
34
|
+
/** Serializable persisted state — the seen set (with timestamps) and satisfied triggers. */
|
|
35
|
+
export interface CoachMarkSnapshot {
|
|
36
|
+
/** Ids of steps that have been advanced past, dismissed, or skipped. */
|
|
37
|
+
seen: readonly string[];
|
|
38
|
+
/** When each seen id was first marked, from the injected clock. */
|
|
39
|
+
seenAt: Readonly<Record<string, number>>;
|
|
40
|
+
/** Currently-satisfied trigger ids (gate state). */
|
|
41
|
+
satisfied: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
/** Options for {@link createCoachMarkSequence}. */
|
|
44
|
+
export interface CoachMarkSequenceOptions {
|
|
45
|
+
/** Ordered hint steps. */
|
|
46
|
+
steps: readonly CoachMarkStep[];
|
|
47
|
+
/** Injected clock (ms) used to timestamp when a step is marked seen. Default `Date.now`. */
|
|
48
|
+
now?: () => number;
|
|
49
|
+
/** Ids of steps already seen (e.g. restored from a save) so they never re-show. */
|
|
50
|
+
seen?: Iterable<string>;
|
|
51
|
+
/** Trigger ids already satisfied when the sequence starts. */
|
|
52
|
+
satisfied?: Iterable<string>;
|
|
53
|
+
}
|
|
54
|
+
/** An ordered, observable, serializable onboarding coach-mark run. */
|
|
55
|
+
export interface CoachMarkSequence {
|
|
56
|
+
/** The configured steps, in order. */
|
|
57
|
+
readonly steps: readonly CoachMarkStep[];
|
|
58
|
+
/** First un-seen step whose condition (if any) is satisfied, or `null` when none is eligible. */
|
|
59
|
+
current(): CoachMarkView | null;
|
|
60
|
+
/** Mark the current step seen and return the next eligible step (or `null`). */
|
|
61
|
+
advance(): CoachMarkView | null;
|
|
62
|
+
/** Alias of {@link CoachMarkSequence.advance}. */
|
|
63
|
+
next(): CoachMarkView | null;
|
|
64
|
+
/** Mark a specific step seen so it never re-shows. */
|
|
65
|
+
dismiss(id: string): void;
|
|
66
|
+
/** Mark every step seen — "skip tour". */
|
|
67
|
+
skipAll(): void;
|
|
68
|
+
/** Add trigger ids to the satisfied set, un-gating any steps waiting on them. */
|
|
69
|
+
satisfy(...triggerIds: string[]): void;
|
|
70
|
+
/** Replace the satisfied trigger set wholesale (e.g. re-derived from game state). */
|
|
71
|
+
setSatisfied(triggerIds: Iterable<string>): void;
|
|
72
|
+
/** Whether the given trigger id is currently satisfied. */
|
|
73
|
+
isTriggerSatisfied(triggerId: string): boolean;
|
|
74
|
+
/** Whether a step id has been seen. */
|
|
75
|
+
isSeen(id: string): boolean;
|
|
76
|
+
/** Clock time a step was first seen, or `undefined` if not yet seen. */
|
|
77
|
+
seenAt(id: string): number | undefined;
|
|
78
|
+
/** True once every step has been seen. */
|
|
79
|
+
isComplete(): boolean;
|
|
80
|
+
/** How many steps remain un-seen (whether or not currently gated). */
|
|
81
|
+
remaining(): number;
|
|
82
|
+
/** Observe changes (advance, dismiss, satisfy, restore). Returns an unsubscribe fn. */
|
|
83
|
+
subscribe(listener: () => void): () => void;
|
|
84
|
+
/** Serializable state for a save. */
|
|
85
|
+
snapshot(): CoachMarkSnapshot;
|
|
86
|
+
/** Restore from a {@link CoachMarkSnapshot}. */
|
|
87
|
+
restore(snapshot: CoachMarkSnapshot): void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* An onboarding coach-mark run: an ordered sequence of hint steps, each shown as
|
|
91
|
+
* a centered callout or anchored to a UI element, with a persisted "seen" set so
|
|
92
|
+
* completed onboarding stays done across sessions, and data-first condition
|
|
93
|
+
* gating so a step waits until its trigger id is satisfied. Advance/skip/dismiss
|
|
94
|
+
* drive it; `snapshot`/`restore` round-trip the seen set through a save. Purely a
|
|
95
|
+
* model — a React host renders `current()`.
|
|
96
|
+
*
|
|
97
|
+
* @capability coach-marks ordered, gated, persist-once onboarding coach-marks/tutorial callouts — anchored or centered steps with a seen set and Next/Skip
|
|
98
|
+
*/
|
|
99
|
+
export declare function createCoachMarkSequence(options: CoachMarkSequenceOptions): CoachMarkSequence;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An onboarding coach-mark run: an ordered sequence of hint steps, each shown as
|
|
3
|
+
* a centered callout or anchored to a UI element, with a persisted "seen" set so
|
|
4
|
+
* completed onboarding stays done across sessions, and data-first condition
|
|
5
|
+
* gating so a step waits until its trigger id is satisfied. Advance/skip/dismiss
|
|
6
|
+
* drive it; `snapshot`/`restore` round-trip the seen set through a save. Purely a
|
|
7
|
+
* model — a React host renders `current()`.
|
|
8
|
+
*
|
|
9
|
+
* @capability coach-marks ordered, gated, persist-once onboarding coach-marks/tutorial callouts — anchored or centered steps with a seen set and Next/Skip
|
|
10
|
+
*/
|
|
11
|
+
export function createCoachMarkSequence(options) {
|
|
12
|
+
const steps = [...options.steps];
|
|
13
|
+
const now = options.now ?? Date.now;
|
|
14
|
+
const seenAtMap = new Map();
|
|
15
|
+
let satisfied = new Set(options.satisfied ?? []);
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
for (const id of options.seen ?? []) {
|
|
18
|
+
if (!seenAtMap.has(id))
|
|
19
|
+
seenAtMap.set(id, now());
|
|
20
|
+
}
|
|
21
|
+
function notify() {
|
|
22
|
+
for (const listener of listeners)
|
|
23
|
+
listener();
|
|
24
|
+
}
|
|
25
|
+
function markSeen(id) {
|
|
26
|
+
if (seenAtMap.has(id))
|
|
27
|
+
return false;
|
|
28
|
+
seenAtMap.set(id, now());
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
function eligible(step) {
|
|
32
|
+
if (seenAtMap.has(step.id))
|
|
33
|
+
return false;
|
|
34
|
+
return step.condition === undefined || satisfied.has(step.condition);
|
|
35
|
+
}
|
|
36
|
+
function remaining() {
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const step of steps)
|
|
39
|
+
if (!seenAtMap.has(step.id))
|
|
40
|
+
count += 1;
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
function current() {
|
|
44
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
45
|
+
const step = steps[index];
|
|
46
|
+
if (eligible(step)) {
|
|
47
|
+
return { step, index, total: steps.length, remaining: remaining() };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
steps,
|
|
54
|
+
current,
|
|
55
|
+
advance() {
|
|
56
|
+
const active = current();
|
|
57
|
+
if (active !== null && markSeen(active.step.id))
|
|
58
|
+
notify();
|
|
59
|
+
return current();
|
|
60
|
+
},
|
|
61
|
+
next() {
|
|
62
|
+
return this.advance();
|
|
63
|
+
},
|
|
64
|
+
dismiss(id) {
|
|
65
|
+
if (markSeen(id))
|
|
66
|
+
notify();
|
|
67
|
+
},
|
|
68
|
+
skipAll() {
|
|
69
|
+
let changed = false;
|
|
70
|
+
for (const step of steps)
|
|
71
|
+
if (markSeen(step.id))
|
|
72
|
+
changed = true;
|
|
73
|
+
if (changed)
|
|
74
|
+
notify();
|
|
75
|
+
},
|
|
76
|
+
satisfy(...triggerIds) {
|
|
77
|
+
let changed = false;
|
|
78
|
+
for (const id of triggerIds) {
|
|
79
|
+
if (!satisfied.has(id)) {
|
|
80
|
+
satisfied.add(id);
|
|
81
|
+
changed = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (changed)
|
|
85
|
+
notify();
|
|
86
|
+
},
|
|
87
|
+
setSatisfied(triggerIds) {
|
|
88
|
+
satisfied = new Set(triggerIds);
|
|
89
|
+
notify();
|
|
90
|
+
},
|
|
91
|
+
isTriggerSatisfied(triggerId) {
|
|
92
|
+
return satisfied.has(triggerId);
|
|
93
|
+
},
|
|
94
|
+
isSeen(id) {
|
|
95
|
+
return seenAtMap.has(id);
|
|
96
|
+
},
|
|
97
|
+
seenAt(id) {
|
|
98
|
+
return seenAtMap.get(id);
|
|
99
|
+
},
|
|
100
|
+
isComplete() {
|
|
101
|
+
return steps.every((step) => seenAtMap.has(step.id));
|
|
102
|
+
},
|
|
103
|
+
remaining,
|
|
104
|
+
subscribe(listener) {
|
|
105
|
+
listeners.add(listener);
|
|
106
|
+
return () => listeners.delete(listener);
|
|
107
|
+
},
|
|
108
|
+
snapshot() {
|
|
109
|
+
const seen = [];
|
|
110
|
+
const seenAt = {};
|
|
111
|
+
for (const [id, at] of seenAtMap) {
|
|
112
|
+
seen.push(id);
|
|
113
|
+
seenAt[id] = at;
|
|
114
|
+
}
|
|
115
|
+
return { seen, seenAt, satisfied: [...satisfied] };
|
|
116
|
+
},
|
|
117
|
+
restore(snapshot) {
|
|
118
|
+
seenAtMap.clear();
|
|
119
|
+
for (const id of snapshot.seen) {
|
|
120
|
+
seenAtMap.set(id, snapshot.seenAt[id] ?? now());
|
|
121
|
+
}
|
|
122
|
+
satisfied = new Set(snapshot.satisfied);
|
|
123
|
+
notify();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** A world-space anchor `[x, y, z]` a floating-text entry rises from. */
|
|
2
|
+
export type FloatingTextPosition = readonly [number, number, number];
|
|
3
|
+
/**
|
|
4
|
+
* One text pop to spawn: what to say, where in the world, and how it moves and
|
|
5
|
+
* fades. Everything but `position` and `text` is optional and falls back to the
|
|
6
|
+
* field's defaults. `kind` is a free-form tag (`"damage"`, `"heal"`, `"crit"`,
|
|
7
|
+
* `"xp"`, `"gold"`, `"bark"`, …) the presentation layer styles — the model never
|
|
8
|
+
* interprets it, so nothing here is combat- or genre-specific.
|
|
9
|
+
*/
|
|
10
|
+
export interface FloatingTextDef {
|
|
11
|
+
/** World anchor the text rises from. */
|
|
12
|
+
position: FloatingTextPosition;
|
|
13
|
+
/** The text to show. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Free-form style tag the game colors/skins. Default `"default"`. */
|
|
16
|
+
kind?: string;
|
|
17
|
+
/** Optional CSS color hint carried straight through to the view (presentation may override per `kind`). */
|
|
18
|
+
color?: string;
|
|
19
|
+
/** Relative scale multiplier (before the birth pop-in). Default 1. */
|
|
20
|
+
size?: number;
|
|
21
|
+
/** Upward world-Y speed in units/second. Default = field `defaultRise`. */
|
|
22
|
+
rise?: number;
|
|
23
|
+
/** Horizontal world-X speed in units/second (signed). Default: a small seeded jitter in `±driftJitter`. */
|
|
24
|
+
drift?: number;
|
|
25
|
+
/** Lifetime in seconds. Default = field `defaultLifetime`. */
|
|
26
|
+
lifetime?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Construction options for {@link createFloatingTextField}. */
|
|
29
|
+
export interface FloatingTextFieldConfig {
|
|
30
|
+
/** Hard cap on live entries; the pool never grows past this. When full, the oldest entry is recycled. Default 64. */
|
|
31
|
+
max?: number;
|
|
32
|
+
/** Injected wall clock in ms, stamped on each entry's `bornAt`. Default `Date.now`; inject for deterministic tests. */
|
|
33
|
+
now?: () => number;
|
|
34
|
+
/** Seed for the horizontal-drift jitter stream — same seed + same calls reproduce identical motion. Default `"floating-text"`. */
|
|
35
|
+
seed?: string | number;
|
|
36
|
+
/** Default lifetime in seconds when a def omits `lifetime`. Default 1.4. */
|
|
37
|
+
defaultLifetime?: number;
|
|
38
|
+
/** Default upward speed in units/second when a def omits `rise`. Default 1.2. */
|
|
39
|
+
defaultRise?: number;
|
|
40
|
+
/** Max magnitude of the seeded horizontal drift used when a def omits `drift`. Default 0.6. */
|
|
41
|
+
driftJitter?: number;
|
|
42
|
+
/** Fraction of life spent on the birth scale pop-in (0.6→1). Default 0.16. */
|
|
43
|
+
popIn?: number;
|
|
44
|
+
/** Fraction of life over which alpha fades to 0 at the end. Default 0.4. */
|
|
45
|
+
fadeFraction?: number;
|
|
46
|
+
}
|
|
47
|
+
/** A live entry resolved for rendering: current world position and fade/scale over life. */
|
|
48
|
+
export interface FloatingTextView {
|
|
49
|
+
/** Stable, monotonically increasing id assigned at emit — usable as a React key. */
|
|
50
|
+
id: number;
|
|
51
|
+
/** Current world position with rise (Y) and drift (X) applied. */
|
|
52
|
+
position: FloatingTextPosition;
|
|
53
|
+
/** The text to show. */
|
|
54
|
+
text: string;
|
|
55
|
+
/** The style tag (resolved; `"default"` when the def omitted `kind`). */
|
|
56
|
+
kind: string;
|
|
57
|
+
/** The def's color hint, if any. */
|
|
58
|
+
color?: string;
|
|
59
|
+
/** Resolved scale: base `size` times the current birth pop-in factor. */
|
|
60
|
+
size: number;
|
|
61
|
+
/** Opacity `0..1`, full until the fade window then easing to 0. */
|
|
62
|
+
alpha: number;
|
|
63
|
+
/** Age as a fraction of lifetime, `0..1`. */
|
|
64
|
+
progress: number;
|
|
65
|
+
/** Wall-clock ms the entry was emitted (from the injected `now`). */
|
|
66
|
+
bornAt: number;
|
|
67
|
+
}
|
|
68
|
+
/** Serializable state for save/restore and deterministic replay. */
|
|
69
|
+
export interface FloatingTextSnapshot {
|
|
70
|
+
seed: number;
|
|
71
|
+
nextId: number;
|
|
72
|
+
count: number;
|
|
73
|
+
/** Numeric per-entry columns, flattened `count * NUMERIC_STRIDE`. */
|
|
74
|
+
data: number[];
|
|
75
|
+
/** Per-entry text. */
|
|
76
|
+
text: string[];
|
|
77
|
+
/** Per-entry kind. */
|
|
78
|
+
kind: string[];
|
|
79
|
+
/** Per-entry color (empty string = none). */
|
|
80
|
+
color: string[];
|
|
81
|
+
}
|
|
82
|
+
/** A live, dt-driven field of world-anchored floating text. */
|
|
83
|
+
export interface FloatingTextField {
|
|
84
|
+
/** Spawn one entry; returns its id. When the pool is full the oldest live entry is recycled. */
|
|
85
|
+
emit(def: FloatingTextDef): number;
|
|
86
|
+
/** Advance every entry by `dt` seconds: age, rise, drift, and reap the expired. */
|
|
87
|
+
update(dt: number): void;
|
|
88
|
+
/** Snapshot of the live entries resolved for rendering (a fresh array each call). */
|
|
89
|
+
active(): FloatingTextView[];
|
|
90
|
+
/** Live entry count. */
|
|
91
|
+
count(): number;
|
|
92
|
+
/** Remove every entry (keeps the seed stream and id counter). */
|
|
93
|
+
clear(): void;
|
|
94
|
+
/** Subscribe to changes (emit/update/clear/restore); returns an unsubscribe. */
|
|
95
|
+
subscribe(listener: () => void): () => void;
|
|
96
|
+
snapshot(): FloatingTextSnapshot;
|
|
97
|
+
restore(snapshot: FloatingTextSnapshot): void;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A generic, allocation-aware field of world-anchored floating text: damage
|
|
101
|
+
* numbers, heals, crits, XP/gold gains, status pops, dialogue barks — anything a
|
|
102
|
+
* game wants to say at a point in the world for a moment. It is dt-driven (call
|
|
103
|
+
* `update(dt)` each frame) and deterministic: the only randomness is the
|
|
104
|
+
* horizontal drift jitter, which flows from the injected `seed`, so the same seed
|
|
105
|
+
* and the same emit/update sequence reproduce identical motion, and
|
|
106
|
+
* `snapshot`/`restore` round-trips the live pool. The model owns only the
|
|
107
|
+
* spawn → rise/drift → fade → reap lifecycle: `kind` is a free string and `color`
|
|
108
|
+
* a passthrough hint, so nothing here couples to combat or any genre. Presentation
|
|
109
|
+
* (projecting each entry to the screen and styling it per `kind`) lives in
|
|
110
|
+
* `@jgengine/react`'s `FloatingText`.
|
|
111
|
+
*
|
|
112
|
+
* @capability floating-text deterministic pooled world-anchored floating combat text / damage numbers with rise, drift, fade, and serializable state
|
|
113
|
+
*/
|
|
114
|
+
export declare function createFloatingTextField(config?: FloatingTextFieldConfig): FloatingTextField;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { hashString, randomSeedFrom, stepRandomSeed } from "../random/rng.js";
|
|
2
|
+
/** Columns per entry in the numeric SoA pool: id, ox, oy, oz, age, life, rise, drift, size. */
|
|
3
|
+
const NUMERIC_STRIDE = 9;
|
|
4
|
+
function clamp01(v) {
|
|
5
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A generic, allocation-aware field of world-anchored floating text: damage
|
|
9
|
+
* numbers, heals, crits, XP/gold gains, status pops, dialogue barks — anything a
|
|
10
|
+
* game wants to say at a point in the world for a moment. It is dt-driven (call
|
|
11
|
+
* `update(dt)` each frame) and deterministic: the only randomness is the
|
|
12
|
+
* horizontal drift jitter, which flows from the injected `seed`, so the same seed
|
|
13
|
+
* and the same emit/update sequence reproduce identical motion, and
|
|
14
|
+
* `snapshot`/`restore` round-trips the live pool. The model owns only the
|
|
15
|
+
* spawn → rise/drift → fade → reap lifecycle: `kind` is a free string and `color`
|
|
16
|
+
* a passthrough hint, so nothing here couples to combat or any genre. Presentation
|
|
17
|
+
* (projecting each entry to the screen and styling it per `kind`) lives in
|
|
18
|
+
* `@jgengine/react`'s `FloatingText`.
|
|
19
|
+
*
|
|
20
|
+
* @capability floating-text deterministic pooled world-anchored floating combat text / damage numbers with rise, drift, fade, and serializable state
|
|
21
|
+
*/
|
|
22
|
+
export function createFloatingTextField(config = {}) {
|
|
23
|
+
const max = Math.max(1, Math.floor(config.max ?? 64));
|
|
24
|
+
const now = config.now ?? (() => Date.now());
|
|
25
|
+
const defaultLifetime = config.defaultLifetime ?? 1.4;
|
|
26
|
+
const defaultRise = config.defaultRise ?? 1.2;
|
|
27
|
+
const driftJitter = config.driftJitter ?? 0.6;
|
|
28
|
+
const popIn = clamp01(config.popIn ?? 0.16);
|
|
29
|
+
const fadeFraction = clamp01(config.fadeFraction ?? 0.4);
|
|
30
|
+
// Numeric SoA columns.
|
|
31
|
+
const id = new Float64Array(max);
|
|
32
|
+
const ox = new Float32Array(max);
|
|
33
|
+
const oy = new Float32Array(max);
|
|
34
|
+
const oz = new Float32Array(max);
|
|
35
|
+
const age = new Float32Array(max);
|
|
36
|
+
const life = new Float32Array(max);
|
|
37
|
+
const rise = new Float32Array(max);
|
|
38
|
+
const drift = new Float32Array(max);
|
|
39
|
+
const size = new Float32Array(max);
|
|
40
|
+
const bornAt = new Float64Array(max);
|
|
41
|
+
// String columns (strings can't live in a typed array).
|
|
42
|
+
const text = new Array(max).fill("");
|
|
43
|
+
const kind = new Array(max).fill("");
|
|
44
|
+
const color = new Array(max).fill(undefined);
|
|
45
|
+
let live = 0;
|
|
46
|
+
let nextId = 1;
|
|
47
|
+
let seed = randomSeedFrom(typeof config.seed === "number" ? config.seed : hashString(String(config.seed ?? "floating-text")));
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
function notify() {
|
|
50
|
+
for (const listener of listeners)
|
|
51
|
+
listener();
|
|
52
|
+
}
|
|
53
|
+
function rand() {
|
|
54
|
+
const [value, next] = stepRandomSeed(seed);
|
|
55
|
+
seed = next;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/** Index of the oldest live entry (highest age) — the one recycled when the pool is full. */
|
|
59
|
+
function oldestIndex() {
|
|
60
|
+
let best = 0;
|
|
61
|
+
for (let i = 1; i < live; i++)
|
|
62
|
+
if (age[i] > age[best])
|
|
63
|
+
best = i;
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
function writeEntry(i, def) {
|
|
67
|
+
const entryId = nextId++;
|
|
68
|
+
id[i] = entryId;
|
|
69
|
+
ox[i] = def.position[0];
|
|
70
|
+
oy[i] = def.position[1];
|
|
71
|
+
oz[i] = def.position[2];
|
|
72
|
+
age[i] = 0;
|
|
73
|
+
life[i] = Math.max(0.0001, def.lifetime ?? defaultLifetime);
|
|
74
|
+
rise[i] = def.rise ?? defaultRise;
|
|
75
|
+
// A seeded per-entry drift keeps stacked numbers from overlapping; still deterministic.
|
|
76
|
+
drift[i] = def.drift ?? (rand() * 2 - 1) * driftJitter;
|
|
77
|
+
size[i] = def.size ?? 1;
|
|
78
|
+
bornAt[i] = now();
|
|
79
|
+
text[i] = def.text;
|
|
80
|
+
kind[i] = def.kind ?? "default";
|
|
81
|
+
color[i] = def.color;
|
|
82
|
+
return entryId;
|
|
83
|
+
}
|
|
84
|
+
function swapRemove(i) {
|
|
85
|
+
const last = live - 1;
|
|
86
|
+
if (i !== last) {
|
|
87
|
+
id[i] = id[last];
|
|
88
|
+
ox[i] = ox[last];
|
|
89
|
+
oy[i] = oy[last];
|
|
90
|
+
oz[i] = oz[last];
|
|
91
|
+
age[i] = age[last];
|
|
92
|
+
life[i] = life[last];
|
|
93
|
+
rise[i] = rise[last];
|
|
94
|
+
drift[i] = drift[last];
|
|
95
|
+
size[i] = size[last];
|
|
96
|
+
bornAt[i] = bornAt[last];
|
|
97
|
+
text[i] = text[last];
|
|
98
|
+
kind[i] = kind[last];
|
|
99
|
+
color[i] = color[last];
|
|
100
|
+
}
|
|
101
|
+
live--;
|
|
102
|
+
}
|
|
103
|
+
/** Current scale (base times birth pop-in) for entry `i`. */
|
|
104
|
+
function resolveScale(i) {
|
|
105
|
+
const t = clamp01(age[i] / life[i]);
|
|
106
|
+
const pop = popIn > 0 && t < popIn ? 0.6 + 0.4 * (t / popIn) : 1;
|
|
107
|
+
return size[i] * pop;
|
|
108
|
+
}
|
|
109
|
+
/** Current alpha (full until the fade window, then easing to 0) for entry `i`. */
|
|
110
|
+
function resolveAlpha(i) {
|
|
111
|
+
const t = clamp01(age[i] / life[i]);
|
|
112
|
+
if (fadeFraction <= 0)
|
|
113
|
+
return 1;
|
|
114
|
+
const fadeStart = 1 - fadeFraction;
|
|
115
|
+
return t <= fadeStart ? 1 : clamp01((1 - t) / fadeFraction);
|
|
116
|
+
}
|
|
117
|
+
const field = {
|
|
118
|
+
emit(def) {
|
|
119
|
+
const i = live < max ? live++ : oldestIndex();
|
|
120
|
+
const entryId = writeEntry(i, def);
|
|
121
|
+
notify();
|
|
122
|
+
return entryId;
|
|
123
|
+
},
|
|
124
|
+
update(dt) {
|
|
125
|
+
if (dt <= 0)
|
|
126
|
+
return;
|
|
127
|
+
for (let i = live - 1; i >= 0; i--) {
|
|
128
|
+
age[i] += dt;
|
|
129
|
+
if (age[i] >= life[i]) {
|
|
130
|
+
swapRemove(i);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
notify();
|
|
134
|
+
},
|
|
135
|
+
active() {
|
|
136
|
+
const views = [];
|
|
137
|
+
for (let i = 0; i < live; i++) {
|
|
138
|
+
const a = age[i];
|
|
139
|
+
views.push({
|
|
140
|
+
id: id[i],
|
|
141
|
+
position: [ox[i] + drift[i] * a, oy[i] + rise[i] * a, oz[i]],
|
|
142
|
+
text: text[i],
|
|
143
|
+
kind: kind[i],
|
|
144
|
+
color: color[i],
|
|
145
|
+
size: resolveScale(i),
|
|
146
|
+
alpha: resolveAlpha(i),
|
|
147
|
+
progress: clamp01(a / life[i]),
|
|
148
|
+
bornAt: bornAt[i],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return views;
|
|
152
|
+
},
|
|
153
|
+
count() {
|
|
154
|
+
return live;
|
|
155
|
+
},
|
|
156
|
+
clear() {
|
|
157
|
+
live = 0;
|
|
158
|
+
notify();
|
|
159
|
+
},
|
|
160
|
+
subscribe(listener) {
|
|
161
|
+
listeners.add(listener);
|
|
162
|
+
return () => listeners.delete(listener);
|
|
163
|
+
},
|
|
164
|
+
snapshot() {
|
|
165
|
+
const data = [];
|
|
166
|
+
const outText = [];
|
|
167
|
+
const outKind = [];
|
|
168
|
+
const outColor = [];
|
|
169
|
+
for (let i = 0; i < live; i++) {
|
|
170
|
+
data.push(id[i], ox[i], oy[i], oz[i], age[i], life[i], rise[i], drift[i], size[i]);
|
|
171
|
+
outText.push(text[i]);
|
|
172
|
+
outKind.push(kind[i]);
|
|
173
|
+
outColor.push(color[i] ?? "");
|
|
174
|
+
}
|
|
175
|
+
return { seed: seed, nextId, count: live, data, text: outText, kind: outKind, color: outColor };
|
|
176
|
+
},
|
|
177
|
+
restore(snapshot) {
|
|
178
|
+
seed = randomSeedFrom(snapshot.seed);
|
|
179
|
+
nextId = snapshot.nextId;
|
|
180
|
+
live = Math.min(max, snapshot.count);
|
|
181
|
+
for (let i = 0; i < live; i++) {
|
|
182
|
+
const o = i * NUMERIC_STRIDE;
|
|
183
|
+
id[i] = snapshot.data[o];
|
|
184
|
+
ox[i] = snapshot.data[o + 1];
|
|
185
|
+
oy[i] = snapshot.data[o + 2];
|
|
186
|
+
oz[i] = snapshot.data[o + 3];
|
|
187
|
+
age[i] = snapshot.data[o + 4];
|
|
188
|
+
life[i] = snapshot.data[o + 5];
|
|
189
|
+
rise[i] = snapshot.data[o + 6];
|
|
190
|
+
drift[i] = snapshot.data[o + 7];
|
|
191
|
+
size[i] = snapshot.data[o + 8];
|
|
192
|
+
// bornAt is presentational only; it is not round-tripped, so re-stamp on restore.
|
|
193
|
+
bornAt[i] = now();
|
|
194
|
+
text[i] = snapshot.text[i];
|
|
195
|
+
kind[i] = snapshot.kind[i];
|
|
196
|
+
const c = snapshot.color[i];
|
|
197
|
+
color[i] = c === undefined || c === "" ? undefined : c;
|
|
198
|
+
}
|
|
199
|
+
notify();
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
return field;
|
|
203
|
+
}
|