diff --git a/src/enemies.lua b/src/enemies.lua new file mode 100644 index 0000000..8e8e51e --- /dev/null +++ b/src/enemies.lua @@ -0,0 +1,4 @@ +-- Convenience import for all enemy types + +import "enemy/ebi" +import "enemy/ika" diff --git a/src/enemy/ebi.lua b/src/enemy/ebi.lua index f03bbf8..74d058e 100644 --- a/src/enemy/ebi.lua +++ b/src/enemy/ebi.lua @@ -11,15 +11,11 @@ class("Ebi").extends(Entity) function Ebi:init() Ebi.super.init(self, gfx.image.new("images/ebi.png"), 5) - self:setCollidesWithGroupsMask(0x3) - local dir = 1 - if math.random(2) == 1 then - dir = -1 - end - - self.vector = geom.vector2D.new(0, dir) + self.type = 'ebi' +end +function Ebi:onReady() self.weaponTimer = playdate.timer.new(2500, function() local b = Bullet(2, 1) @@ -30,8 +26,9 @@ function Ebi:init() self.weaponTimer.repeats = true end -function Ebi:update() - local collisions = Ebi.super.update(self) +-- Ebi needs to bounce off the walls +function Ebi:runReady() + local collisions = Ebi.super.runReady(self) for i=1, #collisions, 1 do if collisions[i].other:getGroupMask() == 0x1 then self.vector.dy *= -1 @@ -39,7 +36,7 @@ function Ebi:update() end end -function Ebi:delete() - Ebi.super.delete(self) - self.weaponTimer:remove() +function Ebi:remove() + Ebi.super.remove(self) + if self.weaponTimer then self.weaponTimer:remove() end end diff --git a/src/enemy/ika.lua b/src/enemy/ika.lua index f7ef6bc..ce6e93b 100644 --- a/src/enemy/ika.lua +++ b/src/enemy/ika.lua @@ -12,12 +12,15 @@ class("Ika").extends(Entity) function Ika:init(target) Ika.super.init(self, gfx.image.new("images/ika.png"), 25, 1) - + self.target = target self:setCollidesWithGroupsMask(0x2) + self.type = 'ika' +end +function Ika:onReady() self.weaponTimer = playdate.timer.new(7000, function() - local b = Bullet(9, 20, self:calculateVector(target)) + local b = Bullet(9, 20, self:calculateVector(self.target)) b:moveTo(self.x - (self.width/2) - 1, self.y) b:add() end @@ -26,9 +29,12 @@ function Ika:init(target) end function Ika:calculateVector(target) - local vec = geom.vector2D.new(0,0) - vec.dx = target.x - self.x - vec.dy = target.y - self.y - + local vec = geom.point.new(target:getPosition()) - + geom.point.new(self:getPosition()) return vec:normalized() * 3 end + +function Ika:remove() + Ika.super.remove(self) + if self.weaponTimer then self.weaponTimer:remove() end +end diff --git a/src/entity.lua b/src/entity.lua index dcfd8c8..3d30366 100644 --- a/src/entity.lua +++ b/src/entity.lua @@ -2,6 +2,7 @@ import "CoreLibs/object" import "CoreLibs/graphics" import "CoreLibs/sprites" +import "statemachine" local gfx = playdate.graphics local geom = playdate.geometry @@ -10,19 +11,39 @@ class("Entity").extends(gfx.sprite) function Entity:init(img, health, armor) Entity.super.init(self, img) + self.type = "entity" self.health = health or 10 self.armor = armor or 0 + self.introAnimator = nil -- movement direction, every update() the entity will move along this vector and return -- collision data to the subclass self.vector = geom.vector2D.new(0, 0) - self:setCollideRect(0, 0, self:getSize()) -- most entities will be enemies, so we configure this mask by default -- We don't set a collider mask because collision is a bit too variable -- (but we should always include 0x2 and handle player collisions) self:setGroupMask(0x4) + + -- state machine mapping + -- this can be extended by subclasses if they need more states using + -- StateMachine:addState() + local states = { + ["INIT"] = { + main=self.runInit, + transition=self.onInit + }, + ["INTRO"] = { + main=self.runIntro, + transition=self.onIntro, + }, + ["READY"] = { + main=self.runReady, + transition=self.onReady, + }, + } + self.fsm = StateMachine.new({self}, states) end function Entity:damage(amount) @@ -31,16 +52,50 @@ function Entity:damage(amount) self.health = math.max(self.health - (amount - self.armor), 0) if self.health == 0 then - self:delete() + self:remove() + end +end + +function Entity:add() + Entity.super.add(self) + if self.introAnimator then + self.fsm:changeState("INTRO") + else + self.fsm:changeState("READY") end end function Entity:update() - local collisions = select(3, self:moveWithCollisions(self.x + self.vector.dx, self.y + self.vector.dy)) - return collisions + -- update state machine + self.fsm:execute() end --- override this if you create timers -function Entity:delete() - self:remove() +-- State machine-controlled functions + +function Entity:onInit() + -- noop +end + +function Entity:runInit() + -- noop +end + +function Entity:onIntro() + -- noop +end + +function Entity:runIntro() + if self.introAnimator and not self.introAnimator:ended() then + self:moveTo(self.introAnimator:currentValue()) + else + self.fsm:changeState("READY") + end +end + +function Entity:onReady() + -- noop +end + +function Entity:runReady() + return select(3, self:moveWithCollisions(self.x + self.vector.dx, self.y + self.vector.dy)) end diff --git a/src/kani.lua b/src/kani.lua index 44421c0..e0862f1 100644 --- a/src/kani.lua +++ b/src/kani.lua @@ -42,6 +42,7 @@ class("Kani").extends(Entity) function Kani:init(ui) Kani.super.init(self, gfx.image.new("images/kani.png"), 100) + self.type = 'kani' self:setGroupMask(0x2) self:setCollidesWithGroupsMask(0xd) @@ -76,6 +77,8 @@ function Kani:init(ui) end, AButtonUp = function() self:fire() end, } + + self.fsm:changeState("READY") end function Kani:chargeReserve(change) @@ -141,8 +144,8 @@ function Kani:removeInputHandlers() end -- move that crab! -function Kani:update() - local collisions = Kani.super.update(self) +function Kani:runReady() + local collisions = Kani.super.runReady(self) for i=0, #collisions, 1 do -- handle player-triggered collisions end diff --git a/src/main.lua b/src/main.lua index d2ecc52..e7fb03c 100644 --- a/src/main.lua +++ b/src/main.lua @@ -9,13 +9,15 @@ import "CoreLibs/graphics" import "CoreLibs/sprites" import "CoreLibs/timer" import "kani" -import "enemy/ika" -import "enemy/ebi" +import "enemies" +import "wave" local gfx = playdate.graphics +local geom = playdate.geometry local player = nil local ui = nil +local currentWave = nil function setup() ui = UI() @@ -24,26 +26,8 @@ function setup() player:addInputHandlers() player:add() ui:add() - - local enemy = Ika(player) - enemy:moveTo(350, 120) - enemy:add() - - -- enemy = Ebi() - -- enemy:moveTo(270, 50) - -- enemy:add() - - -- enemy = Ebi() - -- enemy:moveTo(280, 100) - -- enemy:add() - - -- enemy = Ebi() - -- enemy:moveTo(290, 150) - -- enemy:add() - - -- enemy = Ebi() - -- enemy:moveTo(300, 200) - -- enemy:add() + currentWave = newWave() + currentWave:add() makeWalls() drawBackground() @@ -81,8 +65,52 @@ function drawBackground() ) end +-- Right now we only have a single wave and we repeat it forever +function newWave() + wave = Wave.new() + + local startPosition = geom.point.new(410,120) + + local enemy = Ika(player) + enemy.introAnimator = gfx.animator.new( + 10000, + startPosition, + geom.point.new(350,120) + ) + + wave:addEntity(enemy) + + local y = 50 + for x=270, 300, 10 do + local dir = 1 + if math.random(2) == 1 then + dir = -1 + end + local vector = geom.vector2D.new(0, dir) + + enemy = Ebi() + enemy.vector = vector + enemy.introAnimator = gfx.animator.new( + 5000, + startPosition, + geom.point.new(x,y) + ) + + wave:addEntity(enemy) + + y += 50 + end + + return wave +end + function playdate.update() gfx.sprite.update() + if currentWave:update() then + -- fight forever lol + currentWave = newWave() + currentWave:add() + end playdate.timer.updateTimers() end diff --git a/src/statemachine.lua b/src/statemachine.lua new file mode 100644 index 0000000..91a743e --- /dev/null +++ b/src/statemachine.lua @@ -0,0 +1,47 @@ +-- A state machine +import "CoreLibs/object" + +class("StateMachine").extends() + +function StateMachine.new(params, stateTable) + local fsm = StateMachine(params, stateTable) + return fsm +end + +-- params is a table of parameters to always pass to the functions +-- stateTable is a table of initial states. Entries should be of the form: +-- name = {main=mainFunc, transition=transFunc} +-- where mainFunc and transFunc are functions that should be executed in relation to the state. +-- the State Machine will call mainFunc on execute() +-- the State Machine will call transFunc when the state is entered +function StateMachine:init(params, stateTable) + self.currentState = nil + self.parameters = params or {} + self.states = stateTable or {} +end + +function StateMachine:addState(name, mainFunc, transFunc) + self.states[name] = {main=mainFunc, transition=transFunc} +end + +-- execute the main function for the current state. +-- returns true if the function was executed, false otherwise +function StateMachine:execute() + if self.currentState == nil or not self.states[self.currentState] then + return false + end + + self.states[self.currentState].main(table.unpack(self.parameters)) + return true +end + +-- change to the specified state. returns true if the state change is successful +function StateMachine:changeState(state) + if not self.states[state] then return false end + + self.currentState = state + if self.states[state].transition then + self.states[state].transition(table.unpack(self.parameters)) + end + return true +end diff --git a/src/wave.lua b/src/wave.lua new file mode 100644 index 0000000..b45a0ca --- /dev/null +++ b/src/wave.lua @@ -0,0 +1,64 @@ +-- A set of enemies that spawn as a group +-- TODO: initialize waves from a file so we can define levels + +import "CoreLibs/object" +import "entity" + +local gfx = playdate.graphics +local geom = playdate.geometry + +class("Wave").extends() + +function Wave.new() + local w = Wave() + return w +end + +function Wave:init() + Wave.super.init(self) + self.entities = {} +end + +-- Adds an entity to the wave. Positions are playdate.geometry.point objects +-- - position is where the entity should end up at the start of the combat sequence +-- - vec is the enemy's starting vector +-- - prePosition is the entity's start location for "flying in" animation +-- - introDuration is the number of milliseconds the animation should take. Use this to control the fly-in speed +function Wave:addEntity(entity) + table.insert(self.entities, entity) +end + +-- Add all the sprites in the Wave to the global table +-- Also initializes their intro animations +function Wave:add() + for i=1, #self.entities, 1 do + self.entities[i]:add() + end +end + +-- Call this every frame for an active Wave +-- It will crawl the Entity list and remove any "dead" +-- entities, and will return true to signal the wave end +-- when all enemies are defeated +function Wave:update() + local dead = {} + for i=1, #self.entities, 1 do + if self.entities[i].health == 0 then + table.insert(dead, i) + print(string.format("%d is dead", i)) + end + end + + -- Starting with the highest index to avoid renumbering errors, + -- we remove dead entities from the list + while #dead > 0 do + table.remove(self.entities, table.remove(dead)) + end + + if #self.entities == 0 then + return true + end + + return false +end +