Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
faab9e7
Added `StateSprite`
AllUniversal Sep 22, 2025
a907434
Added support for `state.frame_order`, defining the order the frames …
AllUniversal Sep 25, 2025
ac56faf
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Oct 4, 2025
c3f223d
Incorrect height/width function name
AllUniversal Oct 4, 2025
b7259bb
Added `self.state == nil` check
AllUniversal Oct 4, 2025
be2bcba
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Oct 28, 2025
da4ee69
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Nov 23, 2025
bcb8c76
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Jan 8, 2026
c4509e7
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Mar 31, 2026
ca57d46
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Apr 8, 2026
f5f5565
Moved code to own file
AllUniversal Apr 8, 2026
87ad086
Added (untested) support for `sprite_args` field/param for Center spr…
AllUniversal Apr 10, 2026
d553f7b
Marked Todo
AllUniversal Apr 10, 2026
a3f3e6b
Tested `StateSprite` and made 'em functional
AllUniversal Apr 10, 2026
82355ae
Removed testing stuff
AllUniversal Apr 10, 2026
2211b0d
Added `exit_to` and `frame_durations` fields to `StateSprite` states
AllUniversal Apr 10, 2026
120d18a
2 Oversights 2 Fixed
AllUniversal Apr 10, 2026
4db655f
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Apr 12, 2026
d2fc84e
Refactor / fix
AllUniversal Apr 12, 2026
9e88212
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Apr 20, 2026
7a4fe48
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal May 1, 2026
7296327
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal May 6, 2026
a9289f1
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal May 11, 2026
864f18f
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal May 17, 2026
7240c73
Added `state.default_frame_duration`
AllUniversal May 22, 2026
b1ce500
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal May 22, 2026
cfb1566
Added support for `state.exit_to` being a function + stored `default_…
AllUniversal May 22, 2026
3901778
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal May 22, 2026
b94c573
Merge remote-tracking branch 'upstream/main' into State-Sprite
AllUniversal Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lovely/blind.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ position = 'before'
match_indent = true
payload = '''
local obj = self.config.blind
self.children.animatedSprite = SMODS.create_sprite(self.T.x, self.T.y, self.T.w, self.T.h, obj.atlas or 'blind_chips', obj.pos or G.P_BLINDS.bl_small.pos)
self.children.animatedSprite = SMODS.create_sprite(self.T.x, self.T.y, self.T.w, self.T.h, obj.atlas or 'blind_chips', obj.pos or G.P_BLINDS.bl_small.pos, obj.sprite_args)
self.children.animatedSprite.states = self.states
'''

Expand Down
4 changes: 2 additions & 2 deletions lovely/create_sprite.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pattern = '''
'''
position = 'at'
payload = '''
local tag_sprite = SMODS.create_sprite(0, 0, _size*1, _size*1, SMODS.get_atlas((not self.hide_ability) and G.P_TAGS[self.key].atlas or "tags"), (self.hide_ability) and G.tag_undiscovered.pos or self.pos)
local tag_sprite = SMODS.create_sprite(0, 0, _size*1, _size*1, SMODS.get_atlas((not self.hide_ability) and G.P_TAGS[self.key].atlas or "tags"), (self.hide_ability) and G.tag_undiscovered.pos or self.pos, self.sprite_args)
'''
match_indent = true
# functions/common_events.lua
Expand All @@ -72,7 +72,7 @@ pattern = '''
'''
position = 'at'
payload = '''
local blind_sprite = SMODS.create_sprite(0, 0, 1.2, 1.2, obj.atlas or 'blind_chips', copy_table(G.GAME.blind.pos))
local blind_sprite = SMODS.create_sprite(0, 0, 1.2, 1.2, obj.atlas or 'blind_chips', copy_table(G.GAME.blind.pos), obj.sprite_args)
'''
match_indent = true
[[patches]]
Expand Down
6 changes: 4 additions & 2 deletions lsp_def/classes/atlas.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
---@field super? SMODS.GameObject|table Parent class.
---@field px? string|number Width of individual sprites using this atlas.
---@field py? string|number Height of individual sprite using this atlas.
---@field path? string Name of the image file, including extension.
---@field path? string Name of the image file, including extension.
---@field path_mod? Mod|table The mod this object's `path` belongs to, if this is not the same mod it was created by.
---@field atlas_table? "ASSET_ATLAS"|"ANIMATION_ATLAS"|"ASSET_IMAGES"|string Type of atlas. `ASSET_ATLAS`: non-animated sprites, `ANIMATION_ATLAS`: animated sprites, `ASSET_IMAGES`: anything other image, e.g. logos.
---@field atlas_table? "ASSET_ATLAS"|"ANIMATION_ATLAS"|"ASSET_IMAGES"|"STATE_ATLAS"|string Type of atlas. `ASSET_ATLAS`: non-animated sprites, `ANIMATION_ATLAS`: animated sprites, `STATE_ATLAS`: StateSprites, `ASSET_IMAGES`: anything other image, e.g. logos.
---@field frames? number Number of frames in the animation.
---@field fps? number Speed of animation based on frames per second. Default: 10 or G.ANIMATION_FPS.
---@field columns? number Number of columns (= sprites horizontally).
---@field rows? number Number of rows (= sprites vertically).
---@field raw_key? boolean Sets whether the mod prefix is added to atlas key. Used for overriding vanilla sprites.
---@field language? string Key to a language. Restricts the atlas to only when this language is enabled.
---@field disable_mipmap? boolean Sets if the sprite is affected by the mipmap.
Expand Down
5 changes: 5 additions & 0 deletions lsp_def/vanilla.lua
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ function Sprite:__call(...) return self end
AnimatedSprite = {}
function AnimatedSprite:__call(...) return self end

---@class StateSprite: AnimatedSprite
---@overload fun(...: any): StateSprite|table
StateSprite = {}
function StateSprite:__call(...) return self end

---@class Blind: Moveable
---@overload fun(...: any): Blind|table
Blind = {}
Expand Down
29 changes: 23 additions & 6 deletions src/game_object.lua
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ Set `prefix_config.key = false` on your object instead.]]):format(obj.key), obj.
----- API CODE GameObject.Atlas
-------------------------------------------------------------------------------------------------

local atlas_table_map = {
ASSET_ATLAS = "ASSET_ATLAS",
ANIMATION_ATLAS = "ANIMATION_ATLAS",
STATE_ATLAS = "ANIMATION_ATLAS",
}

SMODS.Atlases = {}
SMODS.Atlas = SMODS.GameObject:extend {
obj_table = SMODS.Atlases,
Expand Down Expand Up @@ -466,7 +472,11 @@ Set `prefix_config.key = false` on your object instead.]]):format(obj.key), obj.
('Failed to initialize image data for Atlas %s'):format(self.key))
self.image = love.graphics.newImage(self.image_data,
{ mipmaps = true, dpiscale = G.SETTINGS.GRAPHICS.texture_scaling })
G[self.atlas_table][self.key_noloc or self.key] = self

self.columns = self.image:getWidth() / self.px
self.rows = self.image:getHeight() / self.py

G[atlas_table_map[self.atlas_table]][self.key_noloc or self.key] = self

local mipmap_level = SMODS.config.graphics_mipmap_level_options[SMODS.config.graphics_mipmap_level]
if not self.disable_mipmap and mipmap_level and mipmap_level > 0 then
Expand Down Expand Up @@ -703,7 +713,7 @@ Set `prefix_config.key = false` on your object instead.]]):format(obj.key), obj.
if not self.injected then
-- Sticker sprites (stake_ prefix is removed for vanilla compatiblity)
if self.sticker_pos ~= nil then
G.shared_stickers[self.key:sub(7)] = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, SMODS.get_atlas(self.sticker_atlas) or SMODS.get_atlas("stickers"), self.sticker_pos)
G.shared_stickers[self.key:sub(7)] = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, SMODS.get_atlas(self.sticker_atlas) or SMODS.get_atlas("stickers"), self.sticker_pos, self.sprite_args)
G.sticker_map[self.key] = self.key:sub(7)
else
G.sticker_map[self.key] = nil
Expand Down Expand Up @@ -1777,7 +1787,7 @@ Set `prefix_config.key = false` on your object instead.]]):format(obj.key), obj.
process_loc_text = function() end,
inject = function(self)
if self.overlay_pos then
self.overlay_sprite = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, self.atlas, self.overlay_pos)
self.overlay_sprite = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, self.atlas, self.overlay_pos, self.sprite_args)
self.no_overlay = true
end
end,
Expand Down Expand Up @@ -1894,7 +1904,7 @@ SMODS.UndiscoveredCompat = {
},
inject = function(self)
G.P_SEALS[self.key] = self
G.shared_seals[self.key] = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, SMODS.get_atlas(self.atlas) or SMODS.get_atlas('centers'), self.pos)
G.shared_seals[self.key] = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, SMODS.get_atlas(self.atlas) or SMODS.get_atlas('centers'), self.pos, self.sprite_args)
self.badge_to_key[self.key:lower() .. '_seal'] = self.key
SMODS.insert_pool(G.P_CENTER_POOLS[self.set], self)
self.rng_buffer[#self.rng_buffer + 1] = self.key
Expand Down Expand Up @@ -3142,7 +3152,7 @@ SMODS.UndiscoveredCompat = {
self.order = #self.obj_buffer
end,
inject = function(self)
self.sticker_sprite = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, self.atlas, self.pos)
self.sticker_sprite = SMODS.create_sprite(0, 0, G.CARD_W, G.CARD_H, self.atlas, self.pos, self.sprite_args)
G.shared_stickers[self.key] = self.sticker_sprite
end,
-- relocating sticker checks to here, so if the sticker has different checks than default
Expand Down Expand Up @@ -3277,6 +3287,7 @@ SMODS.UndiscoveredCompat = {
set = 'Enhanced',
class_prefix = 'm',
atlas = 'centers',
sprite_args = nil, -- Used by StateSprite (when the atlas' atlas_table == "STATE_ATLAS"), {states=..., states_offset=..., default_state=...}
pos = { x = 0, y = 0 },
required_params = {
'key',
Expand Down Expand Up @@ -4014,6 +4025,12 @@ SMODS.UndiscoveredCompat = {
}


-------------------------------------------------------------------------------------------------
----- API IMPORT Object.Node.Moveable.Sprite.AnimatedSprite.StateSprite
-------------------------------------------------------------------------------------------------

assert(load(NFS.read(SMODS.path..'src/game_objects/state_sprite.lua'), ('=[SMODS _ "src/game_objects/state_sprite.lua"]')))()

-------------------------------------------------------------------------------------------------
----- API IMPORT GameObject.DrawStep
-------------------------------------------------------------------------------------------------
Expand All @@ -4038,4 +4055,4 @@ SMODS.UndiscoveredCompat = {
end
end
}
end
end
194 changes: 194 additions & 0 deletions src/game_objects/state_sprite.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
StateSprite = AnimatedSprite:extend()

-- Form of param [states] is;
--[[
{
[state_name] = {
start_pos = { x/y = [0..n-1 for n columns/rows in sprite atlas] },
(frames = [amount of frames] |OR| end_pos = { [same as start_pos] }),
frame_order = "linear" |OR| "random" |OR| {1: x, 2: y, .. n: m}
(optional) flipped_h/flipped_v = true,
(optional) exit_to = [state] |OR [function(state_table, sprite), returning a state],
(optional) frame_durations = {1: 2, 2:...}, (in Frames according to G.ANIMATION_FPS)
(optional) default_frame_duration = 1, (in Frames according to G.ANIMATION_FPS)
},
...
}
]]
-- Example;
--[[
{
sleepy = {
start_pos = {x = 0, y = 0},
end_pos = {x = 3} (y is set to start_pos.y)
},
wakey = {
start_pos = {x = 4}, (y is set to 0)
frames = 4, (end_pos is set to start_pos with .x + frames)
default_frame_duration = 3, (all frames last 3 times longer (0.3 seconds with default G.ANIMATION_FPS == 10))
exit_to = "lookey", (after one iteration, sets state to this value)
},
lookey = {
flipped_h = true, (start_pos is set to {x = 0, y = 0}, end_pos is set to start_pos => this state is a single frame "animation" at x = 0, y = 0, and flipped horizontally and vertically)
flipped_v = true,
frame_durations = {[1] = 3} (the first frame lasts three times longer)
}
}
]]
-- To change state, call StateSprite:set_state(state_name)
function StateSprite:init(X, Y, W, H, new_sprite_atlas, _pos, args)
AnimatedSprite.init(self, X, Y, W, H, new_sprite_atlas, {x=0, y=0})
args = args or {}

if not args.states or not next(args.states) then
sendWarnMessage(string.format("StateSprite initialized without states, atlas = '%s'", new_sprite_atlas.name), "utils")
else
self.sprite_args = args
self.states_offset = args.states_offset and {x = args.states_offset.x or 0, y = args.states_offset.y or 0} or {x = 0, y = 0}
self.default_state = args.default_state or next(args.states)
self:load_states(args.states)
self:set_state(self.default_state)
end

self.flipped_h = false
self.flipped_v = false

if getmetatable(self) == StateSprite then
table.insert(G.I.SPRITE, self)
end
end

function StateSprite:set_state(state)
local a_state = self.a_states[state]
if not a_state then
sendWarnMessage(string.format("StateSprite:set_state() called with invalid state '%s'", state), "utils")
elseif self.state ~= a_state then
self.state = a_state
self:set_sprite_pos({x = self.state.start_pos.x + self.states_offset.x, y = self.state.start_pos.y + self.states_offset.y})
self.flipped_h = self.state.flipped_h
self.flipped_v = self.state.flipped_v
return true
end
return false
end

function StateSprite:load_states(states)
self.a_states = {}
for key, state in pairs(states) do
state.start_pos = state.start_pos and {x = state.start_pos.x or 0, y = state.start_pos.y or 0} or {x = 0, y = 0}
state.frames = state.frames or ((state.end_pos or state.start_pos).x - state.start_pos.x + ((state.end_pos.y or state.start_pos).y - state.start_pos.y) * self.atlas.columns + 1)
state.key = key
if type(state.frame_order) == "string" then
local keymap = {
linear=true,
random=true
}
if not keymap[state.frame_order:lower()] then
state.frame_order = "linear"
end
elseif type(state.frame_order) == "table" then
if not state.frame_order[1] then
state.frame_order = "linear"
end
else
state.frame_order = "linear"
end
self.a_states[key] = state
end
end

function StateSprite:animate()
if not self.state then return end
if self.state.exit_to and self.current_animation.elapsed >= self.current_animation.frames then
if type(self.state.exit_to) == "function" then
self:set_state(self.state:exit_to(self) or self.default_state)
else
self:set_state(self.state.exit_to)
end
end
local frame_finished = (math.floor(G.ANIMATION_FPS*(G.TIMERS.REAL - self.offset_seconds) / (self.current_animation.frame_duration or self.state.default_frame_duration or 1))) > 0
if frame_finished then
local new_frame
if type(self.state.frame_order) == "table" then
self.current_animation.frame_index = (self.current_animation.frame_index + 1) % self.current_animation.frames
new_frame = self.state.frame_order[self.current_animation.frame_index] or self.current_animation.current
elseif self.state.frame_order == "random" then
new_frame = math.random(0, self.current_animation.frames - 1)
end
self.current_animation.current = new_frame or ((self.current_animation.current + 1) % self.current_animation.frames)
self.current_animation.elapsed = self.current_animation.elapsed + 1
self.current_animation.frame_duration = (self.state.frame_durations or {})[self.current_animation.current] or self.state.default_frame_duration or 1
local _x = self.animation.w * ((self.states_offset.x + self.state.start_pos.x + self.current_animation.current) % self.atlas.columns)
local _y = self.animation.h * (self.states_offset.y + self.state.start_pos.y + math.floor(self.current_animation.current / self.atlas.columns))
self.sprite:setViewport(
_x,
_y,
self.animation.w,
self.animation.h
)
self.offset_seconds = G.TIMERS.REAL
end
if self.float then
self.T.r = 0.02*math.sin(2*G.TIMERS.REAL+self.T.x)
self.offset.y = -(1+0.3*math.sin(0.666*G.TIMERS.REAL+self.T.y))*self.shadow_parrallax.y
self.offset.x = -(0.7+0.2*math.sin(0.666*G.TIMERS.REAL+self.T.x))*self.shadow_parrallax.x
end
end

function StateSprite:set_sprite_pos(sprite_pos)
self.animation = {
x = sprite_pos and sprite_pos.x or 0,
y = sprite_pos and sprite_pos.y or 0,
frames = self.state and self.state.frames or 1, current = 0,
w = self.scale.x, h = self.scale.y
}

self.frame_offset = 0 -- Unused

self.current_animation = {
current = 0,
frames = self.animation.frames,
w = self.animation.w,
h = self.animation.h,
elapsed = 0,
frame_duration = (self.state.frame_durations or {})[0] or self.state.default_frame_duration or 1
}

self.image_dims = self.image_dims or {}
self.image_dims[1], self.image_dims[2] = self.atlas.image:getDimensions()

self.sprite = love.graphics.newQuad(
self.animation.w*self.animation.x,
self.animation.h*self.animation.y,
self.animation.w,
self.animation.h,
self.image_dims[1], self.image_dims[2]
)
self.offset_seconds = G.TIMERS.REAL
end

function StateSprite:draw_self()
if not self.states.visible then return end

prep_draw(self, 1)
love.graphics.scale(1/self.scale_mag)
love.graphics.setColor(G.C.WHITE)
love.graphics.draw(
self.atlas.image,
self.sprite,
0 ,0,
0,
self.VT.w/(self.T.w) * (self.flipped_h and -1 or 1),
self.VT.h/(self.T.h) * (self.flipped_v and -1 or 1)
)
love.graphics.pop()
end


function Card:set_sprite_state(new_state)
if self.children.center:is(StateSprite) then
return self.children.center:set_state(new_state)
else
sendWarnMessage("Card:card_set_sprite_state() called on card with no StateSprite", "utils")
end
end
Loading