-- Combatant: unified entity (merged Entity + Player + Enemy) Combatant = {} Combatant.__index = Combatant CombatantTeam = { PLAYER = 1, ENEMY = 2 } CombatantState = { ALIVE = 1, DYING = 2, DEAD = 3 } PlayerSkillSlot = { ATTACK = 1, CHARGE = 2, DEFENSIVE = 3, CONSUMABLE = 4 } local flashShader = love.graphics.newShader [[ vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { vec4 pixel = Texel(texture, texture_coords); if (pixel.a > 0.0) { return vec4(1.0, 1.0, 1.0, pixel.a) * color; } return vec4(0.0, 0.0, 0.0, 0.0); } ]] function Combatant:new(asset, context) self = setmetatable({}, Combatant) local config = asset.config or {} self.context = context self.config = config self.x = config.x or 0 self.y = config.y or 0 self.velX = 0 self.velY = 0 self.grounded = false self.maxVelY = 600 self.facing = 1 self.team = config.team or CombatantTeam.PLAYER self.name = config.name self.behavior = nil self.projectiles = {} self.stats = config.stats or { atk = 0, def = 0, mag = 0, lck = 0 } self.health = config.health or 0 self.maxHealth = self.health self.activeSkills = {} self.skillCooldowns = {} self.skillAnimation = nil self.sprite = Sprite:new(asset.image, Utils.mergeTables({ size = Config.DEFAULT_COMBATANT_SPRITE_SIZE, fps = 15 }, config or {})) self.width = self.sprite.width self.height = self.sprite.height self.floatingTexts = {} self.hitFlash = 0 self.knockbackVelX = 0 self.knockbackTimer = 0 self.visualOffsetY = 0 self.visualOffsetVel = 0 self.state = CombatantState.ALIVE self.deathTimer = 0 self.moveX = 0 self.speed = config.speed or Config.DEFAULT_COMBATANT_SPEED self.invincible = false -- Enemy Vars self.drops = config.drops self.hasDropped = false -- Player Vars self.playerIdx = config.playerIdx self.slot = config.slot self.charName = config.charName self.color = config.color self.coyoteTimer = 0 self.jumpBufferTimer = 0 self.prevGrounded = false self.weapon = config.weapon -- Player Skills self.isCharging = false self.chargeTime = 0 self.chargeEffect = nil self.skillAssets = {} return self end function Combatant:update(dt) self.hitFlash = math.max(0, self.hitFlash - dt) if self.knockbackTimer > 0 then self.knockbackTimer = self.knockbackTimer - dt self.velX = self.knockbackVelX self.knockbackVelX = self.knockbackVelX * 0.9 end self.visualOffsetVel = self.visualOffsetVel + 400 * dt self.visualOffsetY = self.visualOffsetY + self.visualOffsetVel * dt if self.visualOffsetY >= 0 and self.visualOffsetVel > 0 then self.visualOffsetY = 0 self.visualOffsetVel = 0 end self:updateSkill(dt) self:updateSkillCooldown(dt) self:updateFloatingText(dt) -- Trigger dying state when health drops to 0 and knockback has finished if self.state == CombatantState.ALIVE and self.health <= 0 and self.knockbackTimer <= 0 then self.state = CombatantState.DYING self.deathTimer = Config.DEATH_DURATION self.velX = 0 self.velY = 0 self.frameIndex = 1 self.frameTimer = 0 ParticlePlayer:play("explosion", self.x + self.width / 2, self.y + self.height / 2) end -- Update dying state countdown if self.state == CombatantState.DYING then self.deathTimer = (self.deathTimer or Config.DEATH_DURATION) - dt self.sprite:setAnimation("death") if self.deathTimer <= 0 then self.state = CombatantState.DEAD end end -- Horizontal acceleration / deceleration if self.state == CombatantState.ALIVE and self.knockbackTimer <= 0 then local maxSpeed = self.speed or Config.PLAYER_SPEED if self.moveX ~= 0 then if self.moveX * self.velX > 0 then self.velX = self.velX + self.moveX * Config.ACCELERATION * dt else self.velX = self.velX + self.moveX * Config.DECELERATION * dt end if math.abs(self.velX) > maxSpeed then self.velX = self.moveX * maxSpeed end else if self.velX > 0 then self.velX = math.max(0, self.velX - Config.DECELERATION * dt) elseif self.velX < 0 then self.velX = math.min(0, self.velX + Config.DECELERATION * dt) end end end -- Physics self:applyPhysics(dt) -- Behavior if self.behavior then self.behavior:update(dt) end -- Sprite self.sprite:update(dt) end -- === Physics / collision === function Combatant:getBounds() local pLeft = math.floor(self.x) local pRight = math.ceil(self.x + self.width) - 1 local pTop = math.floor(self.y) local pBot = math.ceil(self.y + self.height) - 1 return pLeft, pRight, pTop, pBot end function Combatant:isSolid(px, py, roomCollision) if px < 0 or px >= Config.ROOM_W or py < 0 or py >= Config.ROOM_H then return true end if roomCollision then return Utils.maskIsSolidAt(px, py, roomCollision) end return false end function Combatant:resolveCollisionX(roomConfig) local pLeft, pRight, pTop, pBot = self:getBounds() if self.velX < 0 then local maxSolidCol = -1 for col = pLeft, pRight do for row = pTop, pBot do if self:isSolid(col, row, roomConfig) then maxSolidCol = col end end end if maxSolidCol >= pLeft then self.x = maxSolidCol + 1 self.velX = 0 end elseif self.velX > 0 then local minSolidCol = Config.ROOM_W for col = pLeft, pRight do for row = pTop, pBot do if self:isSolid(col, row, roomConfig) then if col < minSolidCol then minSolidCol = col end end end end if minSolidCol <= pRight then self.x = minSolidCol - self.width self.velX = 0 end end end function Combatant:resolveCollisionY(roomCollision) local pLeft, pRight, pTop, pBot = self:getBounds() if self.velY < 0 then local maxSolidRow = -1 for row = pTop, pBot do for col = pLeft, pRight do if self:isSolid(col, row, roomCollision) then maxSolidRow = row end end end if maxSolidRow >= pTop then self.y = maxSolidRow + 1 self.velY = 0 end elseif self.velY > 0 then local minSolidRow = Config.ROOM_H for row = pTop, pBot do for col = pLeft, pRight do if self:isSolid(col, row, roomCollision) then if row < minSolidRow then minSolidRow = row end end end end if minSolidRow <= pBot then self.y = minSolidRow - self.height self.velY = 0 end end end function Combatant:checkGrounded(roomConfig) local pBot = math.ceil(self.y + self.height) - 1 local pLeft = math.floor(self.x) local pRight = math.ceil(self.x + self.width) - 1 local centerL = pLeft + 2 local centerR = pRight - 2 for col = centerL, centerR do if self:isSolid(col, pBot + 1, roomConfig) then return true end end return false end function Combatant:applyPhysics(dt) local roomCollision = self.context.roomManager.currentRoomCollision if not self.context.roomManager.currentRoomCollision then return end if not self.grounded then self.velY = self.velY + Config.GRAVITY * dt if self.velY > self.maxVelY then self.velY = self.maxVelY end end self.x = self.x + self.velX * dt self:resolveCollisionX(roomCollision) self.y = self.y + self.velY * dt self:resolveCollisionY(roomCollision) self.grounded = self:checkGrounded(roomCollision) if self.x < 0 then self.x = 0 end if self.x + self.width > Config.ROOM_W then self.x = Config.ROOM_W - self.width end if self.y < 0 then self.y = 0 end if self.y + self.height > Config.ROOM_H then self.y = Config.ROOM_H - self.height self.velY = 0 self.grounded = true end end -- === Skills === function Combatant:updateFloatingText(dt) for i = #self.floatingTexts, 1, -1 do self.floatingTexts[i]:update(dt) if not self.floatingTexts[i].alive then table.remove(self.floatingTexts, i) end end end function Combatant:updateSkill(dt) for i = #self.activeSkills, 1, -1 do local skill = self.activeSkills[i] if skill:update(dt) then table.remove(self.activeSkills, i) end end if #self.activeSkills == 0 then self.skillAnimation = nil end end function Combatant:updateSkillCooldown(dt) for k, v in pairs(self.skillCooldowns) do self.skillCooldowns[k] = v - dt if v <= 0 then self.skillCooldowns[k] = 0 end end end function Combatant:activateSkillByAsset(asset) if self.skillCooldowns[asset.name] and self.skillCooldowns[asset.name] > 0 then return end local skill = Skills[asset.config.skill or asset.name]:new(asset, self) table.insert(self.activeSkills, skill) self.skillCooldowns[asset.name] = asset.config.cooldown or 0.1 end function Combatant:activateSkillBySlot(skillSlot) if self.skillAssets[skillSlot] then self:activateSkillByAsset(self.skillAssets[skillSlot]) end end -- === Combat === function Combatant:damage(amount) if amount > 0 and not self.invincible then Sound.play("damage") self.health = self.health - amount self:addFloatingText(tostring(amount), { 1, 0.3, 0.3 }) end end function Combatant:heal(amount) ParticlePlayer:play("heal", self.x + (self.width / 2), self.y + (self.height / 2), { duration = 0.8 }) if amount > 0 then self.health = math.min(self.health + amount, self.maxHealth) self:addFloatingText("+" .. tostring(amount), { 0.2, 1, 0.4 }) end end function Combatant:knockback(direction, strength) direction = direction or self.facing * -1 strength = strength or 150 self.knockbackVelX = direction * strength self.knockbackTimer = 0.08 self.hitFlash = 0.12 self.visualOffsetVel = -100 end -- === Drawing helpers (used by behaviors) === function Combatant:addFloatingText(text, color) table.insert(self.floatingTexts, FloatingText:new( self.x + self.width / 2, self.y + (self.visualOffsetY or 0), text, { color = color } )) end function Combatant:drawSprite() if not self.sprite then return end local drawX = self.x local drawY = self.y + self.visualOffsetY if self.facing == -1 then drawX = drawX + self.width end if self.hitFlash > 0 then love.graphics.setShader(flashShader) end self.sprite:drawAnimationFrame(drawX, drawY, { scale = { self.facing, 1 } }) love.graphics.setShader() end function Combatant:drawFloatingText() for _, ft in ipairs(self.floatingTexts) do ft:draw() end end function Combatant:drawHealthBar(x, y, w, h, color) local x = math.floor(x) local y = math.floor(y) local healthPct = math.max(0, self.health / self.maxHealth) love.graphics.setColor(0.3, 0.3, 0.3, 1) love.graphics.rectangle("fill", x, y, w, h) love.graphics.setColor(color[1], color[2], color[3], 1) love.graphics.rectangle("fill", x, y, w * healthPct, h) love.graphics.setColor(0.1, 0.1, 0.1, 1) love.graphics.rectangle("line", x, y, w, h) love.graphics.setColor(1, 1, 1, 1) end function Combatant:drawDying() if not self.sprite then return end local progress = 1 - self.deathTimer / Config.DEATH_DURATION local alpha = 1 - progress local squash = 0 if progress > 0.75 then squash = (progress - 0.75) / 0.25 * 0.3 end local drawY = self.y + (self.height - (self.height * (1 - squash))) love.graphics.setColor(1, 1, 1, alpha) self.sprite:drawAnimationFrame(self.x, drawY, { scale = { self.facing, 1 - squash } }) love.graphics.setColor(1, 1, 1, 1) end function Combatant:equipSkill(skillSlot, skillName) self.skillAssets[skillSlot] = skillName and self.context.assetLoader:loadSkill(skillName) end function Combatant:equipWeapon(weapon) self.weapon = weapon self:equipSkill(PlayerSkillSlot.ATTACK, weapon.attackSkill) self:equipSkill(PlayerSkillSlot.CHARGE, weapon.chargeSkill) end return Combatant