Skip to content

Store animated sprites as dynamic resources#3408

Draft
DevelopingTom wants to merge 2 commits intoopenfrontio:mainfrom
DevelopingTom:resources
Draft

Store animated sprites as dynamic resources#3408
DevelopingTom wants to merge 2 commits intoopenfrontio:mainfrom
DevelopingTom:resources

Conversation

@DevelopingTom
Copy link
Contributor

@DevelopingTom DevelopingTom commented Mar 11, 2026

Description:

Animated sprites are currently hardcoded, which prevents them from being loaded dynamically.

Changes:

  • move sprite configurations from hardcoded values to a dedicated JSON file
  • load sprites dynamically based on the JSON configuration

The end-goal is to have per-player cosmetics, but this change should not have any visual impacts.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

IngloriousTom

@DevelopingTom DevelopingTom self-assigned this Mar 11, 2026
@DevelopingTom DevelopingTom added the Refactor Code cleanup, technical debt, refactoring, and architecture improvements. label Mar 11, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 11, 2026

Walkthrough

Adds a JSON animated-sprite config and refactors AnimatedSprite to accept an AnimatedSpriteConfig. AnimatedSpriteLoader validates the JSON (zod), loads images from /animatedSprites/{name}.png, creates/caches AnimatedSpriteCanvas objects, and provides colorized AnimatedSprite instances built from cached canvases.

Changes

Cohort / File(s) Summary
Configuration File
resources/animatedSprites/config.json
New JSON file with 12 sprite definitions (name, frameCount, frameDuration, looping, originX, originY).
Sprite Class Refactoring
src/client/graphics/AnimatedSprite.ts
Added exported AnimatedSpriteConfig interface; constructor now accepts image: HTMLCanvasElement and config: AnimatedSpriteConfig. Internal logic updated to use this.config.* for frame/timing/origin/looping; removed prior setOrigin method.
Sprite Loader Refactoring
src/client/graphics/AnimatedSpriteLoader.ts
Replaced hard-coded config with JSON + zod validation. Loader fetches /animatedSprites/{name}.png, builds AnimatedSpriteCanvas instances, caches them keyed by sprite name (string), and provides helpers to create regular and colorized AnimatedSprite wrappers. Adjusted internal method signatures and cache types.

Sequence Diagram(s)

sequenceDiagram
  participant UI as UI/Client
  participant Loader as AnimatedSpriteLoader
  participant Server as File Server
  participant Factory as CanvasFactory
  participant Sprite as AnimatedSprite

  UI->>Loader: loadAllAnimatedSpriteImages()
  Loader->>Server: GET /animatedSprites/config.json
  Server-->>Loader: config.json
  Loader->>Loader: validate config (zod)
  loop for each sprite
    Loader->>Server: GET /animatedSprites/{name}.png
    Server-->>Loader: image data
    Loader->>Factory: createCanvas(image data)
    Factory-->>Loader: AnimatedSpriteCanvas
    Loader->>Loader: cache AnimatedSpriteCanvas by name
  end
  UI->>Loader: request colored sprite (owner, name, theme)
  Loader->>Loader: get cached AnimatedSpriteCanvas
  Loader->>Factory: createColoredAnimatedSpriteCanvas(canvas, theme)
  Factory-->>Loader: colorized AnimatedSpriteCanvas
  Loader->>Sprite: wrap canvas into AnimatedSprite(config)
  Sprite-->>UI: AnimatedSprite instance
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

✨ JSON sprites lined up in rows,
Loader fetches where the image goes,
Zod checks each field with care,
Canvases painted bright and fair,
New configs dance — a lighter show. 🎨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: moving animated sprite configurations from hardcoded values to dynamic JSON resources that can be loaded at runtime.
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation (hardcoded sprites prevent dynamic loading), listing the specific changes made, and describing the intended outcome.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/client/graphics/AnimatedSpriteLoader.ts (1)

68-71: Type mismatch: FxType enum vs string map keys.

The method signature takes FxType, but animatedSpriteImageMap keys are config.name strings from the JSON. This works because FxType values are string literals, but it creates a hidden coupling: if FxType.MiniBigSmoke = "MiniBigSmoke" but config.json has "BigSmoke", the lookup fails silently.

