From 53075be298fc4f39e01ad595a12d06dbaad1cc88 Mon Sep 17 00:00:00 2001 From: ropoko Date: Thu, 26 Mar 2026 22:32:30 -0300 Subject: [PATCH 01/16] fix: transition to loading-game after matchmaking --- src/states/lobby.lua | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/states/lobby.lua b/src/states/lobby.lua index e92c362..e7b26c0 100644 --- a/src/states/lobby.lua +++ b/src/states/lobby.lua @@ -24,10 +24,16 @@ function Lobby:load() self.selected_deck = DeckApi:get_current_deck() socket.on_matchmaker_matched(Constants.SOCKET_CONNECTION, function(match) - Constants.MATCH_ID = match.matchmaker_matched.match_id + local matched = match.matchmaker_matched or {} + local matched_token = matched.token + local matched_match_id = matched.match_id + Constants.ENEMY_ID = self:get_enemy_user_id(match.matchmaker_matched.users) coroutine.resume(coroutine.create(function() + self.matchmake_state = 'idle' + self.matchmake_ticket = nil + local objects = { { collection = 'selected_deck', @@ -39,20 +45,32 @@ function Lobby:load() } } - nakama.write_storage_objects(Constants.NAKAMA_CLIENT, objects, function() - socket.match_join(Constants.SOCKET_CONNECTION, Constants.MATCH_ID, nil, nil, function() - CONTEXT:change('game') - end) - end) + local write_result = nakama.write_storage_objects(Constants.NAKAMA_CLIENT, objects) + if write_result and write_result.error then + Toast:show('error', 'Failed to sync deck before joining match.', 4) + return + end + + local join_result = socket.match_join(Constants.SOCKET_CONNECTION, matched_match_id, matched_token, nil) + if join_result and join_result.error then + Toast:show('error', 'Failed to join match. Please try again.', 4) + return + end + + if join_result and join_result.match and join_result.match.match_id then + Constants.MATCH_ID = join_result.match.match_id + end + + CONTEXT:change('loading_game') end)) end) end function Lobby:has_selected_deck() return self.selected_deck ~= nil - and self.selected_deck.cards ~= nil - and type(self.selected_deck.cards) == 'table' - and #self.selected_deck.cards > 0 + and self.selected_deck.cards ~= nil + and type(self.selected_deck.cards) == 'table' + and #self.selected_deck.cards > 0 end function Lobby:update(dt) self.timer:update(dt) end From dd20545d0db7d7aec2f3c2e2409ed7f28edd357a Mon Sep 17 00:00:00 2001 From: ropoko Date: Thu, 26 Mar 2026 23:31:01 -0300 Subject: [PATCH 02/16] fix: animations working again --- src/entities/card.lua | 20 ++--- src/entities/cards/char.lua | 161 ++++++++++++------------------------ src/entities/deck.lua | 2 +- src/helpers/image.lua | 23 +++++- src/states/game.lua | 22 ++++- src/states/loading-game.lua | 4 +- 6 files changed, 107 insertions(+), 125 deletions(-) diff --git a/src/entities/card.lua b/src/entities/card.lua index 7921aa4..2e5039d 100644 --- a/src/entities/card.lua +++ b/src/entities/card.lua @@ -66,19 +66,17 @@ function Card:load_animations(card) end function Card:draw_loading_animation() - local x = self.x + self.img_card:getWidth() / 2 - local y = self.y + self.img_card:getHeight() / 2 - - love.graphics.stencil(function() - love.graphics.draw(self.img_card, self.x, self.y, 0, self.default_scale, self.default_scale) - end, "replace", 1, false) + local card_w = self.img_card:getWidth() + local card_h = self.img_card:getHeight() + local cx = self.x + card_w / 2 + local cy = self.y + card_h / 2 + love.graphics.setScissor(self.x, self.y, card_w, card_h) love.graphics.setColor(1, 0, 0, 0.5) - love.graphics.setStencilTest('equal', 1) - love.graphics.arc("fill", x, y, 130, -math.pi / 2, -math.pi / 2 + (2 * math.pi * (self.current_cooldown / self.cooldown)), 100) - love.graphics.setColor(1, 1, 1) - - love.graphics.setStencilTest() + love.graphics.arc("fill", cx, cy, 130, -math.pi / 2, + -math.pi / 2 + (2 * math.pi * (self.current_cooldown / self.cooldown)), 100) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setScissor() end function Card:reset_cooldown() diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index acfe219..c8519db 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -25,94 +25,25 @@ local Char = { timeout = 0 } ---[[ - function Char.handle_chars_around(char, enemy) - char.chars_around[enemy.key] = enemy - char.chars_around[enemy.key].key = enemy.key - end - - function Char:load_actions(char) - char.actions = { - walk = { - update = function(dt) - char.animations['walk']:update(dt) - end, - draw = function(x, y, current_life, enemy) - char:lifebar(x,y, current_life) - - if enemy then - x = x + char.speed - else - x = x - char.speed - end - - char.animations['walk']:draw(char.img_walk, x, y) - return x, y - end - }, - follow = { - update = function(dt) - char.nearest_enemy = char:get_nearest_enemy(char, char.chars_around) - - char.animations['walk']:update(dt) - end, - draw = function(x,y, current_life) - char:lifebar(x,y, current_life) - - local dx = char.nearest_enemy.char_x - x - local dy = char.nearest_enemy.char_y - y - - local distance = math.sqrt(dx*dx + dy*dy) - - if distance > 1 then - local angle = math.atan2(dy, dx) - x = x + char.speed * math.cos(angle) - y = y + char.speed * math.sin(angle) - end - - char.animations['walk']:draw(char.img_walk, x, y) - return x,y - end - }, - attack = { - update = function(dt) - char.animations['attack']:update(dt) - end, - draw = function(x,y, current_life) - char:lifebar(x,y, current_life) - - char.nearest_enemy = char:get_nearest_enemy(char, char.chars_around) - - char.animations['attack']:draw(char.img_attack,x,y) - return x,y - end - }, - death = { - update = function(dt) - char.animations['death']:update(dt) - end, - draw = function(x,y, _) - char.animations['death']:draw(char.img_death,x,y) - return x,y - end - } - } - - return char +local function get_walk_preview_quad(char) + if not char.img_walk then return nil end + if not char.frame_width or not char.frame_height then return nil end + if char.img_walk:getWidth() < char.frame_width then return nil end + if char.img_walk:getHeight() < char.frame_height then return nil end + + if not char.walk_preview_quad then + char.walk_preview_quad = love.graphics.newQuad( + 0, + 0, + char.frame_width, + char.frame_height, + char.img_walk:getWidth(), + char.img_walk:getHeight() + ) end - function Char:get_nearest_enemy(char, around) - for _,v in pairs(around) do - local distance_x = v.char_x - char.char_x - local distance_y = v.char_y - char.char_y - - if (distance_x >= (char.nearest_enemy.char_x - char.char_x)) - and (distance_y >= (char.nearest_enemy.char_y - char.char_y)) then - return v - end - end - end -]] + return char.walk_preview_quad +end function Char:get_enemies_in_range(enemies) local enemies_in_range = {} @@ -126,7 +57,7 @@ function Char:get_enemies_in_range(enemies) for k,v in pairs(enemies_in_range) do local has_collision = Utils.circle_rect_collision( self.char_x, self.char_y, self.perception_range/2, - v.char_x, v.char_y, v.img_preview:getWidth(), v.img_preview:getHeight() + v.char_x, v.char_y, v.frame_width or 60, v.frame_height or 60 ) self.enemies_around[k] = has_collision and v or nil @@ -143,7 +74,7 @@ function Char:check_attack_range() local attack_range_collision = Utils.circle_rect_collision( self.char_x, self.char_y, self.attack_range/2, self.nearest_enemy.char_x, self.nearest_enemy.char_y, - self.nearest_enemy.img_preview:getWidth(), self.nearest_enemy.img_preview:getHeight() + self.nearest_enemy.frame_width or 60, self.nearest_enemy.frame_height or 60 ) if attack_range_collision then @@ -151,7 +82,6 @@ function Char:check_attack_range() self.current_action = 'attack' coroutine.resume(coroutine.create(function() - -- maybe I should create a function to send the data so I can add a proper timeout to it? socket.match_data_send( Constants.SOCKET_CONNECTION, Constants.MATCH_ID, @@ -187,27 +117,24 @@ function Char:get_nearest_enemy() end function Char:preview(x, y) - -- -- attack range - -- love.graphics.ellipse("line", x, y, card.attack_range, card.attack_range) - -- -- perception range - -- love.graphics.ellipse("line", x, y, card.perception_range, card.perception_range) + local walk_quad = get_walk_preview_quad(self) + if not walk_quad then return end - local center_x = x - self.img_preview:getWidth() / 2 - local center_y = y - self.img_preview:getHeight() / 2 + local center_x = x - self.frame_width / 2 + local center_y = y - self.frame_height / 2 - love.graphics.setColor(0.2,0.2,0.7,0.5) - love.graphics.draw(self.img_preview, center_x, center_y) - love.graphics.setColor(1,1,1) + love.graphics.setColor(0.2, 0.2, 0.7, 0.5) + love.graphics.draw(self.img_walk, walk_quad, center_x, center_y) + love.graphics.setColor(1, 1, 1, 1) end -function Char:lifebar(x,y, current_life) - love.graphics.setColor(255/255,29/255,29/255) +function Char:lifebar(x, y, current_life) + love.graphics.setColor(1, 29/255, 29/255) love.graphics.rectangle("line", x - 10, y - 10, self.life, 5) love.graphics.rectangle("fill", x - 10, y - 10, current_life, 5) - love.graphics.setColor(255,255,255) + love.graphics.setColor(1, 1, 1, 1) end - function Char:get_action(current_action) if current_action == 'walk' then local new_position = self.enemy @@ -220,19 +147,41 @@ function Char:get_action(current_action) end function Char:update(dt) - self.animations[self.current_action]:update(dt) + local anim = self.animations[self.current_action] + if anim and type(anim.update) == 'function' then + anim:update(dt) + end end function Char:draw() self.last_x = self.char_x + love.graphics.setColor(1, 1, 1, 1) - local x = self.enemy and self.char_x - self.img_preview:getWidth()/2 or self.char_x + self.img_preview:getWidth()/2 - local y = self.char_y + self.img_preview:getHeight()/2 + local fw = self.frame_width or 60 + local fh = self.frame_height or 60 + + local x = self.enemy and self.char_x - fw/2 or self.char_x + fw/2 + local y = self.char_y + fh/2 love.graphics.circle("line", x, y, self.perception_range) love.graphics.circle("line", x, y, self.attack_range) - self.animations[self.current_action]:draw(self['img_'..self.current_action], self.char_x, self.char_y, 0, self.scale_x, 1) + local action = self.current_action + local current_animation = self.animations[action] + local current_img = self['img_' .. action] + + local has_valid_anim = current_animation + and type(current_animation.draw) == 'function' + and current_img + + if has_valid_anim then + current_animation:draw(current_img, self.char_x, self.char_y, 0, self.scale_x, 1) + else + local walk_quad = get_walk_preview_quad(self) + if walk_quad then + love.graphics.draw(self.img_walk, walk_quad, self.char_x, self.char_y, 0, self.scale_x, 1) + end + end self:get_action(self.current_action) end diff --git a/src/entities/deck.lua b/src/entities/deck.lua index 153efc3..1043736 100644 --- a/src/entities/deck.lua +++ b/src/entities/deck.lua @@ -266,7 +266,7 @@ function Deck:mousepressed(x, y, button) socket.match_data_send( Constants.SOCKET_CONNECTION, Constants.MATCH_ID, - MatchEvents.spawn_card, + MatchEvents.card_spawn, json.encode(payload_card), nil ) diff --git a/src/helpers/image.lua b/src/helpers/image.lua index cf9a458..c4f4a64 100644 --- a/src/helpers/image.lua +++ b/src/helpers/image.lua @@ -1,4 +1,5 @@ local https = require('https') +local Constants = require('src.constants') local Image = { missing_card = love.graphics.newImage('assets/missing-card.png') @@ -9,9 +10,27 @@ local Image = { return default card image ]] function Image:load_from_url(url, file_name) - local code, image = https.request(url) + if not url or type(url) ~= 'string' or url == '' then + return self.missing_card + end + + local code, image = https.request(url, { method = 'GET' }) + + -- Some storage backends (e.g. SeaweedFS public buckets) reject requests + -- when Authorization header is present. Retry with bearer only when needed. + if (code == 401 or code == 403) and Constants.ACCESS_TOKEN and Constants.ACCESS_TOKEN ~= '' then + code, image = https.request(url, { + method = 'GET', + headers = { + Authorization = 'Bearer ' .. Constants.ACCESS_TOKEN + } + }) + end - if code ~= 200 then return self.missing_card end + if code ~= 200 then + print(string.format('[IMAGE LOAD] failed code=%s url=%s', tostring(code), tostring(url))) + return self.missing_card + end if image then local file_data = love.filesystem.newFileData(image, file_name) diff --git a/src/states/game.lua b/src/states/game.lua index 1032175..54c9afc 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -101,18 +101,22 @@ function Game:draw() Deck:draw() self:draw_player_status() + love.graphics.setColor(1, 1, 1, 1) for _, card in pairs(self.cards[Constants.USER_ID]) do card:draw() end + love.graphics.setColor(1, 1, 1, 1) for _, card in pairs(self.cards[Constants.ENEMY_ID]) do card:draw() end if Deck.card_selected then + love.graphics.setColor(1, 1, 1, 1) Deck.card_selected:preview(love.mouse.getX(), love.mouse.getY()) end + love.graphics.setColor(1, 1, 1, 1) self:draw_towers() -- self:draw_timer() @@ -150,7 +154,15 @@ end function Game:handle_received_data(message) local data = json.decode(message.match_data.data) local user_id = message.match_data.presence.user_id - local opcode = data.opcode + local opcode = tonumber(message.match_data.op_code) + + if opcode == nil then + if data and data.card_name and data.x and data.y and data.card_id then + opcode = MatchEvents.card_spawn + elseif data and data.card_id and data.action then + opcode = MatchEvents.card_action + end + end self:handle_opcode_event(opcode, user_id, data) end @@ -160,7 +172,13 @@ function Game:handle_opcode_event(opcode, user_id, data) if opcode == MatchEvents.card_spawn then -- mirroring enemy cards + if not self.cards[user_id] then + self.cards[user_id] = {} + end + local enemy_card = self:get_enemy_card(data.card_name) + if not enemy_card then return end + enemy_card.char_x = love.graphics.getWidth() - data.x enemy_card.char_y = data.y @@ -168,7 +186,7 @@ function Game:handle_opcode_event(opcode, user_id, data) end if opcode == MatchEvents.card_action then - print('hey changing action', opcode, user_id, data.card_id, data.action) + if not self.cards[user_id] or not self.cards[user_id][data.card_id] then return end self.cards[user_id][data.card_id].current_action = data.action end end diff --git a/src/states/loading-game.lua b/src/states/loading-game.lua index 842c227..97c9ac5 100644 --- a/src/states/loading-game.lua +++ b/src/states/loading-game.lua @@ -36,8 +36,6 @@ function LoadingGame:draw() end end -function LoadingGame:resize() - -end +function LoadingGame:resize() end return LoadingGame From e92e5c3586df31c5a8745c46b042f76c86d001d0 Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 09:42:05 -0300 Subject: [PATCH 03/16] feat: showing lifebar for chars --- src/entities/card.lua | 4 ++ src/entities/cards/char.lua | 110 +++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 33 deletions(-) diff --git a/src/entities/card.lua b/src/entities/card.lua index 2e5039d..85f8f86 100644 --- a/src/entities/card.lua +++ b/src/entities/card.lua @@ -43,6 +43,10 @@ function Card:new(card, enemy) card = self:load_images(card) card = self:load_animations(card) + if card.type == Card_Types.CHAR and rawget(card, 'current_life') == nil then + card.current_life = card.life + end + return Utils.copy_table(card) end diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index c8519db..3686840 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -25,6 +25,31 @@ local Char = { timeout = 0 } +-- anim8 draws with top-left at (char_x, char_y) when scale_x >= 0, and with the +-- right edge at char_x when flipped (scale_x < 0). Match hitboxes, range circles, +-- and lifebar to that footprint. +local function char_frame_size(c) + local fw = c.frame_width or 60 + local fh = c.frame_height or 60 + return fw, fh +end + +local function char_hit_top_left(c) + local fw, fh = char_frame_size(c) + if (c.scale_x or 1) < 0 then + return c.char_x - fw, c.char_y + end + return c.char_x, c.char_y +end + +local function char_range_center(c) + local fw, fh = char_frame_size(c) + if (c.scale_x or 1) < 0 then + return c.char_x - fw / 2, c.char_y + fh / 2 + end + return c.char_x + fw / 2, c.char_y + fh / 2 +end + local function get_walk_preview_quad(char) if not char.img_walk then return nil end if not char.frame_width or not char.frame_height then return nil end @@ -48,16 +73,19 @@ end function Char:get_enemies_in_range(enemies) local enemies_in_range = {} - for k,v in pairs(enemies) do + for k, v in pairs(enemies) do if v.type == 'char' then enemies_in_range[k] = v end end - for k,v in pairs(enemies_in_range) do + for k, v in pairs(enemies_in_range) do + local scx, scy = char_range_center(self) + local rx, ry = char_hit_top_left(v) + local vw, vh = char_frame_size(v) local has_collision = Utils.circle_rect_collision( - self.char_x, self.char_y, self.perception_range/2, - v.char_x, v.char_y, v.frame_width or 60, v.frame_height or 60 + scx, scy, self.perception_range / 2, + rx, ry, vw, vh ) self.enemies_around[k] = has_collision and v or nil @@ -71,10 +99,12 @@ function Char:get_enemies_in_range(enemies) end function Char:check_attack_range() + local scx, scy = char_range_center(self) + local rx, ry = char_hit_top_left(self.nearest_enemy) + local rw, rh = char_frame_size(self.nearest_enemy) local attack_range_collision = Utils.circle_rect_collision( - self.char_x, self.char_y, self.attack_range/2, - self.nearest_enemy.char_x, self.nearest_enemy.char_y, - self.nearest_enemy.frame_width or 60, self.nearest_enemy.frame_height or 60 + scx, scy, self.attack_range / 2, + rx, ry, rw, rh ) if attack_range_collision then @@ -98,22 +128,25 @@ function Char:check_attack_range() end function Char:get_nearest_enemy() - local nearest_distance = 0 - - for k,v in pairs(self.enemies_around) do - local distance_x = v.char_x - self.char_x - local distance_y = v.char_y - self.char_y - - local distance = math.sqrt(distance_x * distance_x + distance_y * distance_y) - - if nearest_distance == 0 or distance < nearest_distance then - nearest_distance = distance - v.card_id = k - self.nearest_enemy = v - else - self.nearest_enemy = nil + local best = nil + local best_d = math.huge + + for k, v in pairs(self.enemies_around) do + if v then + local ax, ay = char_range_center(self) + local bx, by = char_range_center(v) + local dx, dy = bx - ax, by - ay + local distance = math.sqrt(dx * dx + dy * dy) + + if distance < best_d then + best_d = distance + v.card_id = k + best = v + end end end + + self.nearest_enemy = best end function Char:preview(x, y) @@ -129,17 +162,24 @@ function Char:preview(x, y) end function Char:lifebar(x, y, current_life) - love.graphics.setColor(1, 29/255, 29/255) - love.graphics.rectangle("line", x - 10, y - 10, self.life, 5) - love.graphics.rectangle("fill", x - 10, y - 10, current_life, 5) + current_life = current_life or self.current_life or self.life or 0 + local max_life = self.life or 1 + local bar_width = 46 + local bar_height = 5 + local fill_ratio = math.max(0, math.min(1, current_life / max_life)) + local fill_width = bar_width * fill_ratio + + love.graphics.setColor(1, 29 / 255, 29 / 255) + love.graphics.rectangle("line", x, y, bar_width, bar_height) + love.graphics.rectangle("fill", x, y, fill_width, bar_height) love.graphics.setColor(1, 1, 1, 1) end function Char:get_action(current_action) if current_action == 'walk' then local new_position = self.enemy - and self.char_x + self.speed - or self.char_x - self.speed + and self.char_x + self.speed + or self.char_x - self.speed self.char_x = new_position self.scale_x = self.last_x >= self.char_x and 1 or -1 @@ -160,19 +200,17 @@ function Char:draw() local fw = self.frame_width or 60 local fh = self.frame_height or 60 - local x = self.enemy and self.char_x - fw/2 or self.char_x + fw/2 - local y = self.char_y + fh/2 - - love.graphics.circle("line", x, y, self.perception_range) - love.graphics.circle("line", x, y, self.attack_range) + local rcx, rcy = char_range_center(self) + love.graphics.circle("line", rcx, rcy, self.perception_range / 2) + love.graphics.circle("line", rcx, rcy, self.attack_range / 2) local action = self.current_action local current_animation = self.animations[action] local current_img = self['img_' .. action] local has_valid_anim = current_animation - and type(current_animation.draw) == 'function' - and current_img + and type(current_animation.draw) == 'function' + and current_img if has_valid_anim then current_animation:draw(current_img, self.char_x, self.char_y, 0, self.scale_x, 1) @@ -183,6 +221,12 @@ function Char:draw() end end + local bar_w = 46 + local lcx, _ = char_range_center(self) + local lifebar_x = lcx - bar_w / 2 + local lifebar_y = self.char_y + fh + 4 + self:lifebar(lifebar_x, lifebar_y, self.current_life) + self:get_action(self.current_action) end From 2fbeac9621fe6e5a72e9081b72d0abb16ec2ae43 Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 13:32:40 -0300 Subject: [PATCH 04/16] fix: correct enemy user ID retrieval in lobby state and add validation for user type --- src/states/lobby.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/states/lobby.lua b/src/states/lobby.lua index e7b26c0..960a8c2 100644 --- a/src/states/lobby.lua +++ b/src/states/lobby.lua @@ -28,7 +28,7 @@ function Lobby:load() local matched_token = matched.token local matched_match_id = matched.match_id - Constants.ENEMY_ID = self:get_enemy_user_id(match.matchmaker_matched.users) + Constants.ENEMY_ID = self:get_enemy_user_id(matched.users) coroutine.resume(coroutine.create(function() self.matchmake_state = 'idle' @@ -119,6 +119,10 @@ function Lobby:draw() end function Lobby:get_enemy_user_id(users) + if type(users) ~= 'table' then + return nil + end + for _, user in pairs(users) do if user.presence.user_id ~= Constants.USER_ID then return user.presence.user_id @@ -126,8 +130,6 @@ function Lobby:get_enemy_user_id(users) end end -function Lobby:resize() - -end +function Lobby:resize() end return Lobby From 7d42d0433de061224f827af5c2d8c8599f7afc54 Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 16:43:39 -0300 Subject: [PATCH 05/16] feat: wip game --- main.lua | 2 + src/config/match_events.lua | 29 ++-- src/entities/card.lua | 3 +- src/entities/cards/char.lua | 89 ++---------- src/entities/deck.lua | 22 +-- src/states/game.lua | 267 +++++++++++++++++++++++++++++++----- 6 files changed, 264 insertions(+), 148 deletions(-) diff --git a/main.lua b/main.lua index c647ae8..aa5e279 100644 --- a/main.lua +++ b/main.lua @@ -9,6 +9,8 @@ local Toast = require('src.ui.toast') function love.load() love.graphics.setFont(Fonts.jura(24)) + math.randomseed(os.time() + math.floor(os.clock() * 100000)) + -- initialize the global state manager CONTEXT = Context; CONTEXT:load() diff --git a/src/config/match_events.lua b/src/config/match_events.lua index 98a8503..c67c23d 100644 --- a/src/config/match_events.lua +++ b/src/config/match_events.lua @@ -1,25 +1,28 @@ local MatchEvents = { - -- CARD RELATED EVENTS - -- send x, y, card_id + -- legacy card events card_spawn = 0, - -- send x, y and card_id card_position = 1, - -- send card_id and action (attack, walk, death ...) card_action = 2, - -- send card_id (remove the card from the game) card_dead = 3, - -- send card_id and damage (this is for the card receiving the damage) card_damage = 4, - -- send card_id and healing (this is for the card receiving the healing) card_healing = 5, - - -- TOWER RELATED EVENTS - -- tower_id and damage tower_damage = 6, - -- tower_id and healing tower_healing = 7, - -- (tower_id) the tower being destroyed - tower_destroy = 8 + tower_destroy = 8, + + -- authoritative protocol: client intents + spawn_intent = 20, + command_intent = 21, + + -- authoritative protocol: server snapshots/deltas + state_snapshot = 30, + entity_spawned = 31, + entity_updated = 32, + entity_removed = 33, + damage_event = 34, + tower_event = 35, + match_end = 36, + reject_intent = 37 } return MatchEvents diff --git a/src/entities/card.lua b/src/entities/card.lua index 85f8f86..c85e086 100644 --- a/src/entities/card.lua +++ b/src/entities/card.lua @@ -14,7 +14,8 @@ local Card = { selectable = false, preview_card = false, is_card_loading = false, - perception_range = 0 + perception_range = 0, + predicted = false } local Card_Types = { diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index 3686840..b1df5cb 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -1,9 +1,3 @@ -local socket = require('lib.nakama.socket') -local json = require('lib.json') -local MatchEvents = require('src.config.match_events') -local Constants = require('src.constants') -local Utils = require('src.helpers.utils') - local Char = { current_action = 'walk', current_life = 0, @@ -71,82 +65,15 @@ local function get_walk_preview_quad(char) end function Char:get_enemies_in_range(enemies) - local enemies_in_range = {} - - for k, v in pairs(enemies) do - if v.type == 'char' then - enemies_in_range[k] = v - end - end - - for k, v in pairs(enemies_in_range) do - local scx, scy = char_range_center(self) - local rx, ry = char_hit_top_left(v) - local vw, vh = char_frame_size(v) - local has_collision = Utils.circle_rect_collision( - scx, scy, self.perception_range / 2, - rx, ry, vw, vh - ) - - self.enemies_around[k] = has_collision and v or nil - end - - self:get_nearest_enemy() - - if self.nearest_enemy then - self:check_attack_range() - end + return end function Char:check_attack_range() - local scx, scy = char_range_center(self) - local rx, ry = char_hit_top_left(self.nearest_enemy) - local rw, rh = char_frame_size(self.nearest_enemy) - local attack_range_collision = Utils.circle_rect_collision( - scx, scy, self.attack_range / 2, - rx, ry, rw, rh - ) - - if attack_range_collision then - if self.current_action ~= 'attack' then - self.current_action = 'attack' - - coroutine.resume(coroutine.create(function() - socket.match_data_send( - Constants.SOCKET_CONNECTION, - Constants.MATCH_ID, - MatchEvents.card_action, - json.encode({ - card_id = self.card_id, - action = self.current_action - }), - nil - ) - end)) - end - end + return end function Char:get_nearest_enemy() - local best = nil - local best_d = math.huge - - for k, v in pairs(self.enemies_around) do - if v then - local ax, ay = char_range_center(self) - local bx, by = char_range_center(v) - local dx, dy = bx - ax, by - ay - local distance = math.sqrt(dx * dx + dy * dy) - - if distance < best_d then - best_d = distance - v.card_id = k - best = v - end - end - end - - self.nearest_enemy = best + return nil end function Char:preview(x, y) @@ -176,13 +103,17 @@ function Char:lifebar(x, y, current_life) end function Char:get_action(current_action) + if not self.predicted then + return + end + if current_action == 'walk' then local new_position = self.enemy - and self.char_x + self.speed - or self.char_x - self.speed + and self.char_x - self.speed + or self.char_x + self.speed self.char_x = new_position - self.scale_x = self.last_x >= self.char_x and 1 or -1 + self.scale_x = self.char_x >= self.last_x and 1 or -1 end end diff --git a/src/entities/deck.lua b/src/entities/deck.lua index 1043736..43ba455 100644 --- a/src/entities/deck.lua +++ b/src/entities/deck.lua @@ -1,11 +1,6 @@ local Layout = require('src.helpers.layout') local Map = require('src.entities.map') local Card = require('src.entities.card') -local Constants = require('src.constants') -local socket = require('lib.nakama.socket') -local MatchEvents = require('src.config.match_events') -local json = require('lib.json') -local Utils = require('src.helpers.utils') local uuid = require('lib.uuid') local Deck = { @@ -251,26 +246,15 @@ function Deck:mousepressed(x, y, button) card:reset_cooldown() local payload_card = { + client_intent_id = uuid:generate(), card_id = uuid:generate(), card_name = card.name, x = card.char_x, - y = card.char_y, - action = card.current_action + y = card.char_y } local Game = require('src.states.game') - card.card_id = payload_card.card_id - Game.cards[Constants.USER_ID][payload_card.card_id] = Utils.copy_table(card) - - coroutine.resume(coroutine.create(function() - socket.match_data_send( - Constants.SOCKET_CONNECTION, - Constants.MATCH_ID, - MatchEvents.card_spawn, - json.encode(payload_card), - nil - ) - end)) + Game:spawn_card_intent(card, payload_card) self.card_selected = nil self.deck_selected = self:rotate_deck(card) diff --git a/src/states/game.lua b/src/states/game.lua index 54c9afc..3c7ca74 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -13,14 +13,34 @@ local PlayerStatus = require('src.ui.player-status') local json = require('lib.json') local Assets = require('src.assets') +local MAP_WIDTH = 1344 + +local function resolve_card_bucket(owner_id) + if owner_id == Constants.USER_ID then + return Constants.USER_ID + end + return Constants.ENEMY_ID +end + local Game = { timer = Timer:new(), cards = {}, + pending_spawns = {}, + last_match_tick = 0, + match_winner_id = nil, + player_side = nil, me_status = PlayerStatus:new('2d618372-1220-49b3-b22e-00f6ca0c12a5'), enemy_status = PlayerStatus:new('2d618372-1220-49b3-b22e-00f6ca0c12a5') } +function Game:mirror_x(x) + if self.player_side == 'right' then + return MAP_WIDTH - x + end + return x +end + function Game:load() Assets.TOWER = love.graphics.newImage('assets/tower.png') @@ -34,6 +54,10 @@ function Game:load() self.cards[Constants.USER_ID] = {} self.cards[Constants.ENEMY_ID] = {} + self.pending_spawns = {} + self.last_match_tick = 0 + self.match_winner_id = nil + self.player_side = nil self:load_towers() @@ -87,7 +111,6 @@ function Game:update(dt) for _, card in pairs(self.cards[Constants.USER_ID]) do card:update(dt) - card:get_enemies_in_range(self.cards[Constants.ENEMY_ID]) end for _, enemy_card in pairs(self.cards[Constants.ENEMY_ID]) do @@ -125,16 +148,10 @@ end -- private functions --------- function Game:load_towers() - local tower1 = Tower:load('left', 'top') - local tower2 = Tower:load('left', 'bottom') - - local tower3 = Tower:load('right', 'top') - local tower4 = Tower:load('right', 'bottom') - - table.insert(self.cards[Constants.USER_ID], tower1) - table.insert(self.cards[Constants.USER_ID], tower2) - table.insert(self.cards[Constants.ENEMY_ID], tower3) - table.insert(self.cards[Constants.ENEMY_ID], tower4) + table.insert(self.cards[Constants.USER_ID], Tower:load('left', 'top')) + table.insert(self.cards[Constants.USER_ID], Tower:load('left', 'bottom')) + table.insert(self.cards[Constants.ENEMY_ID], Tower:load('right', 'top')) + table.insert(self.cards[Constants.ENEMY_ID], Tower:load('right', 'bottom')) end function Game:draw_towers() @@ -153,52 +170,230 @@ end function Game:handle_received_data(message) local data = json.decode(message.match_data.data) - local user_id = message.match_data.presence.user_id local opcode = tonumber(message.match_data.op_code) + local user_id = nil + if message.match_data.presence then + user_id = message.match_data.presence.user_id + end + + self:handle_opcode_event(opcode, user_id, data or {}) +end - if opcode == nil then - if data and data.card_name and data.x and data.y and data.card_id then - opcode = MatchEvents.card_spawn - elseif data and data.card_id and data.action then - opcode = MatchEvents.card_action +function Game:handle_opcode_event(opcode, user_id, data) + if opcode == MatchEvents.state_snapshot then + self:apply_snapshot(data) + return + end + + if opcode == MatchEvents.entity_spawned then + if data and data.entity then + self:apply_entity_state(data.entity) + self.pending_spawns[data.client_intent_id] = nil end + return end - self:handle_opcode_event(opcode, user_id, data) + if opcode == MatchEvents.entity_updated then + self:apply_entity_state(data) + return + end + + if opcode == MatchEvents.entity_removed then + if data and data.entity_id and data.owner_id then + local bucket = resolve_card_bucket(data.owner_id) + if self.cards[bucket] then + self.cards[bucket][data.entity_id] = nil + end + end + return + end + + if opcode == MatchEvents.reject_intent then + if data and data.client_intent_id and self.pending_spawns[data.client_intent_id] then + local pending = self.pending_spawns[data.client_intent_id] + if self.cards[Constants.USER_ID] then + self.cards[Constants.USER_ID][pending.card_id] = nil + end + self.pending_spawns[data.client_intent_id] = nil + end + return + end + + if opcode == MatchEvents.match_end then + self.match_winner_id = data.winner_id + return + end end -function Game:handle_opcode_event(opcode, user_id, data) - if user_id == Constants.USER_ID then return end +function Game:get_card_template(card_name, owner_id) + local search_decks = {} + if owner_id == Constants.USER_ID then + search_decks = { Deck.deck_selected, EnemyDeck.deck } + else + search_decks = { EnemyDeck.deck, Deck.deck_selected } + end - if opcode == MatchEvents.card_spawn then - -- mirroring enemy cards - if not self.cards[user_id] then - self.cards[user_id] = {} + for _, deck in ipairs(search_decks) do + for _, value in pairs(deck or {}) do + if value.name == card_name then + return value + end end + end +end - local enemy_card = self:get_enemy_card(data.card_name) - if not enemy_card then return end +function Game:apply_entity_state(entity) + if not entity or not entity.entity_id or not entity.owner_id then return end - enemy_card.char_x = love.graphics.getWidth() - data.x - enemy_card.char_y = data.y + local bucket = resolve_card_bucket(entity.owner_id) - self.cards[user_id][data.card_id] = Utils.copy_table(enemy_card) + if not self.cards[bucket] then + self.cards[bucket] = {} end - if opcode == MatchEvents.card_action then - if not self.cards[user_id] or not self.cards[user_id][data.card_id] then return end - self.cards[user_id][data.card_id].current_action = data.action + local card = self.cards[bucket][entity.entity_id] + local is_new = card == nil + if is_new then + local template = self:get_card_template(entity.card_name, entity.owner_id) + if not template then return end + + card = Utils.copy_table(template) + card.card_id = entity.entity_id + card.predicted = false + card.enemy = bucket == Constants.ENEMY_ID + card.scale_x = card.enemy and -1 or 1 + self.cards[bucket][entity.entity_id] = card end + + local prev_x = card.char_x + local prev_y = card.char_y + local has_authoritative_position = card.has_authoritative_position == true + + local incoming_version = entity.entity_version or 0 + local current_version = card.entity_version or -1 + if incoming_version < current_version then + return + end + + card.entity_version = incoming_version + card.card_id = entity.entity_id + card.enemy = bucket == Constants.ENEMY_ID + card.current_action = entity.action or card.current_action or 'walk' + card.current_life = entity.current_life or card.current_life + card.life = entity.max_life or card.life + + local screen_x = entity.x and self:mirror_x(entity.x) or card.char_x + local target_y = entity.y or card.char_y + + if not is_new and entity.x and card.last_screen_x then + local dx = screen_x - card.last_screen_x + if dx > 1 then + card.scale_x = 1 + elseif dx < -1 then + card.scale_x = -1 + end + end + if entity.x then card.last_screen_x = screen_x end + + if has_authoritative_position and prev_x and prev_y and card.predicted == false then + local alpha = 0.35 + card.char_x = prev_x + (screen_x - prev_x) * alpha + card.char_y = prev_y + (target_y - prev_y) * alpha + else + card.char_x = screen_x + card.char_y = target_y + end + + card.predicted = false + card.has_authoritative_position = true end -function Game:get_enemy_card(card_name) - for _, value in pairs(EnemyDeck.deck) do - if value.name == card_name then - return value +function Game:apply_snapshot(snapshot) + if not snapshot or not snapshot.cards then return end + if snapshot.match_tick and snapshot.match_tick < self.last_match_tick then return end + + self.last_match_tick = snapshot.match_tick or self.last_match_tick + self.match_winner_id = snapshot.winner_id or self.match_winner_id + + if self.player_side == nil and snapshot.towers then + local screen_center = love.graphics.getWidth() / 2 + for _, t in ipairs(snapshot.towers) do + if t.owner_id == Constants.USER_ID then + self.player_side = t.x > screen_center and 'right' or 'left' + break + end + end + end + + local seen = {} + + for _, entity in ipairs(snapshot.cards) do + local b = resolve_card_bucket(entity.owner_id) + seen[b] = seen[b] or {} + seen[b][entity.entity_id] = true + self:apply_entity_state(entity) + end + + for _, bucket_id in ipairs({ Constants.USER_ID, Constants.ENEMY_ID }) do + local entities = self.cards[bucket_id] + if entities then + for entity_id, card in pairs(entities) do + if type(entity_id) == 'string' and card.type == 'char' then + local is_seen = seen[bucket_id] and seen[bucket_id][entity_id] + if not is_seen and not card.predicted then + entities[entity_id] = nil + end + end + end + end + end + + for _, tower_state in ipairs(snapshot.towers or {}) do + local tower_bucket = resolve_card_bucket(tower_state.owner_id) + local towers = self.cards[tower_bucket] or {} + for _, tower in ipairs(towers) do + if tower.type == 'tower' then + local same_band = math.abs((tower.char_y or 0) - (tower_state.y or 0)) < 160 + if same_band then + tower.current_life = tower_state.current_life + end + end end end end +function Game:spawn_card_intent(card, payload) + if not payload or not payload.client_intent_id or not payload.card_id then return end + + local predicted = Utils.copy_table(card) + predicted.card_id = payload.card_id + predicted.char_x = payload.x + predicted.char_y = payload.y + predicted.predicted = true + predicted.current_action = 'walk' + predicted.entity_version = 0 + predicted.enemy = false + predicted.scale_x = 1 + + self.cards[Constants.USER_ID][payload.card_id] = predicted + self.pending_spawns[payload.client_intent_id] = { + card_id = payload.card_id + } + + local server_payload = Utils.copy_table(payload) + server_payload.x = self:mirror_x(payload.x) + + coroutine.resume(coroutine.create(function() + socket.match_data_send( + Constants.SOCKET_CONNECTION, + Constants.MATCH_ID, + MatchEvents.spawn_intent, + json.encode(server_payload), + nil + ) + end)) +end + function Game:update_player_status() self.me_status:update() self.enemy_status:update() From ac7e38f1d77abf39e87c4a6a87f47fb917cd0365 Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 20:50:00 -0300 Subject: [PATCH 06/16] chore: removing dead code --- src/cards/caveman.lua | 143 --------------------------------------- src/cards/dino.lua | 138 ------------------------------------- src/cards/thunder.lua | 0 src/config/range.lua | 22 ------ src/entities/new_map.lua | 41 ----------- src/entities/user.lua | 38 ----------- src/helpers/saver.lua | 68 ------------------- src/ui/alert.lua | 49 -------------- src/ui/cards.lua | 41 ----------- 9 files changed, 540 deletions(-) delete mode 100644 src/cards/caveman.lua delete mode 100644 src/cards/dino.lua delete mode 100644 src/cards/thunder.lua delete mode 100644 src/config/range.lua delete mode 100644 src/entities/new_map.lua delete mode 100644 src/entities/user.lua delete mode 100644 src/helpers/saver.lua delete mode 100644 src/ui/alert.lua delete mode 100644 src/ui/cards.lua diff --git a/src/cards/caveman.lua b/src/cards/caveman.lua deleted file mode 100644 index 68b9828..0000000 --- a/src/cards/caveman.lua +++ /dev/null @@ -1,143 +0,0 @@ -local Assets = require('assets.caveman.assets') -local anim8 = require('lib.anim8') -local Range = require('src.config.range') - -local Caveman = { - name = 'Caveman', - img = Assets.PREVIEW_LEFT, -- TODO: change name to `preview_img` - card_img = Assets.CARD, - is_card_loading = false, - - life = 100, - current_life = 100, - speed = 1, - current_action = 'walk', - attack_range = Range:getSize('melee_short'), - - cooldown = 10, -- seconds - - damage = 100, - - ------------ - - -- TODO: move all props below to an abstract `card` class - current_cooldown = 0, - - x = 0, - y = 0, - - char_x = 0, - char_y = 0, - - animate = {}, - actions = {}, - chars_around = {}, - selected = false, - selectable = false, - preview_card = false -} - -local nearest_enemy = { - x = 0, - y = 0, - current_life = 0 -} - -local function handle_damage() - nearest_enemy.current_life = nearest_enemy.current_life - Caveman.damage -end - -local walking = Assets.WALK_LEFT -local grid_walking = anim8.newGrid(60, 60, walking:getWidth(), walking:getHeight()) -local walk_animation = anim8.newAnimation(grid_walking('1-11', 1), Caveman.speed/10) - -local attack = Assets.ATTACK_LEFT -local grid_attack = anim8.newGrid(70, 60, attack:getWidth(), attack:getHeight()) -local attack_animation = anim8.newAnimation(grid_attack('1-9', 1), 0.2, handle_damage()) - -Caveman.animate.update = function(self, dt) - return self.actions[self.current_action].update(dt) -end - -Caveman.animate.draw = function(self, x, y, ...) - self:lifebar(x,y) - - return self.actions[self.current_action].draw(x,y) -end - -Caveman.actions = { - walk = { - update = function(dt) - walk_animation:update(dt) - end, - draw = function(x,y) - x = x - Caveman.speed - - walk_animation:draw(walking, x,y) - return x,y - end - }, - follow = { - update = function(dt) - nearest_enemy = Caveman:get_nearest_enemy(Caveman.chars_around) - - walk_animation:update(dt) - end, - draw = function(x,y) - local dx = nearest_enemy.x - x - local dy = nearest_enemy.y - y - - local distance = math.sqrt(dx*dx + dy*dy) - - if distance > 1 then - local angle = math.atan2(dy, dx) - x = x + Caveman.speed * math.cos(angle) - y = y + Caveman.speed * math.sin(angle) - end - - walk_animation:draw(walking, x, y) - return x,y - end - }, - attack = { - update = function(dt) - attack_animation:update(dt) - end, - draw = function(x,y) - if nearest_enemy.width == nil then - nearest_enemy = Caveman:get_nearest_enemy(Caveman.chars_around) - end - - attack_animation:draw(attack,x,y) - return x,y - end - } -} - --- show life level for each char --- TODO: move to generic card module -function Caveman:lifebar(x,y) - love.graphics.setColor(255/255,29/255,29/255) - love.graphics.rectangle("line", x + 10, y - 10, self.life / 2, 5) - love.graphics.rectangle("fill", x + 10, y - 10, self.current_life / 2, 5) - love.graphics.setColor(255,255,255) -end - --- only showed on preview -function Caveman:perception_range() - return self.attack_range * 2 -end - -function Caveman:get_nearest_enemy(around) - for _,v in pairs(around) do - local distance_x = v.x - self.char_x - local distance_y = v.y - self.char_y - - if (distance_x >= (nearest_enemy.x - self.char_x)) - and (distance_y >= (nearest_enemy.y - self.char_y)) then - return v - end - end -end - -return Caveman diff --git a/src/cards/dino.lua b/src/cards/dino.lua deleted file mode 100644 index 36ccb59..0000000 --- a/src/cards/dino.lua +++ /dev/null @@ -1,138 +0,0 @@ -local Assets = require('assets.dino.assets') -local anim8 = require('lib.anim8') -local Range = require('src.config.range') - -local Dino = { - name = 'Dino', - img = Assets.PREVIEW_LEFT, -- TODO: change name to `preview_img` - card_img = Assets.CARD, - is_card_loading = false, - - life = 100, - current_life = 100, - - speed = 1.5, - current_action = 'walk', - attack_range = Range:getSize('melee_medium'), - - cooldown = 5, -- seconds - - damage = 200, - - ------------ - - -- TODO: move all props below to an abstract `card` class - current_cooldown = 0, - - x = 0, - y = 0, - - char_x = 0, - char_y = 0, - - animate = {}, - actions = {}, - chars_around = {}, - selected = false, - selectable = false, - preview_card = false -} - -local nearest_enemy = { - x = 0, - y = 0 -} - -local walking = Assets.WALK_LEFT -local grid_walking = anim8.newGrid(90, 90, walking:getWidth(), walking:getHeight()) -local walk_animation = anim8.newAnimation(grid_walking('1-6', 1), 0.2) - -local attack = Assets.ATTACK_LEFT -local grid_attack = anim8.newGrid(90, 90, attack:getWidth(), attack:getHeight()) -local attack_animation = anim8.newAnimation(grid_attack('1-7', 1), 0.2) - -Dino.animate.update = function(self, dt) - return self.actions[self.current_action].update(dt) -end - -Dino.animate.draw = function(self, x, y, ...) - self:lifebar(x,y) - - return self.actions[self.current_action].draw(x,y) -end - -Dino.actions = { - walk = { - update = function(dt) - walk_animation:update(dt) - end, - draw = function(x,y) - x = x - Dino.speed - - walk_animation:draw(walking, x,y) - return x,y - end - }, - follow = { - update = function(dt) - nearest_enemy = Dino:get_nearest_enemy(Dino.chars_around) - walk_animation:update(dt) - end, - draw = function(x,y) - local dx = nearest_enemy.x - x - local dy = nearest_enemy.y - y - - local distance = math.sqrt(dx*dx + dy*dy) - - if distance > 1 then - local angle = math.atan2(dy, dx) - x = x + Dino.speed * math.cos(angle) - y = y + Dino.speed * math.sin(angle) - end - - walk_animation:draw(walking, x, y) - return x,y - end - }, - attack = { - update = function(dt) - attack_animation:update(dt) - end, - draw = function(x,y) - if not nearest_enemy.width then - nearest_enemy = Dino:get_nearest_enemy(Dino.chars_around) - end - - attack_animation:draw(attack,x,y) - return x,y - end - } -} - --- show life level for each char --- TODO: move to generic card module -function Dino:lifebar(x,y) - love.graphics.setColor(255/255,29/255,29/255) - love.graphics.rectangle("line", x + 10, y - 10, self.life / 2, 5) - love.graphics.rectangle("fill", x + 10, y - 10, self.current_life / 2, 5) - love.graphics.setColor(255,255,255) -end - --- only showed on preview -function Dino:perception_range() - return self.attack_range * 2 -end - -function Dino:get_nearest_enemy(around) - for _,v in pairs(around) do - local distance_x = v.x - self.char_x - local distance_y = v.y - self.char_y - - if (distance_x >= (nearest_enemy.x - self.char_x)) - and (distance_y >= (nearest_enemy.y - self.char_y)) then - return v - end - end -end - -return Dino diff --git a/src/cards/thunder.lua b/src/cards/thunder.lua deleted file mode 100644 index e69de29..0000000 diff --git a/src/config/range.lua b/src/config/range.lua deleted file mode 100644 index 29c0bb2..0000000 --- a/src/config/range.lua +++ /dev/null @@ -1,22 +0,0 @@ -local Range = { - melee_short = 20, - melee_medium = 30, - melee_long = 40, - distance = 0 -} - -function Range:getSize(type_range, distance) - distance = distance or 0 - - if self[type_range] == nil then - error('invalid range type') - end - - if type_range == 'distance' then - return distance - end - - return self[type_range] -end - -return Range diff --git a/src/entities/new_map.lua b/src/entities/new_map.lua deleted file mode 100644 index e3fc5c4..0000000 --- a/src/entities/new_map.lua +++ /dev/null @@ -1,41 +0,0 @@ -local Map = {image_width = 0, image_height = 0, scale_x = 1, scale_y = 1 } - -local tile_size_const = 32 -local map_scale_const = 1.25 -function Map:load() - - self.initial_width, self.initial_height = love.graphics.getWidth(), - love.graphics.getHeight() - - self.image_width, self.image_height = - self.initial_width * (self.scale_x / map_scale_const), - self.initial_height * (self.scale_x / map_scale_const) - - self.image = love.graphics.newImage("assets/map/wild map.png") - self.image:setFilter("nearest", "nearest") - -end - -function Map:update(dt) end - -function Map:draw() - - local lasting_tiles = (love.graphics.getWidth() - Map.image_width) / - tile_size_const - - love.graphics.draw(self.image, tile_size_const * (lasting_tiles / 2), 0, 0, - self.scale_x / map_scale_const, - self.scale_y / map_scale_const) -end - -function love.resize(w, h) - Map.scale_x = w / Map.image:getWidth() - Map.scale_y = h / Map.image:getHeight() - - Map.image_width, Map.image_height = Map.initial_width * - (Map.scale_x / map_scale_const), - Map.initial_height * - (Map.scale_x / map_scale_const) -end - -return Map diff --git a/src/entities/user.lua b/src/entities/user.lua deleted file mode 100644 index 8a4a0a6..0000000 --- a/src/entities/user.lua +++ /dev/null @@ -1,38 +0,0 @@ -local Deck = require('src.entities.deck') - -local function User(nickname, level) - local obj = { - __call = function(self) - return self.new() - end, - - __tostring = function(self) - return string.format('nickname: %s, level: %d', self.nickname, self.level) - end, - - nickname = nickname, - level = level or 1, - - decks = Deck, - deck_selected = 'deck1' - } - - obj.__index = obj - - setmetatable(obj, {}) - - function obj.new() - if obj._instance then - return obj._instance - end - - local instance = setmetatable({}, obj) - - obj._instance = instance - return obj._instance - end - - return obj -end - -return User diff --git a/src/helpers/saver.lua b/src/helpers/saver.lua deleted file mode 100644 index 6ae2c33..0000000 --- a/src/helpers/saver.lua +++ /dev/null @@ -1,68 +0,0 @@ -local Saver = {} -local Lume = require('lib.lume') - -local Constants = require('src.constants') -local User = require('src.entities.user') - -local get_users = require('src.api.user') - --- TODO: remove lume and use json.lua - --- this is the default path LOVE2D sets --- we only can write on another path using IO lib --- ~/.local/share/love/wild-league/savedata.txt - -local path = 'savedata.txt' - -function Saver:save(user) - if user.nickname == nil then - error('user should have a nickname prop') - end - - -- TODO: added log file - print('saving data...') - - local serialized_data = Lume.serialize({ nickname = user.nickname, level = user.level }) - - Constants.LOGGED_USER = User(user.nickname, user.level) - - local res, message = love.filesystem.write(path, serialized_data) - - if res ~= true then - error('error trying to save data. Message: '..message) - return false - end - - return true -end - -function Saver:retrieveData() - -- TODO: add log file - print('getting data... ') - - get_users() - - -- print(users) - - -- for key, value in pairs(users) do - -- print(key, value) - -- end - - - -- print(user.id, user.nickname, user.email) - - if love.filesystem.getInfo(path) then - local file = love.filesystem.read(path) - local data = Lume.deserialize(file) - - Constants.LOGGED_USER = User(data.nickname, data.level) - - return Constants.LOGGED_USER - else - love.filesystem.newFile(path) - end - - return nil -end - -return Saver diff --git a/src/ui/alert.lua b/src/ui/alert.lua deleted file mode 100644 index ec617a4..0000000 --- a/src/ui/alert.lua +++ /dev/null @@ -1,49 +0,0 @@ -local Fonts = require('src.ui.fonts') - -local Alert = { - alerts = {} -} - ---[[ - duration: in seconds -]] -function Alert:show(title, message, duration) - table.insert(self.alerts, { - title = title, - message = message, - duration = duration or 3, - start_time = love.timer.getTime() - }) -end - -function Alert:draw() - if #self.alerts == 0 then return end - - local current_time = love.timer.getTime() - - for i, alert in ipairs(self.alerts) do - local elapsed = current_time - alert.start_time - - if elapsed >= alert.duration then - table.remove(self.alerts, i) - end - - love.graphics.setFont(Fonts.jura(22)) - - local mainX = 20 - local mainY = love.graphics.getHeight() - 100 - - love.graphics.setColor(0, 0, 0, 0.5) - love.graphics.rectangle('fill', mainX, mainY, 300, 80) - love.graphics.setColor(1, 1, 1, 1) - - love.graphics.printf(alert.title, mainX - 50, mainY + 10, 300, 'center') - - if alert.message then - love.graphics.setFont(Fonts.jura(15)) - love.graphics.printf(alert.message, mainX - 65, mainY + 50, 300, "right") - end - end -end - -return Alert diff --git a/src/ui/cards.lua b/src/ui/cards.lua deleted file mode 100644 index c6854d8..0000000 --- a/src/ui/cards.lua +++ /dev/null @@ -1,41 +0,0 @@ -local yui = require("lib.yui") -local ImageHelper = require("src.helpers.image") - -local Card = { - bottom_padding = 20, - side_padding = 20, - scale = 1.25 -}; - -function Card:new(card) - return yui.Button { - w = card.img_card:getWidth(), - h = card.img_card:getHeight(), - image = ImageHelper:load_from_url(card.img_card, 'preview'), - use_image_as_background = true, - scale = self.scale, - theme = nil, - mode = "line", - onHit = function() print(card.name) end, - - yui.Label({ - w = 80, - h = 10, - text = card.name, - size = 10, - theme = { color = { normal = { fg = { 1, 1, 1 }}}} - }), - - yui.Spacer({ w = 100, h = 440 }), - - yui.Label({ - w = 80, - h = 10, - text = card.cooldown .. "s", - size = 20, - theme = { color = { normal = { fg = { 1, 1, 1 }}}} - }) - } -end - -return Card From c3d14362a93d08d1f5cbca5a867b90e991118424 Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 23:30:34 -0300 Subject: [PATCH 07/16] fix: update tower scaling and add tower_id parameter in load function --- src/assets.lua | 4 +++- src/entities/tower.lua | 23 +++++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/assets.lua b/src/assets.lua index a3cdefb..1284eb8 100644 --- a/src/assets.lua +++ b/src/assets.lua @@ -20,9 +20,11 @@ local Assets = { TOWER = love.graphics.newImage('assets/tower.png'), } +Assets.TOWER:setFilter('nearest', 'nearest') + setmetatable(Assets, { __index = function(_, key) - error(string.format('the assets: "%s" is not set in assets', key)) + error(string.format('the assets: "%s" is not set in assets', key)) end }) diff --git a/src/entities/tower.lua b/src/entities/tower.lua index 5553e0d..cc1e4ce 100644 --- a/src/entities/tower.lua +++ b/src/entities/tower.lua @@ -13,7 +13,7 @@ local default_props = { img = Assets.TOWER } -function Tower:load(side, position) +function Tower:load(side, position, tower_id) if side ~= 'left' and side ~= 'right' then error('Invalid side for Tower') end @@ -27,21 +27,21 @@ function Tower:load(side, position) height = love.graphics.getHeight() / 2 } - local red = { 255/255, 0/255, 0/255 } - local green = { 0/255, 255/255, 0/255 } + local red = { 255 / 255, 0 / 255, 0 / 255 } + local green = { 0 / 255, 255 / 255, 0 / 255 } local positions = { left = { top = { x = center.width - 470, y = center.height - 180, - scale_x = -1, + scale_x = -2, color = red }, bottom = { x = center.width - 470, y = center.height + 200, - scale_x = -1, + scale_x = -2, color = red } }, @@ -49,13 +49,13 @@ function Tower:load(side, position) top = { x = center.width + 470, y = center.height - 180, - scale_x = 1 , + scale_x = 2, color = green }, bottom = { x = center.width + 470, y = center.height + 200, - scale_x = 1, + scale_x = 2, color = green } } @@ -68,11 +68,12 @@ function Tower:load(side, position) end tower.side = side + tower.tower_id = tower_id tower.char_x = positions[side][position].x tower.char_y = positions[side][position].y tower.color = positions[side][position].color tower.scale_x = positions[side][position].scale_x - tower.scale_y = 1 + tower.scale_y = 2 tower.update = function(tower_, dt) return Tower.update(tower_, dt) @@ -91,12 +92,14 @@ end function Tower:update(dt) end function Tower.draw(tower_, current_life) - love.graphics.draw(tower_.img, tower_.char_x, tower_.char_y, 0, tower_.scale_x, tower_.scale_y, tower_.w / 2, tower_.h / 2) + local life = current_life or tower_.current_life or tower_.life or 100 + love.graphics.draw(tower_.img, tower_.char_x, tower_.char_y, 0, tower_.scale_x, tower_.scale_y, tower_.w / 2, + tower_.h / 2) local lifebar_x = tower_.char_x - (100 / 2) local lifebar_y = tower_.char_y - tower_.h * tower_.scale_y / 2 - 10 - Tower:lifebar(lifebar_x, lifebar_y, 100, tower_.side,tower_.color) + Tower:lifebar(lifebar_x, lifebar_y, life, tower_.side, tower_.color) end function Tower:lifebar(x, y, current_life, side, color) From 2adf0283edd726480fd3f0cdaeb6e84ba0467436 Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 23:49:07 -0300 Subject: [PATCH 08/16] fix: mirroring --- lib/nakama/engine/love2d.lua | 48 ++++++++++++----------- src/config/match_events.lua | 11 ------ src/entities/card.lua | 10 +++-- src/entities/cards/char.lua | 76 ++++++++++++++++++------------------ src/entities/deck.lua | 44 ++++++++++++--------- src/helpers/animation.lua | 4 +- src/states/game.lua | 74 +++++++++++++++++++---------------- 7 files changed, 137 insertions(+), 130 deletions(-) diff --git a/lib/nakama/engine/love2d.lua b/lib/nakama/engine/love2d.lua index 1cf3458..3f28f16 100644 --- a/lib/nakama/engine/love2d.lua +++ b/lib/nakama/engine/love2d.lua @@ -24,7 +24,7 @@ local M = {} -- @return The mac address string. local function get_mac_address() local ifaddrs = sys.get_ifaddrs() - for _,interface in ipairs(ifaddrs) do + for _, interface in ipairs(ifaddrs) do if interface.mac then return interface.mac end @@ -42,9 +42,9 @@ function M.uuid() return uuid(mac) end - local make_http_request -make_http_request = function(url, method, callback, headers, post_data, options, retry_intervals, retry_count, cancellation_token) +make_http_request = function(url, method, callback, headers, post_data, options, retry_intervals, retry_count, + cancellation_token) if cancellation_token and cancellation_token.cancelled then callback(nil) return @@ -58,22 +58,23 @@ make_http_request = function(url, method, callback, headers, post_data, options, data = post_data and post_data or nil }) - local ok, decoded = pcall(json.decode, response) + local ok, decoded = pcall(json.decode, response) - if status_code >= 200 and status_code <= 299 then - callback(decoded) - elseif retry_count > #retry_intervals then - if not ok then - callback({ error = true, message = "Unable to decode response" }) - else - callback({error = decoded.error or true, message = decoded.message, code = decoded.code}) - end + if status_code >= 200 and status_code <= 299 then + callback(decoded) + elseif retry_count > #retry_intervals then + if not ok then + callback({ error = true, message = "Unable to decode response" }) else - -- Retry - local retry_interval = retry_intervals[retry_count] - love.timer.sleep(retry_interval) - make_http_request(url, method, callback, headers, post_data, options, retry_intervals, retry_count + 1, cancellation_token) + callback({ error = decoded.error or true, message = decoded.message, code = decoded.code }) end + else + -- Retry + local retry_interval = retry_intervals[retry_count] + love.timer.sleep(retry_interval) + make_http_request(url, method, callback, headers, post_data, options, retry_intervals, retry_count + 1, + cancellation_token) + end end @@ -89,13 +90,15 @@ end function M.http(config, url_path, query_params, method, post_data, retry_policy, cancellation_token, callback) local query_string = "" if next(query_params) then - for query_key,query_value in pairs(query_params) do + for query_key, query_value in pairs(query_params) do if type(query_value) == "table" then - for _,v in ipairs(query_value) do - query_string = ("%s%s%s=%s"):format(query_string, (#query_string == 0 and "?" or "&"), query_key, uri_encode_component(tostring(v))) + for _, v in ipairs(query_value) do + query_string = ("%s%s%s=%s"):format(query_string, (#query_string == 0 and "?" or "&"), query_key, + uri_encode_component(tostring(v))) end else - query_string = ("%s%s%s=%s"):format(query_string, (#query_string == 0 and "?" or "&"), query_key, uri_encode_component(tostring(query_value))) + query_string = ("%s%s%s=%s"):format(query_string, (#query_string == 0 and "?" or "&"), query_key, + uri_encode_component(tostring(query_value))) end end end @@ -117,7 +120,8 @@ function M.http(config, url_path, query_params, method, post_data, retry_policy, log("HTTP", method, url) log("DATA", post_data) - make_http_request(url, method, callback, headers, post_data, options, retry_policy or config.retry_policy, 1, cancellation_token) + make_http_request(url, method, callback, headers, post_data, options, retry_policy or config.retry_policy, 1, + cancellation_token) end --- Create a new socket with message handler. @@ -172,7 +176,7 @@ function M.socket_connect(socket, callback) callback(true) function ws:onmessage(message) - print('ws message', message) + -- print('ws message', message) log("EVENT_MESSAGE: ", message) on_message(socket, message) diff --git a/src/config/match_events.lua b/src/config/match_events.lua index c67c23d..d5d9473 100644 --- a/src/config/match_events.lua +++ b/src/config/match_events.lua @@ -1,15 +1,4 @@ local MatchEvents = { - -- legacy card events - card_spawn = 0, - card_position = 1, - card_action = 2, - card_dead = 3, - card_damage = 4, - card_healing = 5, - tower_damage = 6, - tower_healing = 7, - tower_destroy = 8, - -- authoritative protocol: client intents spawn_intent = 20, command_intent = 21, diff --git a/src/entities/card.lua b/src/entities/card.lua index c85e086..dce353b 100644 --- a/src/entities/card.lua +++ b/src/entities/card.lua @@ -70,15 +70,17 @@ function Card:load_animations(card) return card end -function Card:draw_loading_animation() - local card_w = self.img_card:getWidth() - local card_h = self.img_card:getHeight() +function Card:draw_loading_animation(scale) + scale = scale or 1 + local card_w = self.img_card:getWidth() * scale + local card_h = self.img_card:getHeight() * scale local cx = self.x + card_w / 2 local cy = self.y + card_h / 2 love.graphics.setScissor(self.x, self.y, card_w, card_h) love.graphics.setColor(1, 0, 0, 0.5) - love.graphics.arc("fill", cx, cy, 130, -math.pi / 2, + local arc_r = 130 * scale + love.graphics.arc("fill", cx, cy, arc_r, -math.pi / 2, -math.pi / 2 + (2 * math.pi * (self.current_cooldown / self.cooldown)), 100) love.graphics.setColor(1, 1, 1, 1) love.graphics.setScissor() diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index b1df5cb..99fba29 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -3,7 +3,6 @@ local Char = { current_life = 0, scale_x = 1, - last_x = 0, allies_around = {}, @@ -19,25 +18,23 @@ local Char = { timeout = 0 } --- anim8 draws with top-left at (char_x, char_y) when scale_x >= 0, and with the --- right edge at char_x when flipped (scale_x < 0). Match hitboxes, range circles, --- and lifebar to that footprint. +-- World-space positions are unchanged; this only scales on-screen sprites vs. map/towers. +local RENDER_SCALE = 1.2 + +-- Sprites face left: scale_x 1 = unflipped (left), -1 = flipped (right). Love2D draws +-- from top-left when scale_x > 0; scale_x < 0 mirrors horizontally. Match hitboxes, +-- range circles, and lifebar to that footprint. local function char_frame_size(c) local fw = c.frame_width or 60 local fh = c.frame_height or 60 return fw, fh end -local function char_hit_top_left(c) - local fw, fh = char_frame_size(c) - if (c.scale_x or 1) < 0 then - return c.char_x - fw, c.char_y - end - return c.char_x, c.char_y -end local function char_range_center(c) local fw, fh = char_frame_size(c) + fw = fw * RENDER_SCALE + fh = fh * RENDER_SCALE if (c.scale_x or 1) < 0 then return c.char_x - fw / 2, c.char_y + fh / 2 end @@ -78,13 +75,16 @@ end function Char:preview(x, y) local walk_quad = get_walk_preview_quad(self) + if not walk_quad then return end - local center_x = x - self.frame_width / 2 - local center_y = y - self.frame_height / 2 + local fw = (self.frame_width or 60) * RENDER_SCALE + local fh = (self.frame_height or 60) * RENDER_SCALE + local center_x = x - fw / 2 + local center_y = y - fh / 2 love.graphics.setColor(0.2, 0.2, 0.7, 0.5) - love.graphics.draw(self.img_walk, walk_quad, center_x, center_y) + love.graphics.draw(self.img_walk, walk_quad, center_x, center_y, 0, RENDER_SCALE, RENDER_SCALE) love.graphics.setColor(1, 1, 1, 1) end @@ -96,36 +96,41 @@ function Char:lifebar(x, y, current_life) local fill_ratio = math.max(0, math.min(1, current_life / max_life)) local fill_width = bar_width * fill_ratio - love.graphics.setColor(1, 29 / 255, 29 / 255) + if self.enemy then + love.graphics.setColor(1, 29 / 255, 29 / 255) + else + love.graphics.setColor(0, 200 / 255, 0) + end love.graphics.rectangle("line", x, y, bar_width, bar_height) love.graphics.rectangle("fill", x, y, fill_width, bar_height) love.graphics.setColor(1, 1, 1, 1) end -function Char:get_action(current_action) - if not self.predicted then - return +function Char:update(dt) + local anim = self.animations[self.current_action] + if anim and type(anim.update) == 'function' then + anim:update(dt) + end + + local prev_x = self._prev_char_x + if prev_x == nil then + prev_x = self.char_x end - if current_action == 'walk' then + if self.predicted and self.current_action == 'walk' then local new_position = self.enemy - and self.char_x - self.speed - or self.char_x + self.speed - + and self.char_x + self.speed + or self.char_x - self.speed self.char_x = new_position - self.scale_x = self.char_x >= self.last_x and 1 or -1 end -end -function Char:update(dt) - local anim = self.animations[self.current_action] - if anim and type(anim.update) == 'function' then - anim:update(dt) - end + -- Left-facing art: screen-x increasing → face right (-1); decreasing → face left (1). + self.scale_x = self.char_x > prev_x and -1 or 1 + + self._prev_char_x = self.char_x end function Char:draw() - self.last_x = self.char_x love.graphics.setColor(1, 1, 1, 1) local fw = self.frame_width or 60 @@ -144,21 +149,14 @@ function Char:draw() and current_img if has_valid_anim then - current_animation:draw(current_img, self.char_x, self.char_y, 0, self.scale_x, 1) - else - local walk_quad = get_walk_preview_quad(self) - if walk_quad then - love.graphics.draw(self.img_walk, walk_quad, self.char_x, self.char_y, 0, self.scale_x, 1) - end + current_animation:draw(current_img, self.char_x, self.char_y, 0, self.scale_x, RENDER_SCALE) end local bar_w = 46 local lcx, _ = char_range_center(self) local lifebar_x = lcx - bar_w / 2 - local lifebar_y = self.char_y + fh + 4 + local lifebar_y = self.char_y + fh * RENDER_SCALE + 4 self:lifebar(lifebar_x, lifebar_y, self.current_life) - - self:get_action(self.current_action) end return Char diff --git a/src/entities/deck.lua b/src/entities/deck.lua index 43ba455..c864cee 100644 --- a/src/entities/deck.lua +++ b/src/entities/deck.lua @@ -4,7 +4,8 @@ local Card = require('src.entities.card') local uuid = require('lib.uuid') local Deck = { - default_scale = 0.2, + default_scale = 1, + hand_slot_spacing = 80, selectable_cards = 4, deck_selected = {}, @@ -32,7 +33,6 @@ function Deck:load(deck_selected) -- if greather than `selectable_cards`, should rotate cards if #self.deck_selected > self.selectable_cards then - -- get the cards left from deck and make unselectable -- and add to queue for i = self.selectable_cards + 1, #self.deck_selected do @@ -52,7 +52,7 @@ function Deck:update(dt) end function Deck:draw_background() - love.graphics.clear(1,1,1) + love.graphics.clear(1, 1, 1) local window_w, window_h = love.graphics.getDimensions() local image_w, image_h = 40, 40 @@ -65,9 +65,9 @@ function Deck:draw_background() for x = 0, tiles_x - 1 do for y = 0, tiles_y - 1 do if (x + y) % 2 == 0 then - love.graphics.setColor(32/255, 32/255, 32/255) + love.graphics.setColor(32 / 255, 32 / 255, 32 / 255) else - love.graphics.setColor(48/255, 48/255, 48/255) + love.graphics.setColor(48 / 255, 48 / 255, 48 / 255) end love.graphics.rectangle('fill', x * image_w, y * image_h, image_w, image_h) love.graphics.setColor(1, 1, 1) @@ -84,16 +84,17 @@ function Deck:draw() self:highlight_selected_card(self.card_selected) end + local s = self.default_scale for i = 1, self.selectable_cards do local card = self.deck_selected[i] -- just in case the deck has less than `selectable_cards` if card == nil then return end - love.graphics.draw(card.img_card, card.x, card.y) + love.graphics.draw(card.img_card, card.x, card.y, 0, s, s) if card.is_card_loading then - card:draw_loading_animation() + card:draw_loading_animation(s) end end @@ -107,13 +108,14 @@ function Deck:define_positions() local position = Layout:down_right(196, 56) -- assign default positions + local step = self.hand_slot_spacing for i = 1, self.selectable_cards do local card = self.deck_selected[i] -- for cases when the deck has less than 4 cards if card == nil then return end - card.x = position.width - (i * 200) + card.x = position.width - (i * step) card.y = position.height - 50 -- padding end @@ -130,13 +132,17 @@ end -- the next card on queue function Deck:draw_preview_card() - love.graphics.draw(self.queue_next_cards[1].img_card, self.queue_next_cards[1].x, self.queue_next_cards[1].y, 0, 0.65 * self.default_scale, 0.65 * self.default_scale) + local ps = self.default_scale * 1.15 + love.graphics.draw(self.queue_next_cards[1].img_card, self.queue_next_cards[1].x, self.queue_next_cards[1].y, 0, ps, ps) end function Deck:highlight_selected_card(card) - love.graphics.setColor(1,0,0) - love.graphics.rectangle("fill", card.x - 4, card.y - 4, card.img_card:getWidth() + 8, card.img_card:getHeight() + 8) - love.graphics.setColor(1,1,1) + local s = self.default_scale + local w = card.img_card:getWidth() * s + local h = card.img_card:getHeight() * s + love.graphics.setColor(1, 0, 0) + love.graphics.rectangle("fill", card.x - 4, card.y - 4, w + 8, h + 8) + love.graphics.setColor(1, 1, 1) end function Deck:set_queue_next_cards(deck) @@ -213,12 +219,15 @@ function Deck:mousepressed(x, y, button) -- right click if button ~= 1 then return end + local s = self.default_scale for _, card in pairs(self.deck_selected) do + local cw = card.img_card:getWidth() * s + local ch = card.img_card:getHeight() * s -- click on card? if ( - x >= card.x and x <= (card.x + card.img_card:getWidth()) - and y >= card.y and y <= (card.y + card.img_card:getHeight()) - ) then + x >= card.x and x <= (card.x + cw) + and y >= card.y and y <= (card.y + ch) + ) then if not card.is_card_loading then if self.card_selected == card then self.card_selected = nil @@ -231,9 +240,8 @@ function Deck:mousepressed(x, y, button) -- this is the selected card? if self.card_selected == card then -- click on map? - if not (x >= card.x and x <= (card.x + card.img_card:getWidth())) - and not (y >= card.y and y <= (card.y + card.img_card:getHeight())) then - + if not (x >= card.x and x <= (card.x + cw)) + and not (y >= card.y and y <= (card.y + ch)) then card.char_x = x card.char_y = y diff --git a/src/helpers/animation.lua b/src/helpers/animation.lua index 12857ad..267f813 100644 --- a/src/helpers/animation.lua +++ b/src/helpers/animation.lua @@ -3,13 +3,13 @@ local anim8 = require('lib.anim8') local Animation = {} function Animation:new(card, action) - local action_image = card['img_'..action] + local action_image = card['img_' .. action] local number_frames = math.floor(action_image:getWidth() / card.frame_width) local grid = anim8.newGrid(card.frame_width, card.frame_height, action_image:getWidth(), action_image:getHeight()) - return anim8.newAnimation(grid('1-'..number_frames, 1), card.speed/10) + return anim8.newAnimation(grid('1-' .. number_frames, 1), card.speed / 10) end return Animation diff --git a/src/states/game.lua b/src/states/game.lua index 3c7ca74..b85d97d 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -35,7 +35,7 @@ local Game = { } function Game:mirror_x(x) - if self.player_side == 'right' then + if self.player_side == 'left' then return MAP_WIDTH - x end return x @@ -148,10 +148,10 @@ end -- private functions --------- function Game:load_towers() - table.insert(self.cards[Constants.USER_ID], Tower:load('left', 'top')) - table.insert(self.cards[Constants.USER_ID], Tower:load('left', 'bottom')) - table.insert(self.cards[Constants.ENEMY_ID], Tower:load('right', 'top')) - table.insert(self.cards[Constants.ENEMY_ID], Tower:load('right', 'bottom')) + table.insert(self.cards[Constants.USER_ID], Tower:load('right', 'top', Constants.USER_ID .. '_tower_top')) + table.insert(self.cards[Constants.USER_ID], Tower:load('right', 'bottom', Constants.USER_ID .. '_tower_bottom')) + table.insert(self.cards[Constants.ENEMY_ID], Tower:load('left', 'top', Constants.ENEMY_ID .. '_tower_top')) + table.insert(self.cards[Constants.ENEMY_ID], Tower:load('left', 'bottom', Constants.ENEMY_ID .. '_tower_bottom')) end function Game:draw_towers() @@ -242,6 +242,20 @@ function Game:get_card_template(card_name, owner_id) end end +function Game:remirror_chars_from_stored_world_x() + for _, bucket_id in ipairs({ Constants.USER_ID, Constants.ENEMY_ID }) do + local ents = self.cards[bucket_id] + if ents then + for _, card in pairs(ents) do + if card.type == 'char' and card._world_x then + card.char_x = self:mirror_x(card._world_x) + card._prev_char_x = card.char_x + end + end + end + end +end + function Game:apply_entity_state(entity) if not entity or not entity.entity_id or not entity.owner_id then return end @@ -261,6 +275,8 @@ function Game:apply_entity_state(entity) card.card_id = entity.entity_id card.predicted = false card.enemy = bucket == Constants.ENEMY_ID + -- Spritesheets face left: scale_x 1 = as drawn (left), -1 = flipped (right). + -- Enemies on the left walk right toward the player; our units walk left toward them. card.scale_x = card.enemy and -1 or 1 self.cards[bucket][entity.entity_id] = card end @@ -282,18 +298,11 @@ function Game:apply_entity_state(entity) card.current_life = entity.current_life or card.current_life card.life = entity.max_life or card.life - local screen_x = entity.x and self:mirror_x(entity.x) or card.char_x - local target_y = entity.y or card.char_y - - if not is_new and entity.x and card.last_screen_x then - local dx = screen_x - card.last_screen_x - if dx > 1 then - card.scale_x = 1 - elseif dx < -1 then - card.scale_x = -1 - end + if entity.x then + card._world_x = entity.x end - if entity.x then card.last_screen_x = screen_x end + local screen_x = entity.x and self:mirror_x(card._world_x) or card.char_x + local target_y = entity.y or card.char_y if has_authoritative_position and prev_x and prev_y and card.predicted == false then local alpha = 0.35 @@ -304,6 +313,10 @@ function Game:apply_entity_state(entity) card.char_y = target_y end + if card.type == 'char' then + card._prev_char_x = card.char_x + end + card.predicted = false card.has_authoritative_position = true end @@ -315,15 +328,18 @@ function Game:apply_snapshot(snapshot) self.last_match_tick = snapshot.match_tick or self.last_match_tick self.match_winner_id = snapshot.winner_id or self.match_winner_id + local player_side_was_unknown = self.player_side == nil if self.player_side == nil and snapshot.towers then - local screen_center = love.graphics.getWidth() / 2 for _, t in ipairs(snapshot.towers) do if t.owner_id == Constants.USER_ID then - self.player_side = t.x > screen_center and 'right' or 'left' + self.player_side = t.x > MAP_WIDTH / 2 and 'right' or 'left' break end end end + if player_side_was_unknown and self.player_side ~= nil then + self:remirror_chars_from_stored_world_x() + end local seen = {} @@ -352,11 +368,8 @@ function Game:apply_snapshot(snapshot) local tower_bucket = resolve_card_bucket(tower_state.owner_id) local towers = self.cards[tower_bucket] or {} for _, tower in ipairs(towers) do - if tower.type == 'tower' then - local same_band = math.abs((tower.char_y or 0) - (tower_state.y or 0)) < 160 - if same_band then - tower.current_life = tower_state.current_life - end + if tower.type == 'tower' and tower.tower_id == tower_state.tower_id then + tower.current_life = tower_state.current_life end end end @@ -394,22 +407,15 @@ function Game:spawn_card_intent(card, payload) end)) end -function Game:update_player_status() - self.me_status:update() - self.enemy_status:update() -end +-- TODO: UI player status in game +function Game:update_player_status() end -function Game:draw_player_status() - -- self.me_status:draw() - -- self.enemy_status:draw() -end +function Game:draw_player_status() end function Game:mousepressed(x, y, button) Deck:mousepressed(x, y, button) end -function Game:resize() - -end +function Game:resize() end return Game From 48e56b2d5f2a7f6fb3c61ac6ecfb52777b7e0f2c Mon Sep 17 00:00:00 2001 From: ropoko Date: Fri, 27 Mar 2026 23:59:14 -0300 Subject: [PATCH 09/16] fix: lint --- src/entities/cards/char.lua | 1 - src/states/game.lua | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index 99fba29..509b63a 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -133,7 +133,6 @@ end function Char:draw() love.graphics.setColor(1, 1, 1, 1) - local fw = self.frame_width or 60 local fh = self.frame_height or 60 local rcx, rcy = char_range_center(self) diff --git a/src/states/game.lua b/src/states/game.lua index b85d97d..f42a57d 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -226,7 +226,7 @@ function Game:handle_opcode_event(opcode, user_id, data) end function Game:get_card_template(card_name, owner_id) - local search_decks = {} + local search_decks if owner_id == Constants.USER_ID then search_decks = { Deck.deck_selected, EnemyDeck.deck } else From 0d4b7585ab417a7759dc639fa5a837576e07b357 Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 12:41:57 -0300 Subject: [PATCH 10/16] feat: moved mirroring logic to server --- src/states/game.lua | 48 ++------------------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/src/states/game.lua b/src/states/game.lua index f42a57d..d675664 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -13,8 +13,6 @@ local PlayerStatus = require('src.ui.player-status') local json = require('lib.json') local Assets = require('src.assets') -local MAP_WIDTH = 1344 - local function resolve_card_bucket(owner_id) if owner_id == Constants.USER_ID then return Constants.USER_ID @@ -28,19 +26,11 @@ local Game = { pending_spawns = {}, last_match_tick = 0, match_winner_id = nil, - player_side = nil, me_status = PlayerStatus:new('2d618372-1220-49b3-b22e-00f6ca0c12a5'), enemy_status = PlayerStatus:new('2d618372-1220-49b3-b22e-00f6ca0c12a5') } -function Game:mirror_x(x) - if self.player_side == 'left' then - return MAP_WIDTH - x - end - return x -end - function Game:load() Assets.TOWER = love.graphics.newImage('assets/tower.png') @@ -57,7 +47,6 @@ function Game:load() self.pending_spawns = {} self.last_match_tick = 0 self.match_winner_id = nil - self.player_side = nil self:load_towers() @@ -242,20 +231,6 @@ function Game:get_card_template(card_name, owner_id) end end -function Game:remirror_chars_from_stored_world_x() - for _, bucket_id in ipairs({ Constants.USER_ID, Constants.ENEMY_ID }) do - local ents = self.cards[bucket_id] - if ents then - for _, card in pairs(ents) do - if card.type == 'char' and card._world_x then - card.char_x = self:mirror_x(card._world_x) - card._prev_char_x = card.char_x - end - end - end - end -end - function Game:apply_entity_state(entity) if not entity or not entity.entity_id or not entity.owner_id then return end @@ -298,10 +273,7 @@ function Game:apply_entity_state(entity) card.current_life = entity.current_life or card.current_life card.life = entity.max_life or card.life - if entity.x then - card._world_x = entity.x - end - local screen_x = entity.x and self:mirror_x(card._world_x) or card.char_x + local screen_x = entity.x or card.char_x local target_y = entity.y or card.char_y if has_authoritative_position and prev_x and prev_y and card.predicted == false then @@ -328,19 +300,6 @@ function Game:apply_snapshot(snapshot) self.last_match_tick = snapshot.match_tick or self.last_match_tick self.match_winner_id = snapshot.winner_id or self.match_winner_id - local player_side_was_unknown = self.player_side == nil - if self.player_side == nil and snapshot.towers then - for _, t in ipairs(snapshot.towers) do - if t.owner_id == Constants.USER_ID then - self.player_side = t.x > MAP_WIDTH / 2 and 'right' or 'left' - break - end - end - end - if player_side_was_unknown and self.player_side ~= nil then - self:remirror_chars_from_stored_world_x() - end - local seen = {} for _, entity in ipairs(snapshot.cards) do @@ -393,15 +352,12 @@ function Game:spawn_card_intent(card, payload) card_id = payload.card_id } - local server_payload = Utils.copy_table(payload) - server_payload.x = self:mirror_x(payload.x) - coroutine.resume(coroutine.create(function() socket.match_data_send( Constants.SOCKET_CONNECTION, Constants.MATCH_ID, MatchEvents.spawn_intent, - json.encode(server_payload), + json.encode(payload), nil ) end)) From 5c16772828072b046231cf16a40ffccfd5960770 Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 15:29:23 -0300 Subject: [PATCH 11/16] refactor: clarify scale_x logic for character movement and update handling --- src/entities/cards/char.lua | 9 +++++++-- src/states/game.lua | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index 509b63a..d327aab 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -124,8 +124,13 @@ function Char:update(dt) self.char_x = new_position end - -- Left-facing art: screen-x increasing → face right (-1); decreasing → face left (1). - self.scale_x = self.char_x > prev_x and -1 or 1 + -- Left-facing art: scale_x 1 = left, -1 = mirrored (right). + -- Screen +x → move right → face right (-1); screen -x → face left (1). + if self.char_x > prev_x then + self.scale_x = -1 + elseif self.char_x < prev_x then + self.scale_x = 1 + end self._prev_char_x = self.char_x end diff --git a/src/states/game.lua b/src/states/game.lua index d675664..25f621c 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -250,9 +250,7 @@ function Game:apply_entity_state(entity) card.card_id = entity.entity_id card.predicted = false card.enemy = bucket == Constants.ENEMY_ID - -- Spritesheets face left: scale_x 1 = as drawn (left), -1 = flipped (right). - -- Enemies on the left walk right toward the player; our units walk left toward them. - card.scale_x = card.enemy and -1 or 1 + card._prev_char_x = nil self.cards[bucket][entity.entity_id] = card end @@ -286,7 +284,19 @@ function Game:apply_entity_state(entity) end if card.type == 'char' then - card._prev_char_x = card.char_x + -- Keep previous screen X so Char:update can derive walk direction for scale_x. + -- First apply: avoid template char_x (often 0) vs real spawn X (huge fake delta). + if is_new then + card._prev_char_x = screen_x + else + card._prev_char_x = prev_x + end + -- Network handlers may run after Char:update; set facing immediately for this frame. + if card.char_x > card._prev_char_x then + card.scale_x = -1 + elseif card.char_x < card._prev_char_x then + card.scale_x = 1 + end end card.predicted = false From 20093d32c3c3fa500ef484f9a57246634ee2bfc6 Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 15:40:42 -0300 Subject: [PATCH 12/16] refactor: improve character range center calculation and update drawing logic --- src/entities/cards/char.lua | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index d327aab..68703d4 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -31,14 +31,15 @@ local function char_frame_size(c) end +-- Center of the on-screen sprite footprint (matches Char:draw sx/sy = scale_x*RENDER_SCALE, RENDER_SCALE). local function char_range_center(c) local fw, fh = char_frame_size(c) - fw = fw * RENDER_SCALE - fh = fh * RENDER_SCALE + local scaled_w = fw * RENDER_SCALE + local scaled_h = fh * RENDER_SCALE if (c.scale_x or 1) < 0 then - return c.char_x - fw / 2, c.char_y + fh / 2 + return c.char_x - scaled_w / 2, c.char_y + scaled_h / 2 end - return c.char_x + fw / 2, c.char_y + fh / 2 + return c.char_x + scaled_w / 2, c.char_y + scaled_h / 2 end local function get_walk_preview_quad(char) @@ -126,10 +127,15 @@ function Char:update(dt) -- Left-facing art: scale_x 1 = left, -1 = mirrored (right). -- Screen +x → move right → face right (-1); screen -x → face left (1). - if self.char_x > prev_x then - self.scale_x = -1 - elseif self.char_x < prev_x then - self.scale_x = 1 + -- Only derive facing here for client-predicted walk: authoritative positions + -- and scale_x are applied in Game:apply_entity_state; comparing char_x to + -- _prev_char_x here would usually see no delta (or fight apply's facing). + if self.predicted and self.current_action == 'walk' then + if self.char_x > prev_x then + self.scale_x = -1 + elseif self.char_x < prev_x then + self.scale_x = 1 + end end self._prev_char_x = self.char_x @@ -153,7 +159,14 @@ function Char:draw() and current_img if has_valid_anim then - current_animation:draw(current_img, self.char_x, self.char_y, 0, self.scale_x, RENDER_SCALE) + current_animation:draw( + current_img, + self.char_x, + self.char_y, + 0, + self.scale_x * RENDER_SCALE, + RENDER_SCALE + ) end local bar_w = 46 From aa87e8cb53fe085eaeb2e8d4f8488550b716d3bd Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 15:43:55 -0300 Subject: [PATCH 13/16] feat: add drawing logic for enemy towers in game state --- src/states/game.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/states/game.lua b/src/states/game.lua index 25f621c..87371f5 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -147,6 +147,9 @@ function Game:draw_towers() for _, tower in ipairs(self.cards[Constants.USER_ID]) do tower:draw(tower.current_life) end + for _, tower in ipairs(self.cards[Constants.ENEMY_ID]) do + tower:draw(tower.current_life) + end end function Game:draw_timer() From c2fb9bac0cf1283be5a6311ef1bb9230549a0c12 Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 15:44:48 -0300 Subject: [PATCH 14/16] refactor: update preview card scaling logic in deck --- src/entities/deck.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/entities/deck.lua b/src/entities/deck.lua index c864cee..a159f9b 100644 --- a/src/entities/deck.lua +++ b/src/entities/deck.lua @@ -3,6 +3,8 @@ local Map = require('src.entities.map') local Card = require('src.entities.card') local uuid = require('lib.uuid') +local PREVIEW_QUEUE_SCALE = 0.65 + local Deck = { default_scale = 1, hand_slot_spacing = 80, @@ -132,7 +134,7 @@ end -- the next card on queue function Deck:draw_preview_card() - local ps = self.default_scale * 1.15 + local ps = self.default_scale * PREVIEW_QUEUE_SCALE love.graphics.draw(self.queue_next_cards[1].img_card, self.queue_next_cards[1].x, self.queue_next_cards[1].y, 0, ps, ps) end From 6a519e113d1b1640853a9d440b9b03052556207a Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 15:55:44 -0300 Subject: [PATCH 15/16] feat: implement death animation handling and cleanup for character entities --- src/entities/cards/char.lua | 26 +++++++++++++++++++++ src/states/game.lua | 46 +++++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index 68703d4..04c5be6 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -62,6 +62,26 @@ local function get_walk_preview_quad(char) return char.walk_preview_quad end +local function get_death_animation_duration(char) + if char.death_animation_duration then + return char.death_animation_duration + end + + local default_duration = 0.35 + if not char.img_death or not char.frame_width then + char.death_animation_duration = default_duration + return char.death_animation_duration + end + + local total_width = char.img_death:getWidth() or 0 + local frame_width = char.frame_width or 0 + local number_frames = math.max(1, math.floor(total_width / frame_width)) + local frame_duration = (char.speed or 1) / 10 + + char.death_animation_duration = math.max(default_duration, number_frames * frame_duration) + return char.death_animation_duration +end + function Char:get_enemies_in_range(enemies) return end @@ -113,6 +133,12 @@ function Char:update(dt) anim:update(dt) end + if self.current_action == 'death' then + self.death_elapsed = (self.death_elapsed or 0) + dt + self.death_animation_duration = get_death_animation_duration(self) + return + end + local prev_x = self._prev_char_x if prev_x == nil then prev_x = self.char_x diff --git a/src/states/game.lua b/src/states/game.lua index 87371f5..0cf0471 100644 --- a/src/states/game.lua +++ b/src/states/game.lua @@ -105,6 +105,8 @@ function Game:update(dt) for _, enemy_card in pairs(self.cards[Constants.ENEMY_ID]) do enemy_card:update(dt) end + + self:cleanup_finished_deaths() end function Game:draw() @@ -194,7 +196,7 @@ function Game:handle_opcode_event(opcode, user_id, data) if data and data.entity_id and data.owner_id then local bucket = resolve_card_bucket(data.owner_id) if self.cards[bucket] then - self.cards[bucket][data.entity_id] = nil + self:queue_entity_removal(bucket, data.entity_id) end end return @@ -273,6 +275,10 @@ function Game:apply_entity_state(entity) card.current_action = entity.action or card.current_action or 'walk' card.current_life = entity.current_life or card.current_life card.life = entity.max_life or card.life + if card.current_action ~= 'death' then + card.pending_removal = false + card.death_elapsed = 0 + end local screen_x = entity.x or card.char_x local target_y = entity.y or card.char_y @@ -329,7 +335,7 @@ function Game:apply_snapshot(snapshot) if type(entity_id) == 'string' and card.type == 'char' then local is_seen = seen[bucket_id] and seen[bucket_id][entity_id] if not is_seen and not card.predicted then - entities[entity_id] = nil + self:queue_entity_removal(bucket_id, entity_id) end end end @@ -376,6 +382,42 @@ function Game:spawn_card_intent(card, payload) end)) end +function Game:queue_entity_removal(bucket, entity_id) + local entities = self.cards[bucket] + if not entities then return end + + local entity = entities[entity_id] + if not entity then return end + + if entity.type ~= 'char' then + entities[entity_id] = nil + return + end + + entity.pending_removal = true + if entity.current_action ~= 'death' then + entity.current_action = 'death' + entity.death_elapsed = 0 + end +end + +function Game:cleanup_finished_deaths() + for _, bucket in ipairs({ Constants.USER_ID, Constants.ENEMY_ID }) do + local entities = self.cards[bucket] + if entities then + for entity_id, entity in pairs(entities) do + if entity.pending_removal and entity.type == 'char' then + local elapsed = entity.death_elapsed or 0 + local duration = entity.death_animation_duration or 0.35 + if elapsed >= duration then + entities[entity_id] = nil + end + end + end + end + end +end + -- TODO: UI player status in game function Game:update_player_status() end From bda50ba91fe65ef47b68d12c8ad625891ab33f89 Mon Sep 17 00:00:00 2001 From: ropoko Date: Sun, 29 Mar 2026 16:40:28 -0300 Subject: [PATCH 16/16] refactor: use attack_speed for attack animation --- src/entities/cards/char.lua | 21 --------------------- src/helpers/animation.lua | 14 +++++++++++++- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/entities/cards/char.lua b/src/entities/cards/char.lua index 04c5be6..a41f303 100644 --- a/src/entities/cards/char.lua +++ b/src/entities/cards/char.lua @@ -18,12 +18,8 @@ local Char = { timeout = 0 } --- World-space positions are unchanged; this only scales on-screen sprites vs. map/towers. local RENDER_SCALE = 1.2 --- Sprites face left: scale_x 1 = unflipped (left), -1 = flipped (right). Love2D draws --- from top-left when scale_x > 0; scale_x < 0 mirrors horizontally. Match hitboxes, --- range circles, and lifebar to that footprint. local function char_frame_size(c) local fw = c.frame_width or 60 local fh = c.frame_height or 60 @@ -31,7 +27,6 @@ local function char_frame_size(c) end --- Center of the on-screen sprite footprint (matches Char:draw sx/sy = scale_x*RENDER_SCALE, RENDER_SCALE). local function char_range_center(c) local fw, fh = char_frame_size(c) local scaled_w = fw * RENDER_SCALE @@ -82,17 +77,6 @@ local function get_death_animation_duration(char) return char.death_animation_duration end -function Char:get_enemies_in_range(enemies) - return -end - -function Char:check_attack_range() - return -end - -function Char:get_nearest_enemy() - return nil -end function Char:preview(x, y) local walk_quad = get_walk_preview_quad(self) @@ -151,11 +135,6 @@ function Char:update(dt) self.char_x = new_position end - -- Left-facing art: scale_x 1 = left, -1 = mirrored (right). - -- Screen +x → move right → face right (-1); screen -x → face left (1). - -- Only derive facing here for client-predicted walk: authoritative positions - -- and scale_x are applied in Game:apply_entity_state; comparing char_x to - -- _prev_char_x here would usually see no delta (or fight apply's facing). if self.predicted and self.current_action == 'walk' then if self.char_x > prev_x then self.scale_x = -1 diff --git a/src/helpers/animation.lua b/src/helpers/animation.lua index 267f813..98dc280 100644 --- a/src/helpers/animation.lua +++ b/src/helpers/animation.lua @@ -9,7 +9,19 @@ function Animation:new(card, action) local grid = anim8.newGrid(card.frame_width, card.frame_height, action_image:getWidth(), action_image:getHeight()) - return anim8.newAnimation(grid('1-' .. number_frames, 1), card.speed / 10) + local frame_duration = card.speed / 10 + + if action == 'attack' then + -- attack_speed = attacks per second + local aps = tonumber(card.attack_speed) + if not aps or aps <= 0 then + aps = 1.0 + end + local attack_cycle_seconds = 1 / aps + frame_duration = attack_cycle_seconds / math.max(1, number_frames) + end + + return anim8.newAnimation(grid('1-' .. number_frames, 1), frame_duration) end return Animation