@jjlmoya/utils-games-development 1.63.0 → 1.65.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/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +7 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tests/translation_copy.test.ts +21 -18
- package/src/tool/audioLoopPointFinder/i18n/de.ts +13 -0
- package/src/tool/audioLoopPointFinder/i18n/fr.ts +26 -0
- package/src/tool/audioLoopPointFinder/i18n/id.ts +4 -0
- package/src/tool/audioLoopPointFinder/i18n/it.ts +3 -0
- package/src/tool/audioLoopPointFinder/i18n/nl.ts +4 -0
- package/src/tool/audioLoopPointFinder/i18n/pl.ts +4 -0
- package/src/tool/audioLoopPointFinder/i18n/pt.ts +3 -0
- package/src/tool/audioLoopPointFinder/i18n/ru.ts +4 -0
- package/src/tool/audioLoopPointFinder/i18n/sv.ts +4 -0
- package/src/tool/audioLoopPointFinder/i18n/tr.ts +4 -0
- package/src/tool/audioLoopPointFinder/i18n/zh.ts +4 -0
- package/src/tool/damageFormulaLab/bibliography.astro +24 -0
- package/src/tool/damageFormulaLab/bibliography.ts +10 -0
- package/src/tool/damageFormulaLab/component.astro +178 -0
- package/src/tool/damageFormulaLab/controller.ts +235 -0
- package/src/tool/damageFormulaLab/damage-formula-lab.css +644 -0
- package/src/tool/damageFormulaLab/dom-views.ts +193 -0
- package/src/tool/damageFormulaLab/entry.ts +28 -0
- package/src/tool/damageFormulaLab/evaluator.ts +84 -0
- package/src/tool/damageFormulaLab/expression.ts +191 -0
- package/src/tool/damageFormulaLab/i18n/de.ts +194 -0
- package/src/tool/damageFormulaLab/i18n/en.ts +231 -0
- package/src/tool/damageFormulaLab/i18n/es.ts +181 -0
- package/src/tool/damageFormulaLab/i18n/fr.ts +194 -0
- package/src/tool/damageFormulaLab/i18n/id.ts +194 -0
- package/src/tool/damageFormulaLab/i18n/it.ts +182 -0
- package/src/tool/damageFormulaLab/i18n/ja.ts +183 -0
- package/src/tool/damageFormulaLab/i18n/ko.ts +182 -0
- package/src/tool/damageFormulaLab/i18n/nl.ts +182 -0
- package/src/tool/damageFormulaLab/i18n/pl.ts +182 -0
- package/src/tool/damageFormulaLab/i18n/pt.ts +182 -0
- package/src/tool/damageFormulaLab/i18n/ru.ts +183 -0
- package/src/tool/damageFormulaLab/i18n/sv.ts +183 -0
- package/src/tool/damageFormulaLab/i18n/tr.ts +183 -0
- package/src/tool/damageFormulaLab/i18n/zh.ts +183 -0
- package/src/tool/damageFormulaLab/index.ts +11 -0
- package/src/tool/damageFormulaLab/logic.test.ts +138 -0
- package/src/tool/damageFormulaLab/logic.ts +138 -0
- package/src/tool/damageFormulaLab/model.ts +151 -0
- package/src/tool/damageFormulaLab/seo.astro +13 -0
- package/src/tool/damageFormulaLab/storage.ts +62 -0
- package/src/tool/damageFormulaLab/transfers.ts +89 -0
- package/src/tool/damageFormulaLab/ui.ts +62 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/bibliography.astro +22 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/bibliography.ts +10 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/component.astro +51 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/controller.ts +79 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/dom-views.ts +88 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/entry.ts +27 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/evaluator.ts +21 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/game-delta-time-fixed-timestep-lab.css +491 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/de.ts +56 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/en.ts +180 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/es.ts +56 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/fr.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/id.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/it.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/ja.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/ko.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/nl.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/pl.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/pt.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/ru.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/sv.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/tr.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/i18n/zh.ts +46 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/index.ts +11 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/logic.test.ts +35 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/logic.ts +140 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/seo.astro +15 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/seo_contract.test.ts +25 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/storage.ts +23 -0
- package/src/tool/gameDeltaTimeFixedTimestepLab/ui.ts +54 -0
- package/src/tool/gameUIAccessibilityTester/i18n/de.ts +13 -0
- package/src/tool/gameUIAccessibilityTester/i18n/fr.ts +13 -0
- package/src/tool/gameUIAccessibilityTester/i18n/id.ts +13 -0
- package/src/tool/gameUIAccessibilityTester/i18n/it.ts +1 -0
- package/src/tool/gameUIAccessibilityTester/i18n/nl.ts +5 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pl.ts +5 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pt.ts +2 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ru.ts +1 -0
- package/src/tool/gameUIAccessibilityTester/i18n/sv.ts +1 -0
- package/src/tool/gameUIAccessibilityTester/i18n/tr.ts +2 -0
- package/src/tool/gameUIAccessibilityTester/i18n/zh.ts +1 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/de.ts +14 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/fr.ts +13 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/id.ts +13 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/it.ts +1 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/nl.ts +1 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/pl.ts +2 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/pt.ts +2 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ru.ts +2 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/sv.ts +1 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/tr.ts +2 -0
- package/src/tool/itchioGameTester/i18n/de.ts +13 -0
- package/src/tool/itchioGameTester/i18n/es.ts +2 -2
- package/src/tool/itchioGameTester/i18n/fr.ts +26 -0
- package/src/tool/itchioGameTester/i18n/id.ts +3 -0
- package/src/tool/itchioGameTester/i18n/it.ts +3 -0
- package/src/tool/itchioGameTester/i18n/ja.ts +3 -0
- package/src/tool/itchioGameTester/i18n/ko.ts +3 -0
- package/src/tool/itchioGameTester/i18n/nl.ts +3 -0
- package/src/tool/itchioGameTester/i18n/pl.ts +3 -0
- package/src/tool/itchioGameTester/i18n/pt.ts +3 -0
- package/src/tool/itchioGameTester/i18n/ru.ts +3 -0
- package/src/tool/itchioGameTester/i18n/sv.ts +3 -0
- package/src/tool/itchioGameTester/i18n/tr.ts +3 -0
- package/src/tool/itchioGameTester/i18n/zh.ts +3 -0
- package/src/tool/localizationSanitizer/i18n/de.ts +13 -0
- package/src/tool/localizationSanitizer/i18n/es.ts +26 -0
- package/src/tool/localizationSanitizer/i18n/fr.ts +4 -0
- package/src/tool/localizationSanitizer/i18n/id.ts +4 -0
- package/src/tool/localizationSanitizer/i18n/it.ts +4 -0
- package/src/tool/localizationSanitizer/i18n/ja.ts +4 -0
- package/src/tool/localizationSanitizer/i18n/ko.ts +4 -0
- package/src/tool/localizationSanitizer/i18n/nl.ts +5 -0
- package/src/tool/localizationSanitizer/i18n/pl.ts +5 -0
- package/src/tool/localizationSanitizer/i18n/pt.ts +5 -0
- package/src/tool/localizationSanitizer/i18n/ru.ts +5 -0
- package/src/tool/localizationSanitizer/i18n/sv.ts +5 -0
- package/src/tool/localizationSanitizer/i18n/tr.ts +5 -0
- package/src/tool/localizationSanitizer/i18n/zh.ts +6 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ja.ts +13 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ko.ts +13 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/zh.ts +18 -0
- package/src/tool/retroSfxGenerator/i18n/de.ts +13 -0
- package/src/tool/retroSfxGenerator/i18n/fr.ts +13 -0
- package/src/tool/retroSfxGenerator/i18n/id.ts +13 -0
- package/src/tool/retroSfxGenerator/i18n/it.ts +1 -0
- package/src/tool/retroSfxGenerator/i18n/nl.ts +1 -0
- package/src/tool/retroSfxGenerator/i18n/pl.ts +1 -0
- package/src/tool/retroSfxGenerator/i18n/pt.ts +1 -0
- package/src/tool/retroSfxGenerator/i18n/ru.ts +1 -0
- package/src/tool/retroSfxGenerator/i18n/sv.ts +2 -0
- package/src/tool/retroSfxGenerator/i18n/tr.ts +1 -0
- package/src/tool/retroSfxGenerator/i18n/zh.ts +1 -0
- package/src/tool/spriteSheetPacker/i18n/de.ts +13 -0
- package/src/tool/spriteSheetPacker/i18n/es.ts +2 -2
- package/src/tool/spriteSheetPacker/i18n/fr.ts +26 -0
- package/src/tool/spriteSheetPacker/i18n/id.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/it.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/ja.ts +3 -0
- package/src/tool/spriteSheetPacker/i18n/ko.ts +3 -0
- package/src/tool/spriteSheetPacker/i18n/nl.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/pl.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/pt.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/ru.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/sv.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/tr.ts +4 -0
- package/src/tool/spriteSheetPacker/i18n/zh.ts +4 -0
- package/src/tool/steamBbcodeTranslator/i18n/de.ts +22 -0
- package/src/tool/steamBbcodeTranslator/i18n/es.ts +23 -1
- package/src/tool/steamBbcodeTranslator/i18n/fr.ts +6 -1
- package/src/tool/steamBbcodeTranslator/i18n/id.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/it.ts +6 -1
- package/src/tool/steamBbcodeTranslator/i18n/ja.ts +5 -1
- package/src/tool/steamBbcodeTranslator/i18n/ko.ts +5 -1
- package/src/tool/steamBbcodeTranslator/i18n/nl.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/pl.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/pt.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/ru.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/sv.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/tr.ts +7 -1
- package/src/tool/steamBbcodeTranslator/i18n/zh.ts +5 -1
- package/src/tool/steamCapsuleGenerator/i18n/de.ts +2 -2
- package/src/tool/steamCapsuleGenerator/i18n/es.ts +2 -2
- package/src/tool/steamCapsuleGenerator/i18n/fr.ts +2 -2
- package/src/tool/steamCapsuleGenerator/i18n/pl.ts +13 -0
- package/src/tool/steamCapsuleGenerator/i18n/ru.ts +26 -0
- package/src/tool/steamCapsuleGenerator/i18n/sv.ts +1 -0
- package/src/tool/steamCapsuleGenerator/i18n/tr.ts +1 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CombatSettings,
|
|
3
|
+
FormulaVariables,
|
|
4
|
+
ModifierOrder,
|
|
5
|
+
RoundingMode,
|
|
6
|
+
SweepRange,
|
|
7
|
+
} from "./logic";
|
|
8
|
+
|
|
9
|
+
export interface LabConfig {
|
|
10
|
+
version: 1;
|
|
11
|
+
formulaA: string;
|
|
12
|
+
formulaB: string;
|
|
13
|
+
variables: FormulaVariables;
|
|
14
|
+
settings: CombatSettings;
|
|
15
|
+
attackRange: SweepRange;
|
|
16
|
+
defenseRange: SweepRange;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defaultConfig: LabConfig = {
|
|
20
|
+
version: 1,
|
|
21
|
+
formulaA: "max(1, attack * power - defense)",
|
|
22
|
+
formulaB: "max(1, attack * power * 100 / (100 + defense))",
|
|
23
|
+
variables: {
|
|
24
|
+
attack: 80,
|
|
25
|
+
defense: 35,
|
|
26
|
+
level: 20,
|
|
27
|
+
power: 1.4,
|
|
28
|
+
resistance: 15,
|
|
29
|
+
flat: 3,
|
|
30
|
+
criticalChance: 20,
|
|
31
|
+
criticalMultiplier: 1.75,
|
|
32
|
+
},
|
|
33
|
+
settings: {
|
|
34
|
+
health: 450,
|
|
35
|
+
attacksPerSecond: 1.5,
|
|
36
|
+
rounding: "round",
|
|
37
|
+
modifierOrder: "resistance-first",
|
|
38
|
+
},
|
|
39
|
+
attackRange: { min: 40, max: 160, steps: 17 },
|
|
40
|
+
defenseRange: { min: 10, max: 90, steps: 9 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const formulaPresets = {
|
|
44
|
+
linear: "max(1, attack * power - defense)",
|
|
45
|
+
ratio: "max(1, attack * power * 100 / (100 + defense))",
|
|
46
|
+
level:
|
|
47
|
+
"max(1, ((2 * level + 10) / 250) * (attack / max(1, defense)) * power + 2)",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const scenarios: Record<
|
|
51
|
+
string,
|
|
52
|
+
Partial<FormulaVariables> & { health: number }
|
|
53
|
+
> = {
|
|
54
|
+
skirmisher: { defense: 20, resistance: 5, health: 220 },
|
|
55
|
+
guardian: { defense: 70, resistance: 25, health: 700 },
|
|
56
|
+
boss: { defense: 110, resistance: 35, health: 2200 },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function finite(value: unknown, fallback: number): number {
|
|
60
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rounding(value: unknown): RoundingMode {
|
|
64
|
+
if (value === "none" || value === "floor" || value === "ceil") return value;
|
|
65
|
+
return "round";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function order(value: unknown): ModifierOrder {
|
|
69
|
+
return value === "flat-first" ? "flat-first" : "resistance-first";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function variables(value: unknown): FormulaVariables {
|
|
73
|
+
const source =
|
|
74
|
+
typeof value === "object" && value
|
|
75
|
+
? (value as Partial<FormulaVariables>)
|
|
76
|
+
: {};
|
|
77
|
+
const base = defaultConfig.variables;
|
|
78
|
+
return {
|
|
79
|
+
attack: finite(source.attack, base.attack),
|
|
80
|
+
defense: finite(source.defense, base.defense),
|
|
81
|
+
level: finite(source.level, base.level),
|
|
82
|
+
power: finite(source.power, base.power),
|
|
83
|
+
resistance: finite(source.resistance, base.resistance),
|
|
84
|
+
flat: finite(source.flat, base.flat),
|
|
85
|
+
criticalChance: finite(source.criticalChance, base.criticalChance),
|
|
86
|
+
criticalMultiplier: finite(
|
|
87
|
+
source.criticalMultiplier,
|
|
88
|
+
base.criticalMultiplier,
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function range(value: unknown, fallback: SweepRange): SweepRange {
|
|
94
|
+
const source =
|
|
95
|
+
typeof value === "object" && value ? (value as Partial<SweepRange>) : {};
|
|
96
|
+
return {
|
|
97
|
+
min: finite(source.min, fallback.min),
|
|
98
|
+
max: finite(source.max, fallback.max),
|
|
99
|
+
steps: finite(source.steps, fallback.steps),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function combatSettings(value: unknown): CombatSettings {
|
|
104
|
+
const source =
|
|
105
|
+
typeof value === "object" && value
|
|
106
|
+
? (value as Partial<CombatSettings>)
|
|
107
|
+
: {};
|
|
108
|
+
return {
|
|
109
|
+
health: finite(source.health, defaultConfig.settings.health),
|
|
110
|
+
attacksPerSecond: finite(
|
|
111
|
+
source.attacksPerSecond,
|
|
112
|
+
defaultConfig.settings.attacksPerSecond,
|
|
113
|
+
),
|
|
114
|
+
rounding: rounding(source.rounding),
|
|
115
|
+
modifierOrder: order(source.modifierOrder),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseConfig(value: unknown): LabConfig {
|
|
120
|
+
if (typeof value !== "object" || !value)
|
|
121
|
+
throw new Error("Invalid configuration");
|
|
122
|
+
const source = value as Partial<LabConfig>;
|
|
123
|
+
return {
|
|
124
|
+
version: 1,
|
|
125
|
+
formulaA:
|
|
126
|
+
typeof source.formulaA === "string"
|
|
127
|
+
? source.formulaA
|
|
128
|
+
: defaultConfig.formulaA,
|
|
129
|
+
formulaB:
|
|
130
|
+
typeof source.formulaB === "string"
|
|
131
|
+
? source.formulaB
|
|
132
|
+
: defaultConfig.formulaB,
|
|
133
|
+
variables: variables(source.variables),
|
|
134
|
+
settings: combatSettings(source.settings),
|
|
135
|
+
attackRange: range(source.attackRange, defaultConfig.attackRange),
|
|
136
|
+
defenseRange: range(source.defenseRange, defaultConfig.defenseRange),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function configFromHash(hash: string): LabConfig | undefined {
|
|
141
|
+
try {
|
|
142
|
+
if (!hash.startsWith("#damage=")) return undefined;
|
|
143
|
+
return parseConfig(JSON.parse(decodeURIComponent(hash.slice(8))));
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function configToHash(config: LabConfig): string {
|
|
150
|
+
return `#damage=${encodeURIComponent(JSON.stringify(config))}`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import type { SEOSection } from '../../types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
locale?: string;
|
|
7
|
+
sections?: SEOSection[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en', sections = [] } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<SEORenderer content={{ locale, sections }} />
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ModifierOrder, RoundingMode } from "./logic";
|
|
2
|
+
|
|
3
|
+
export interface LabPreferences {
|
|
4
|
+
formulaA: string;
|
|
5
|
+
formulaB: string;
|
|
6
|
+
rounding: RoundingMode;
|
|
7
|
+
modifierOrder: ModifierOrder;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const STORAGE_KEY = "jjlmoya-damage-formula-lab";
|
|
11
|
+
|
|
12
|
+
export const defaultPreferences: LabPreferences = {
|
|
13
|
+
formulaA: "max(1, attack * power - defense)",
|
|
14
|
+
formulaB: "max(1, attack * power * 100 / (100 + defense))",
|
|
15
|
+
rounding: "round",
|
|
16
|
+
modifierOrder: "resistance-first",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isRounding(value: unknown): value is RoundingMode {
|
|
20
|
+
return (
|
|
21
|
+
value === "none" ||
|
|
22
|
+
value === "floor" ||
|
|
23
|
+
value === "round" ||
|
|
24
|
+
value === "ceil"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isOrder(value: unknown): value is ModifierOrder {
|
|
29
|
+
return value === "resistance-first" || value === "flat-first";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadPreferences(): LabPreferences {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(
|
|
35
|
+
localStorage.getItem(STORAGE_KEY) ?? "",
|
|
36
|
+
) as Partial<LabPreferences>;
|
|
37
|
+
return {
|
|
38
|
+
formulaA:
|
|
39
|
+
typeof parsed.formulaA === "string"
|
|
40
|
+
? parsed.formulaA
|
|
41
|
+
: defaultPreferences.formulaA,
|
|
42
|
+
formulaB:
|
|
43
|
+
typeof parsed.formulaB === "string"
|
|
44
|
+
? parsed.formulaB
|
|
45
|
+
: defaultPreferences.formulaB,
|
|
46
|
+
rounding: isRounding(parsed.rounding)
|
|
47
|
+
? parsed.rounding
|
|
48
|
+
: defaultPreferences.rounding,
|
|
49
|
+
modifierOrder: isOrder(parsed.modifierOrder)
|
|
50
|
+
? parsed.modifierOrder
|
|
51
|
+
: defaultPreferences.modifierOrder,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return { ...defaultPreferences };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function savePreferences(preferences: LabPreferences): void {
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { buildCurve } from "./logic";
|
|
2
|
+
import { configToHash } from "./model";
|
|
3
|
+
import type { LabConfig } from "./model";
|
|
4
|
+
|
|
5
|
+
export function download(name: string, type: string, body: BlobPart): void {
|
|
6
|
+
const link = document.createElement("a");
|
|
7
|
+
link.href = URL.createObjectURL(new Blob([body], { type }));
|
|
8
|
+
link.download = name;
|
|
9
|
+
link.click();
|
|
10
|
+
URL.revokeObjectURL(link.href);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function curveRows(config: LabConfig): Array<Array<number | undefined>> {
|
|
14
|
+
const a = buildCurve(
|
|
15
|
+
config.formulaA,
|
|
16
|
+
config.variables,
|
|
17
|
+
config.settings,
|
|
18
|
+
config.attackRange,
|
|
19
|
+
);
|
|
20
|
+
const b = buildCurve(
|
|
21
|
+
config.formulaB,
|
|
22
|
+
config.variables,
|
|
23
|
+
config.settings,
|
|
24
|
+
config.attackRange,
|
|
25
|
+
);
|
|
26
|
+
return a.map((point, index) => [
|
|
27
|
+
point.attack,
|
|
28
|
+
point.defense,
|
|
29
|
+
point.roundedDamage,
|
|
30
|
+
point.hitsToKill,
|
|
31
|
+
point.timeToKill,
|
|
32
|
+
b[index]?.roundedDamage,
|
|
33
|
+
b[index]?.hitsToKill,
|
|
34
|
+
b[index]?.timeToKill,
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function exportCsv(config: LabConfig): void {
|
|
39
|
+
const header = [
|
|
40
|
+
"attack",
|
|
41
|
+
"defense",
|
|
42
|
+
"formula_a_damage",
|
|
43
|
+
"formula_a_hits",
|
|
44
|
+
"formula_a_ttk",
|
|
45
|
+
"formula_b_damage",
|
|
46
|
+
"formula_b_hits",
|
|
47
|
+
"formula_b_ttk",
|
|
48
|
+
];
|
|
49
|
+
const body = [header, ...curveRows(config)]
|
|
50
|
+
.map((row) => row.join(","))
|
|
51
|
+
.join("\n");
|
|
52
|
+
download("damage-formula-sweep.csv", "text/csv", body);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function exportJson(config: LabConfig): void {
|
|
56
|
+
download(
|
|
57
|
+
"damage-formula-lab.json",
|
|
58
|
+
"application/json",
|
|
59
|
+
JSON.stringify(config, null, 2),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function drawPng(image: HTMLImageElement): void {
|
|
64
|
+
const canvas = document.createElement("canvas");
|
|
65
|
+
canvas.width = 1200;
|
|
66
|
+
canvas.height = 700;
|
|
67
|
+
const context = canvas.getContext("2d");
|
|
68
|
+
if (!context) return;
|
|
69
|
+
context.fillStyle = "#f7f4ee";
|
|
70
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
71
|
+
context.drawImage(image, 70, 70, 1060, 560);
|
|
72
|
+
canvas.toBlob((blob) => {
|
|
73
|
+
if (blob) download("damage-formula-curve.png", "image/png", blob);
|
|
74
|
+
}, "image/png");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function exportChartPng(root: HTMLElement): void {
|
|
78
|
+
const svg = root.querySelector<SVGSVGElement>("[data-chart]");
|
|
79
|
+
if (!svg) return;
|
|
80
|
+
const source = new XMLSerializer().serializeToString(svg);
|
|
81
|
+
const image = new Image();
|
|
82
|
+
image.onload = () => drawPng(image);
|
|
83
|
+
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function copyShareLink(config: LabConfig): Promise<void> {
|
|
87
|
+
const url = `${location.origin}${location.pathname}${configToHash(config)}`;
|
|
88
|
+
await navigator.clipboard.writeText(url);
|
|
89
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface DamageFormulaLabUI extends Record<string, string> {
|
|
2
|
+
onboarding: string;
|
|
3
|
+
localNote: string;
|
|
4
|
+
formulaDeck: string;
|
|
5
|
+
formulaALabel: string;
|
|
6
|
+
formulaBLabel: string;
|
|
7
|
+
formulaHint: string;
|
|
8
|
+
formulaFunctions: string;
|
|
9
|
+
presetLinear: string;
|
|
10
|
+
presetRatio: string;
|
|
11
|
+
presetLevel: string;
|
|
12
|
+
combatInputs: string;
|
|
13
|
+
attackLabel: string;
|
|
14
|
+
defenseLabel: string;
|
|
15
|
+
levelLabel: string;
|
|
16
|
+
powerLabel: string;
|
|
17
|
+
resistanceLabel: string;
|
|
18
|
+
flatLabel: string;
|
|
19
|
+
criticalChanceLabel: string;
|
|
20
|
+
criticalMultiplierLabel: string;
|
|
21
|
+
healthLabel: string;
|
|
22
|
+
cadenceLabel: string;
|
|
23
|
+
roundingLabel: string;
|
|
24
|
+
roundingNone: string;
|
|
25
|
+
roundingFloor: string;
|
|
26
|
+
roundingRound: string;
|
|
27
|
+
roundingCeil: string;
|
|
28
|
+
orderLabel: string;
|
|
29
|
+
resistanceFirst: string;
|
|
30
|
+
flatFirst: string;
|
|
31
|
+
runLabel: string;
|
|
32
|
+
resultDamage: string;
|
|
33
|
+
resultHits: string;
|
|
34
|
+
resultTtk: string;
|
|
35
|
+
resultDifference: string;
|
|
36
|
+
formulaAName: string;
|
|
37
|
+
formulaBName: string;
|
|
38
|
+
curveTitle: string;
|
|
39
|
+
curveCaption: string;
|
|
40
|
+
heatmapTitle: string;
|
|
41
|
+
heatmapCaption: string;
|
|
42
|
+
attackAxis: string;
|
|
43
|
+
defenseAxis: string;
|
|
44
|
+
scenariosTitle: string;
|
|
45
|
+
scenarioSkirmisher: string;
|
|
46
|
+
scenarioGuardian: string;
|
|
47
|
+
scenarioBoss: string;
|
|
48
|
+
scenarioCustom: string;
|
|
49
|
+
diagnosticsTitle: string;
|
|
50
|
+
statusBalanced: string;
|
|
51
|
+
exportTitle: string;
|
|
52
|
+
copyLink: string;
|
|
53
|
+
exportCsv: string;
|
|
54
|
+
exportJson: string;
|
|
55
|
+
importJson: string;
|
|
56
|
+
exportPng: string;
|
|
57
|
+
reset: string;
|
|
58
|
+
privacyDisclosure: string;
|
|
59
|
+
limitationDisclosure: string;
|
|
60
|
+
importError: string;
|
|
61
|
+
copiedStatus: string;
|
|
62
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { BibliographyEntry } from '../../types';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
title?: string;
|
|
6
|
+
entries?: BibliographyEntry[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { title = 'References', entries = [] } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{entries.length > 0 && <section class="gll-references" aria-labelledby="gll-reference-title"><h2 id="gll-reference-title">{title}</h2><ul>{entries.map((entry) => <li><a href={entry.url} rel="noopener noreferrer" target="_blank">{entry.name}</a></li>)}</ul></section>}
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
.gll-references {
|
|
16
|
+
margin-block: 2rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.gll-references a {
|
|
20
|
+
color: var(--n-primary);
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const bibliographyEntries = [
|
|
2
|
+
{
|
|
3
|
+
name: 'Fix Your Timestep! by Glenn Fiedler',
|
|
4
|
+
url: 'https://gafferongames.com/post/fix_your_timestep/',
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: 'Game Loop from Game Programming Patterns',
|
|
8
|
+
url: 'https://gameprogrammingpatterns.com/game-loop.html',
|
|
9
|
+
},
|
|
10
|
+
];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { GameDeltaTimeFixedTimestepLabUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: GameDeltaTimeFixedTimestepLabUI;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props as Props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<div class="game-loop-lab" data-game-loop-lab>
|
|
12
|
+
<div class="gll-shell">
|
|
13
|
+
<form class="gll-controls" data-controls>
|
|
14
|
+
<div class="gll-control-heading"><p class="gll-kicker">{ui.controlsTitle}</p></div>
|
|
15
|
+
<label class="gll-range-field"><span>{ui.fpsLabel}</span><output data-output="fps">60</output><input data-field="fps" type="range" min="1" max="240" step="1" value="60" /></label>
|
|
16
|
+
<p class="gll-field-hint">{ui.fpsHint}</p>
|
|
17
|
+
<label class="gll-range-field"><span>{ui.spikeLabel} ({ui.unitMs})</span><output data-output="spikeMs">80</output><input data-field="spikeMs" type="range" min="0" max="250" step="1" value="80" /></label>
|
|
18
|
+
<p class="gll-field-hint">{ui.spikeHint}</p>
|
|
19
|
+
<label class="gll-number-field"><span>{ui.spikeEveryLabel}</span><input data-field="spikeEveryFrames" type="number" min="1" max="600" step="1" value="30" /></label>
|
|
20
|
+
<label class="gll-range-field"><span>{ui.fixedDtLabel} ({ui.unitMs})</span><output data-output="fixedDtMs">16.666</output><input data-field="fixedDtMs" type="range" min="1" max="50" step="0.001" value="16.666" /></label>
|
|
21
|
+
<p class="gll-field-hint">{ui.fixedDtHint}</p>
|
|
22
|
+
<label class="gll-range-field"><span>{ui.velocityLabel}</span><output data-output="velocity">20</output><input data-field="velocity" type="range" min="0" max="100" step="1" value="20" /></label>
|
|
23
|
+
<label class="gll-range-field"><span>{ui.durationLabel}</span><output data-output="durationSeconds">6</output><input data-field="durationSeconds" type="range" min="1" max="20" step="1" value="6" /></label>
|
|
24
|
+
<div class="gll-clamp" data-clamp-detail>
|
|
25
|
+
<label class="gll-check-field"><input data-field="clampEnabled" type="checkbox" /><span>{ui.clampToggle}</span></label>
|
|
26
|
+
<label class="gll-range-field"><span>{ui.clampLabel} ({ui.unitMs})</span><output data-output="clampMs">100</output><input data-field="clampMs" type="range" min="1" max="250" step="1" value="100" /></label>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="gll-actions"><button type="button" class="gll-primary" data-run>{ui.runLabel}</button><button type="button" class="gll-secondary" data-reset>{ui.resetLabel}</button></div>
|
|
29
|
+
</form>
|
|
30
|
+
<section class="gll-stage" aria-labelledby="gll-stage-title">
|
|
31
|
+
<div class="gll-stage-heading"><div><p class="gll-kicker">{ui.stageKicker}</p><h2 id="gll-stage-title">{ui.stageTitle}</h2><p>{ui.stageCaption}</p></div><span class="gll-live-badge" data-live aria-live="polite">{ui.statusReady}</span></div>
|
|
32
|
+
<div class="gll-stage-visual" data-stage></div>
|
|
33
|
+
<div data-summary></div>
|
|
34
|
+
<div class="gll-timeline-heading"><h3>{ui.timelineTitle}</h3><p>{ui.timelineCaption}</p></div>
|
|
35
|
+
<div data-chart></div>
|
|
36
|
+
<div class="gll-legend"><span><i class="gll-legend-dot gll-variable-dot"></i>{ui.legendVariable}</span><span><i class="gll-legend-dot gll-fixed-dot"></i>{ui.legendFixed}</span><span><i class="gll-legend-dot gll-spike-dot"></i>{ui.legendSpike}</span></div>
|
|
37
|
+
<div class="gll-table" data-table></div>
|
|
38
|
+
<p class="gll-model-note">{ui.modelNote}</p>
|
|
39
|
+
<p class="gll-privacy">{ui.privacyDisclosure}</p>
|
|
40
|
+
</section>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<script is:inline type="application/json" data-game-loop-ui set:html={JSON.stringify(ui)}></script>
|
|
45
|
+
|
|
46
|
+
<script>
|
|
47
|
+
import { mountGameDeltaTimeFixedTimestepLab } from './controller';
|
|
48
|
+
const root = document.querySelector<HTMLElement>('[data-game-loop-lab]');
|
|
49
|
+
const uiScript = document.querySelector<HTMLScriptElement>('[data-game-loop-ui]');
|
|
50
|
+
if (root && uiScript) mountGameDeltaTimeFixedTimestepLab(root, JSON.parse(uiScript.textContent ?? '{}'));
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { evaluateLoop } from './evaluator';
|
|
2
|
+
import { DEFAULT_LOOP_CONFIG, normalizeLoopConfig, simulateLoop, type LoopConfig } from './logic';
|
|
3
|
+
import { renderAccessibleTable, renderChart, renderStage, renderSummary } from './dom-views';
|
|
4
|
+
import { loadLoopConfig, saveLoopConfig } from './storage';
|
|
5
|
+
import type { GameDeltaTimeFixedTimestepLabUI } from './ui';
|
|
6
|
+
|
|
7
|
+
const fieldNames: (keyof LoopConfig)[] = ['fps', 'spikeMs', 'spikeEveryFrames', 'fixedDtMs', 'velocity', 'durationSeconds', 'clampMs'];
|
|
8
|
+
|
|
9
|
+
function input(root: HTMLElement, name: string): HTMLInputElement {
|
|
10
|
+
return root.querySelector<HTMLInputElement>(`[data-field="${name}"]`) as HTMLInputElement;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readConfig(root: HTMLElement): LoopConfig {
|
|
14
|
+
const values = Object.fromEntries(fieldNames.map((name) => [name, Number(input(root, name).value)]));
|
|
15
|
+
const clampEnabled = root.querySelector<HTMLInputElement>('[data-field="clampEnabled"]')?.checked ?? false;
|
|
16
|
+
return normalizeLoopConfig({ ...values, clampEnabled });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function syncOutputs(root: HTMLElement): void {
|
|
20
|
+
fieldNames.forEach((name) => {
|
|
21
|
+
const field = input(root, name);
|
|
22
|
+
const output = root.querySelector<HTMLOutputElement>(`[data-output="${name}"]`);
|
|
23
|
+
if (output) output.value = field.value;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function syncClamp(root: HTMLElement): void {
|
|
28
|
+
const enabled = root.querySelector<HTMLInputElement>('[data-field="clampEnabled"]')?.checked ?? false;
|
|
29
|
+
input(root, 'clampMs').disabled = !enabled;
|
|
30
|
+
root.querySelector<HTMLElement>('[data-clamp-detail]')?.toggleAttribute('data-disabled', !enabled);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function render(root: HTMLElement, ui: GameDeltaTimeFixedTimestepLabUI, config: LoopConfig): void {
|
|
34
|
+
const result = simulateLoop(config);
|
|
35
|
+
const diagnostic = evaluateLoop(result);
|
|
36
|
+
const stage = root.querySelector<HTMLElement>('[data-stage]');
|
|
37
|
+
const summary = root.querySelector<HTMLElement>('[data-summary]');
|
|
38
|
+
const chart = root.querySelector<HTMLElement>('[data-chart]');
|
|
39
|
+
const table = root.querySelector<HTMLElement>('[data-table]');
|
|
40
|
+
if (stage) renderStage(stage, result, ui);
|
|
41
|
+
if (summary) renderSummary(summary, result, diagnostic, ui);
|
|
42
|
+
if (chart) renderChart(chart, result, ui);
|
|
43
|
+
if (table) renderAccessibleTable(table, result, ui);
|
|
44
|
+
root.querySelector<HTMLElement>('[data-live]')!.textContent = ui.statusReady;
|
|
45
|
+
saveLoopConfig(config);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function bindInputs(root: HTMLElement, ui: GameDeltaTimeFixedTimestepLabUI): void {
|
|
49
|
+
fieldNames.forEach((name) => input(root, name).addEventListener('input', () => {
|
|
50
|
+
syncOutputs(root);
|
|
51
|
+
render(root, ui, readConfig(root));
|
|
52
|
+
}));
|
|
53
|
+
root.querySelector<HTMLInputElement>('[data-field="clampEnabled"]')?.addEventListener('change', () => {
|
|
54
|
+
syncClamp(root);
|
|
55
|
+
render(root, ui, readConfig(root));
|
|
56
|
+
});
|
|
57
|
+
root.querySelector<HTMLButtonElement>('[data-run]')?.addEventListener('click', () => {
|
|
58
|
+
render(root, ui, readConfig(root));
|
|
59
|
+
});
|
|
60
|
+
root.querySelector<HTMLButtonElement>('[data-reset]')?.addEventListener('click', () => {
|
|
61
|
+
fieldNames.forEach((name) => { input(root, name).value = String(DEFAULT_LOOP_CONFIG[name]); });
|
|
62
|
+
const checkbox = root.querySelector<HTMLInputElement>('[data-field="clampEnabled"]');
|
|
63
|
+
if (checkbox) checkbox.checked = DEFAULT_LOOP_CONFIG.clampEnabled;
|
|
64
|
+
syncOutputs(root);
|
|
65
|
+
syncClamp(root);
|
|
66
|
+
render(root, ui, readConfig(root));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function mountGameDeltaTimeFixedTimestepLab(root: HTMLElement, ui: GameDeltaTimeFixedTimestepLabUI): void {
|
|
71
|
+
const stored = normalizeLoopConfig(loadLoopConfig());
|
|
72
|
+
fieldNames.forEach((name) => { input(root, name).value = String(stored[name]); });
|
|
73
|
+
const checkbox = root.querySelector<HTMLInputElement>('[data-field="clampEnabled"]');
|
|
74
|
+
if (checkbox) checkbox.checked = stored.clampEnabled;
|
|
75
|
+
syncOutputs(root);
|
|
76
|
+
syncClamp(root);
|
|
77
|
+
bindInputs(root, ui);
|
|
78
|
+
render(root, ui, stored);
|
|
79
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { GameDeltaTimeFixedTimestepLabUI } from './ui';
|
|
2
|
+
import type { LoopResult, LoopSample } from './logic';
|
|
3
|
+
import type { Diagnostic } from './evaluator';
|
|
4
|
+
|
|
5
|
+
function escapeHtml(value: string): string {
|
|
6
|
+
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function number(value: number, digits = 2): string {
|
|
10
|
+
return value.toLocaleString('en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function samplePoints(samples: LoopSample[]): LoopSample[] {
|
|
14
|
+
const stride = Math.max(1, Math.ceil(samples.length / 80));
|
|
15
|
+
return samples.filter((_, index) => index % stride === 0 || index === samples.length - 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PathOptions {
|
|
19
|
+
samples: LoopSample[];
|
|
20
|
+
key: 'variablePosition' | 'fixedPosition';
|
|
21
|
+
max: number;
|
|
22
|
+
top?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pathFor(options: PathOptions): string {
|
|
27
|
+
const { samples, key, max, top = 190, height = 140 } = options;
|
|
28
|
+
return samples.map((sample, index) => {
|
|
29
|
+
const x = 54 + (index / Math.max(1, samples.length - 1)) * 672;
|
|
30
|
+
const y = top - (sample[key] / max) * height;
|
|
31
|
+
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
32
|
+
}).join(' ');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function differencePath(samples: LoopSample[], max: number): string {
|
|
36
|
+
return samples.map((sample, index) => {
|
|
37
|
+
const x = 54 + (index / Math.max(1, samples.length - 1)) * 672;
|
|
38
|
+
const difference = sample.variablePosition - sample.fixedPosition;
|
|
39
|
+
const y = 247 - ((difference + max) / (max * 2)) * 54;
|
|
40
|
+
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
41
|
+
}).join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function frameBars(samples: LoopSample[], baseMs: number, maxMs: number): string {
|
|
45
|
+
return samples.map((sample, index) => {
|
|
46
|
+
const x = 54 + (index / Math.max(1, samples.length - 1)) * 672;
|
|
47
|
+
const height = (sample.frameMs / maxMs) * 30;
|
|
48
|
+
const isSpike = sample.frameMs > baseMs * 1.25;
|
|
49
|
+
return `<rect class="${isSpike ? 'gll-spike-bar' : 'gll-frame-bar'}" x="${(x - 3).toFixed(1)}" y="${(76 - height).toFixed(1)}" width="6" height="${height.toFixed(1)}" rx="2" />`;
|
|
50
|
+
}).join('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderStage(target: HTMLElement, result: LoopResult, ui: GameDeltaTimeFixedTimestepLabUI): void {
|
|
54
|
+
const points = samplePoints(result.samples);
|
|
55
|
+
const maxPosition = Math.max(1, result.variablePosition, result.fixedPosition);
|
|
56
|
+
const maxDifference = Math.max(1, ...points.map((sample) => Math.abs(sample.variablePosition - sample.fixedPosition)));
|
|
57
|
+
const baseMs = 1000 / result.config.fps;
|
|
58
|
+
const maxMs = Math.max(baseMs, ...points.map((sample) => sample.frameMs));
|
|
59
|
+
const bars = frameBars(points, baseMs, maxMs);
|
|
60
|
+
const variablePath = pathFor({ samples: points, key: 'variablePosition', max: maxPosition, top: 170, height: 60 });
|
|
61
|
+
const fixedPath = pathFor({ samples: points, key: 'fixedPosition', max: maxPosition, top: 170, height: 60 });
|
|
62
|
+
const deltaPath = differencePath(points, maxDifference);
|
|
63
|
+
const variableY = 170 - (result.variablePosition / maxPosition) * 60;
|
|
64
|
+
const fixedY = 170 - (result.fixedPosition / maxPosition) * 60;
|
|
65
|
+
target.innerHTML = `<svg class="gll-stage-svg" viewBox="0 0 760 330" role="img" aria-label="${escapeHtml(ui.stageTitle)}"><rect class="gll-paper" x="0" y="0" width="760" height="330" rx="18" /><text class="gll-plot-title" x="24" y="28">${escapeHtml(ui.frameTraceLabel)}</text><line class="gll-base-line" x1="54" y1="76" x2="726" y2="76" />${bars}<text class="gll-plot-axis" x="726" y="94" text-anchor="end">${number(maxMs, 1)} ${escapeHtml(ui.unitMs)}</text><text class="gll-plot-title" x="24" y="112">${escapeHtml(ui.positionPlotLabel)}</text><line class="gll-grid-line" x1="54" y1="170" x2="726" y2="170" /><text class="gll-plot-label" x="24" y="140">${escapeHtml(ui.variableLane)}</text><text class="gll-plot-label" x="24" y="165">${escapeHtml(ui.fixedLane)}</text><path class="gll-variable-line gll-stage-position-line" d="${variablePath}" /><path class="gll-fixed-line gll-stage-position-line" d="${fixedPath}" /><circle class="gll-variable-token" cx="726" cy="${variableY.toFixed(1)}" r="7" /><circle class="gll-fixed-token" cx="726" cy="${fixedY.toFixed(1)}" r="7" /><text class="gll-plot-axis" x="726" y="184" text-anchor="end">${number(maxPosition)} ${escapeHtml(ui.unitPixels)}</text><text class="gll-plot-title" x="24" y="210">${escapeHtml(ui.differencePlotLabel)}</text><line class="gll-zero-line" x1="54" y1="247" x2="726" y2="247" />${deltaPath ? `<path class="gll-difference-line" d="${deltaPath}" />` : ''}<text class="gll-plot-axis" x="726" y="268" text-anchor="end">+/- ${number(maxDifference)} ${escapeHtml(ui.unitPixels)}</text><text class="gll-stage-axis" x="54" y="302">0 ${escapeHtml(ui.unitSeconds)}</text><text class="gll-stage-axis" x="726" y="302" text-anchor="end">${number(result.totalWallTime)} ${escapeHtml(ui.unitSeconds)}</text></svg>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderChart(target: HTMLElement, result: LoopResult, ui: GameDeltaTimeFixedTimestepLabUI): void {
|
|
69
|
+
const points = samplePoints(result.samples);
|
|
70
|
+
const max = Math.max(1, ...points.map((sample) => Math.max(sample.variablePosition, sample.fixedPosition)));
|
|
71
|
+
const variablePath = pathFor({ samples: points, key: 'variablePosition', max });
|
|
72
|
+
const fixedPath = pathFor({ samples: points, key: 'fixedPosition', max });
|
|
73
|
+
target.innerHTML = `<svg class="gll-chart-svg" viewBox="0 0 760 250" role="img" aria-label="${escapeHtml(ui.timelineTitle)}"><line class="gll-chart-axis" x1="54" y1="190" x2="726" y2="190" /><line class="gll-chart-axis" x1="54" y1="32" x2="54" y2="190" /><path class="gll-variable-line" d="${variablePath}" /><path class="gll-fixed-line" d="${fixedPath}" /><text class="gll-chart-label" x="54" y="220">0 ${escapeHtml(ui.unitSeconds)}</text><text class="gll-chart-label" x="726" y="220" text-anchor="end">${number(result.totalWallTime)} ${escapeHtml(ui.unitSeconds)}</text><text class="gll-chart-label" x="42" y="38" text-anchor="end">${number(max)} ${escapeHtml(ui.unitPixels)}</text><text class="gll-chart-label" x="390" y="244" text-anchor="middle">${escapeHtml(ui.frameAxis)}</text><text class="gll-chart-label" x="14" y="110" transform="rotate(-90 14 110)" text-anchor="middle">${escapeHtml(ui.positionAxis)}</text></svg>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function diagnosticText(diagnostic: Diagnostic, ui: GameDeltaTimeFixedTimestepLabUI): string {
|
|
77
|
+
const labels = { stable: ui.stableStatus, variable: ui.variableStatus, fixed: ui.fixedStatus, clamped: ui.clampStatus };
|
|
78
|
+
return labels[diagnostic.key];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderSummary(target: HTMLElement, result: LoopResult, diagnostic: Diagnostic, ui: GameDeltaTimeFixedTimestepLabUI): void {
|
|
82
|
+
target.innerHTML = `<div class="gll-summary-grid"><div><span>${escapeHtml(ui.frameCountLabel)}</span><strong>${result.frameCount.toLocaleString('en-US')}</strong></div><div><span>${escapeHtml(ui.wallTimeLabel)}</span><strong>${number(result.totalWallTime)} ${escapeHtml(ui.unitSeconds)}</strong></div><div><span>${escapeHtml(ui.variableTimeLabel)}</span><strong>${number(result.variableTime)} ${escapeHtml(ui.unitSeconds)}</strong></div><div><span>${escapeHtml(ui.fixedTimeLabel)}</span><strong>${number(result.fixedTime)} ${escapeHtml(ui.unitSeconds)}</strong></div><div><span>${escapeHtml(ui.divergenceLabel)}</span><strong>${number(result.divergence)} ${escapeHtml(ui.unitPixels)}</strong></div><div><span>${escapeHtml(ui.stepsLabel)}</span><strong>${result.fixedSteps.toLocaleString('en-US')}</strong></div></div><p class="gll-diagnostic gll-diagnostic-${diagnostic.severity}">${escapeHtml(ui.diagnosticsTitle)}: ${escapeHtml(diagnosticText(diagnostic, ui))}</p>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderAccessibleTable(target: HTMLElement, result: LoopResult, ui: GameDeltaTimeFixedTimestepLabUI): void {
|
|
86
|
+
const rows = samplePoints(result.samples).slice(-8).map((sample) => `<tr><td>${sample.frame}</td><td>${number(sample.wallTime)} ${escapeHtml(ui.unitSeconds)}</td><td>${number(sample.variablePosition)} ${escapeHtml(ui.unitPixels)}</td><td>${number(sample.fixedPosition)} ${escapeHtml(ui.unitPixels)}</td><td>${number(sample.variablePosition - sample.fixedPosition)} ${escapeHtml(ui.unitPixels)}</td></tr>`).join('');
|
|
87
|
+
target.innerHTML = `<h3>${escapeHtml(ui.tableTitle)}</h3><table><thead><tr><th>${escapeHtml(ui.tableFrame)}</th><th>${escapeHtml(ui.tableWall)}</th><th>${escapeHtml(ui.tableVariable)}</th><th>${escapeHtml(ui.tableFixed)}</th><th>${escapeHtml(ui.tableDelta)}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GamesToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { GameDeltaTimeFixedTimestepLabUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { GameDeltaTimeFixedTimestepLabUI };
|
|
5
|
+
export type GameDeltaTimeFixedTimestepLabLocaleContent = ToolLocaleContent<GameDeltaTimeFixedTimestepLabUI>;
|
|
6
|
+
|
|
7
|
+
export const gameDeltaTimeFixedTimestepLab: GamesToolEntry<GameDeltaTimeFixedTimestepLabUI> = {
|
|
8
|
+
id: 'game-delta-time-fixed-timestep-lab',
|
|
9
|
+
icons: { bg: 'mdi:timer-sand', fg: 'mdi:gamepad-variant' },
|
|
10
|
+
i18n: {
|
|
11
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
12
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
13
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
15
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
16
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
26
|
+
},
|
|
27
|
+
};
|