Consider adding a type assertion or explicit mapping to make this coupling visible and safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/AnimatedSpriteLoader.ts` around lines 68 - 71, The lookup
in createRegularAnimatedSprite uses FxType to index animatedSpriteImageMap which
actually uses config.name string keys, creating a fragile hidden coupling;
update createRegularAnimatedSprite to explicitly map or coerce FxType to the
expected config name (e.g., use a mapping object or a conversion function)
before calling animatedSpriteImageMap.get, validate the mapped key exists and
handle/memoize missing keys deterministically (returning null or fallback), and
add a comment documenting the mapping between FxType values and config.name keys
to make the dependency explicit; refer to the createRegularAnimatedSprite
method, the animatedSpriteImageMap collection, and the FxType enum when
implementing the mapping.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@resources/animatedSprites/config.json`:
- Around line 19-26: The sprite config uses "BigSmoke" but the FxType enum and
NukeFx lookup expect "MiniBigSmoke", so rename the "name" field in the JSON
object from "BigSmoke" to "MiniBigSmoke" in
resources/animatedSprites/config.json so the loader can find the sprite
referenced by FxType::MiniBigSmoke and the NukeFx (referenced in NukeFx.ts) will
render correctly.

In `@src/client/graphics/AnimatedSprite.ts`:
- Around line 87-90: The setOrigin method mutates the shared this.config
(originX/originY) which is supplied by AnimatedSpriteLoader via
animatedSpriteImageMap; change to store origin on the instance instead: add
instance fields (e.g. this.originX, this.originY) initialized from this.config
in the AnimatedSprite constructor, update setOrigin to assign these instance
fields (not this.config), and update any internal uses that read
this.config.originX/ originY to use the instance fields so changing one sprite’s
origin won’t affect others.

In `@src/client/graphics/AnimatedSpriteLoader.ts`:
- Around line 33-55: In loadAllAnimatedSpriteImages, stop using result.data!
when safeParse can fail: after calling
AnimatedSpriteConfigsSchema.safeParse(animationConfig) check result.success and
if false either throw the validation error (replacing safeParse with parse) or
return early after logging the error so you don't proceed to
result.data!.animatedSprites; update the function (references:
AnimatedSpriteConfigsSchema.safeParse / .parse, loadAllAnimatedSpriteImages,
result.data!.animatedSprites, createCanvas) to ensure you only run Promise.all
when validation succeeded.
- Around line 73-95: The cache key in createColoredAnimatedSpriteForUnit omits
the theme, so coloredAnimatedSpriteCache can return sprites with wrong colors
when theme varies; change the key construction (the local key variable used in
coloredAnimatedSpriteCache.get/set) to include a unique theme identifier (for
example theme.name, theme.id, or theme.spawnHighlightColor()) so the lookup and
subsequent set use the theme-aware key, leaving the rest of
createColoredAnimatedSpriteForUnit and the call to
createColoredAnimatedSpriteCanvas unchanged.

---

Nitpick comments:
In `@src/client/graphics/AnimatedSpriteLoader.ts`:
- Around line 68-71: The lookup in createRegularAnimatedSprite uses FxType to
index animatedSpriteImageMap which actually uses config.name string keys,
creating a fragile hidden coupling; update createRegularAnimatedSprite to
explicitly map or coerce FxType to the expected config name (e.g., use a mapping
object or a conversion function) before calling animatedSpriteImageMap.get,
validate the mapped key exists and handle/memoize missing keys deterministically
(returning null or fallback), and add a comment documenting the mapping between
FxType values and config.name keys to make the dependency explicit; refer to the
createRegularAnimatedSprite method, the animatedSpriteImageMap collection, and
the FxType enum when implementing the mapping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b8b42b4-b259-4c1f-8014-b8b5a282468a

📥 Commits

Reviewing files that changed from the base of the PR and between 2c13c41 and 7dfd413.

⛔ Files ignored due to path filters (12)
  • resources/animatedSprites/BigSmoke.png is excluded by !**/*.png
  • resources/animatedSprites/BuildingExplosion.png is excluded by !**/*.png
  • resources/animatedSprites/Conquest.png is excluded by !**/*.png
  • resources/animatedSprites/Dust.png is excluded by !**/*.png
  • resources/animatedSprites/MiniExplosion.png is excluded by !**/*.png
  • resources/animatedSprites/MiniFire.png is excluded by !**/*.png
  • resources/animatedSprites/MiniSmoke.png is excluded by !**/*.png
  • resources/animatedSprites/MiniSmokeAndFire.png is excluded by !**/*.png
  • resources/animatedSprites/Nuke.png is excluded by !**/*.png
  • resources/animatedSprites/SAMExplosion.png is excluded by !**/*.png
  • resources/animatedSprites/SinkingShip.png is excluded by !**/*.png
  • resources/animatedSprites/UnitExplosion.png is excluded by !**/*.png
📒 Files selected for processing (3)
  • resources/animatedSprites/config.json
  • src/client/graphics/AnimatedSprite.ts
  • src/client/graphics/AnimatedSpriteLoader.ts

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Mar 11, 2026
coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 11, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/client/graphics/AnimatedSpriteLoader.ts (1)

75-113: ⚠️ Potential issue | 🟡 Minor

Include the theme in the colored-sprite cache key.

The cached canvas depends on theme.spawnHighlightColor(), but the key only uses fxType and owner.id(). If the same loader is reused after a theme change, it can return the wrong cached colors.

💡 Proposed fix
-    const key = `${fxType}-${owner.id()}`;
+    const key = `${fxType}-${owner.id()}-${theme.spawnHighlightColor()}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/AnimatedSpriteLoader.ts` around lines 75 - 113, The cache
key for colored sprites in createColoredAnimatedSpriteForUnit only uses
`${fxType}-${owner.id()}` but the result also depends on
theme.spawnHighlightColor(); update the key generation used with
coloredAnimatedSpriteCache to include the theme (e.g., incorporate
theme.spawnHighlightColor() or another unique theme identifier) so that
createColoredAnimatedSpriteForUnit and its use of
createColoredAnimatedSpriteCanvas return a cached sprite only when fxType,
owner.id() and theme match.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/graphics/AnimatedSprite.ts`:
- Around line 36-50: The update method in AnimatedSprite currently advances at
most one frame per call; change it to consume the entire deltaTime by looping:
accumulate elapsedTime, then while elapsedTime >= this.config.frameDuration
subtract frameDuration and advance this.currentFrame repeatedly until
elapsedTime < frameDuration, applying the same end-of-animation logic (handle
this.config.looping to wrap to 0 or set this.currentFrame =
this.config.frameCount - 1 and this.active = false) so multiple overdue frames
are processed in a single update call.

In `@src/client/graphics/AnimatedSpriteLoader.ts`:
- Around line 41-54: The image event handlers are attached after setting img.src
which can miss cached load/error events; in the animatedSprites mapping in
AnimatedSpriteLoader.ts, create the Promise and assign img.onload and
img.onerror (calling this.createCanvas(config, img) and resolving/rejecting)
before setting img.src to "/animatedSprites/" + config.name + ".png", ensuring
handlers are in place; keep the existing createCanvas(config, img) call and
error logging in the onerror handler.

---

Duplicate comments:
In `@src/client/graphics/AnimatedSpriteLoader.ts`:
- Around line 75-113: The cache key for colored sprites in
createColoredAnimatedSpriteForUnit only uses `${fxType}-${owner.id()}` but the
result also depends on theme.spawnHighlightColor(); update the key generation
used with coloredAnimatedSpriteCache to include the theme (e.g., incorporate
theme.spawnHighlightColor() or another unique theme identifier) so that
createColoredAnimatedSpriteForUnit and its use of
createColoredAnimatedSpriteCanvas return a cached sprite only when fxType,
owner.id() and theme match.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b8ccd0fa-26ed-47d2-830a-63e2bb2dbf03

📥 Commits

Reviewing files that changed from the base of the PR and between 7dfd413 and af3b0b7.

⛔ Files ignored due to path filters (1)
  • resources/animatedSprites/MiniBigSmoke.png is excluded by !**/*.png
📒 Files selected for processing (3)
  • resources/animatedSprites/config.json
  • src/client/graphics/AnimatedSprite.ts
  • src/client/graphics/AnimatedSpriteLoader.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • resources/animatedSprites/config.json

Comment on lines 36 to 50
update(deltaTime: number) {
if (!this.active) return;
this.elapsedTime += deltaTime;
if (this.elapsedTime >= this.frameDuration) {
this.elapsedTime -= this.frameDuration;
if (this.elapsedTime >= this.config.frameDuration) {
this.elapsedTime -= this.config.frameDuration;
this.currentFrame++;

if (this.currentFrame >= this.frameCount) {
if (this.looping) {
if (this.currentFrame >= this.config.frameCount) {
if (this.config.looping) {
this.currentFrame = 0;
} else {
this.currentFrame = this.frameCount - 1;
this.currentFrame = this.config.frameCount - 1;
this.active = false;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consume the full deltaTime in update().

A long frame only advances one animation frame here. SpriteFx.renderTick() stops FX by wall-clock duration, so after a hitch this sprite can be removed before it catches up visually. Step through all overdue frames, not just one.

💡 Proposed fix
   update(deltaTime: number) {
     if (!this.active) return;
     this.elapsedTime += deltaTime;
-    if (this.elapsedTime >= this.config.frameDuration) {
+    while (this.active && this.elapsedTime >= this.config.frameDuration) {
       this.elapsedTime -= this.config.frameDuration;
       this.currentFrame++;
 
       if (this.currentFrame >= this.config.frameCount) {
         if (this.config.looping) {
           this.currentFrame = 0;
         } else {
           this.currentFrame = this.config.frameCount - 1;
           this.active = false;
         }
       }
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
update(deltaTime: number) {
if (!this.active) return;
this.elapsedTime += deltaTime;
if (this.elapsedTime >= this.frameDuration) {
this.elapsedTime -= this.frameDuration;
if (this.elapsedTime >= this.config.frameDuration) {
this.elapsedTime -= this.config.frameDuration;
this.currentFrame++;
if (this.currentFrame >= this.frameCount) {
if (this.looping) {
if (this.currentFrame >= this.config.frameCount) {
if (this.config.looping) {
this.currentFrame = 0;
} else {
this.currentFrame = this.frameCount - 1;
this.currentFrame = this.config.frameCount - 1;
this.active = false;
}
}
update(deltaTime: number) {
if (!this.active) return;
this.elapsedTime += deltaTime;
while (this.active && this.elapsedTime >= this.config.frameDuration) {
this.elapsedTime -= this.config.frameDuration;
this.currentFrame++;
if (this.currentFrame >= this.config.frameCount) {
if (this.config.looping) {
this.currentFrame = 0;
} else {
this.currentFrame = this.config.frameCount - 1;
this.active = false;
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/AnimatedSprite.ts` around lines 36 - 50, The update
method in AnimatedSprite currently advances at most one frame per call; change
it to consume the entire deltaTime by looping: accumulate elapsedTime, then
while elapsedTime >= this.config.frameDuration subtract frameDuration and
advance this.currentFrame repeatedly until elapsedTime < frameDuration, applying
the same end-of-animation logic (handle this.config.looping to wrap to 0 or set
this.currentFrame = this.config.frameCount - 1 and this.active = false) so
multiple overdue frames are processed in a single update call.

Comment on lines +41 to +54
result.data!.animatedSprites.map((config) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = "/animatedSprites/" + config.name + ".png";
return new Promise<void>((resolve, reject) => {
img.onload = () => {
this.createCanvas(config, img);
resolve();
};
img.onerror = (e) => {
reject(e);
console.error(`Could not load animated sprite: `, e);
};
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In browsers, can new Image()miss theloadorerrorevent ifsrcis assigned beforeonloadandonerror, especially when the image is already cached?

💡 Result:

Yes—if you set img.src first and only later assign img.onload / img.onerror (or add listeners), you can miss the event, and this is especially likely when the image resolves “immediately” (e.g., from cache), because the load/error may fire before your handler is attached. This is a well-known gotcha; older/real-world browser behavior and common guidance is to attach handlers before setting src. [1], [2]

Robust patterns:

  • Attach handlers first, then set src: (avoids the race) [2]
  • Or, after setting src, also check img.complete and (commonly) img.naturalWidth > 0 to distinguish success vs failure; complete becomes true when the browser is done trying (success or failure). [1]
  • Or use img.decode() and await/.then(): it returns a promise that fulfills when decoding is complete (and will reject if it can’t be decoded). [3]

Example (safe order):

const img = new Image();
img.onload = () => console.log("loaded");
img.onerror = () => console.log("error");
img.src = url;

Example (cover the cached/instant case):

const img = new Image();
img.onload = done;
img.onerror = fail;
img.src = url;

if (img.complete) (img.naturalWidth ? done() : fail());

Sources: MDN on complete semantics [1]; guidance noting cached images can finish before handlers are bound [2]; HTML spec on img.decode() promise behavior [3].


Attach event handlers before setting image source.

When you set img.src, the browser starts loading immediately. If the image is cached, the load or error event fires before your handlers are attached, leaving the promise pending indefinitely and blocking the function.

💡 Proposed fix
       result.data!.animatedSprites.map((config) => {
         const img = new Image();
         img.crossOrigin = "anonymous";
-        img.src = "/animatedSprites/" + config.name + ".png";
         return new Promise<void>((resolve, reject) => {
           img.onload = () => {
             this.createCanvas(config, img);
             resolve();
           };
           img.onerror = (e) => {
             console.error(`Could not load animated sprite: `, e);
             reject(e);
           };
+          img.src = `/animatedSprites/${config.name}.png`;
         });
       }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/AnimatedSpriteLoader.ts` around lines 41 - 54, The image
event handlers are attached after setting img.src which can miss cached
load/error events; in the animatedSprites mapping in AnimatedSpriteLoader.ts,
create the Promise and assign img.onload and img.onerror (calling
this.createCanvas(config, img) and resolving/rejecting) before setting img.src
to "/animatedSprites/" + config.name + ".png", ensuring handlers are in place;
keep the existing createCanvas(config, img) call and error logging in the
onerror handler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Refactor Code cleanup, technical debt, refactoring, and architecture improvements.

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant