ü!"""""""D4""DD"""D"""D""CD""D4"   """""" "" "" "" !  "" ! "" !""À ÜÍÀÝÝ ÀÝÝ ÀÝÝ ÀÝÝ ÀÝÝ ADô D$0D4@DD@DDÀÌÌÀÝÍ ÀÌÌÀÌÌÀÝÍ ÀÌÌíÝÿÿíÝÿÿíÝÿÿíݹ›¹›¹› ¹› º«àº«ÞÌÌí @@BAðàOôî?ôîþOôï3D0CD0CH0CD0CC0C4CD€wwpww€wwpff`ffðîîàþïîB$îþDDïþDDïîB$îàîä #2@DD33DD0DD0@0°€ °»» °™™ °‘ °I” °I” "D"""D"""D"""""""D"""D""""""!"""" ""   ! ! ! "" """"PPPUUPUUPPÀÝÝ Àîî Àîî ÀÝÝ ÀÝÝ ÀÝÝ ÀÌÌ @DD@DD0D4 D$DAÀÌÌÀÝÍ ÀÌÌÀÌÌÀÝÍ ÀÌÌÿÿíÝÿÿíÝÿÿíÝßÍÜýàÞíéž¹›¹›¹›¹›îOóïþOôîà?ô $ ð decelDistance then velocityAbs = math.min(velocityAbs + acc, velocityMax) else velocityAbs = math.max(velocityAbs * dec, 0) end if target < current then velocityAbs = -velocityAbs end velocity = velocityAbs current = current + velocity if distance < snap then current = target velocity = 0 end return current, velocity end function accel2D(current, velocity, target, velocityMax, acc, dec, snap) xDelta = target.x - current.x yDelta = target.y - current.y distance = math.sqrt(xDelta ^ 2 + yDelta ^ 2) if distance < snap then x = target.x y = target.y velocity = 0 else decelDistance = velocity * velocity / (2 * dec) if distance > decelDistance then velocity = math.min(velocity + acc, velocityMax) else velocity = math.max(velocity - dec, 0) end angle = math.atan2(yDelta, xDelta) x = current.x + velocity * math.cos(angle) y = current.y + velocity * math.sin(angle) end return Vector.new(x, y), velocity end function getRandomRarity(minimumRarity) rarity = minimumRarity or 1 -- Iterate so that each rarity has a 25% chance to be upgraded to a higher rarity. for i = 1, 3 do if rarity == i and math.random(4) == 1 then rarity = rarity + 1 end end return rarity end function getRandomCardByRarity(rarity) return rarityPool[rarity][math.random(#rarityPool[rarity])] end -- Write a number with sprites function writeNumber(number, x, y, justify) justify = justify or "left" str = tostring(number) if justify == "right" then x = x - 4 * #str + 2 elseif justify == "center" then x = x - 2 * #str + 1 end for i = 1, #str do n = tonumber(string.sub(str, i, i)) spr(256 + n, x + (i - 1) * 4, y, 0) end end -- An advanced print function with automatic line break and control codes. function richPrint(message, x, y, width) width = width or 239 c = 12 word = "" lineWidth = 0 cursorX = x cursorY = y realWidth = 0 --Iterate through all the characters. i = 1 while true do chr = string.sub(message, i, i) -- Case: character is a blank space. -- Break line if needed, then write the word. if chr == " " then word_width = print(word, 0, 136) if word_width > width - lineWidth then cursorX = x cursorY = cursorY + 7 realWidth = math.max(realWidth, lineWidth) lineWidth = 0 end print(word, cursorX, cursorY, c) cursorX = cursorX + word_width + 4 lineWidth = lineWidth + word_width + 4 word = "" -- Case: character is | -- Start parsing a control code. elseif chr == "|" then i = i + 1 chr = string.sub(message, i, i) -- Case: control code starts with C -- Set color. -- syntax: |C[color index]| if chr == "C" then numstr = "" i = i + 1 while true do chr = string.sub(message, i, i) if chr == "|" then num = tonumber(numstr) c = num break else numstr = numstr .. chr end i = i + 1 end -- Case: control code starts with L -- Force line break. -- Syntax: |L| elseif chr == "L" then i = i + 1 realWidth = math.max(realWidth, lineWidth) lineWidth = 0 cursorX = x cursorY = cursorY + 7 -- Case: control code starts with S -- Draw sprites. -- Syntax: |S[sprite id]_[amount of sprites]_[stride]_[sprite width]_[background color]| elseif chr == "S" then params = {} paramI = 1 numstr = "" i = i + 1 while true do chr = string.sub(message, i, i) if chr == "_" or chr == "|" then num = tonumber(numstr) params[paramI] = num numstr = "" paramI = paramI + 1 else numstr = numstr .. chr end if chr == "|" then spriteX = cursorX + params[2] * params[3] for j = 1, params[2] do spr(params[1], spriteX - j * params[3], cursorY, params[5]) end cursorX = cursorX + (params[2] - 1) * params[3] + params[4] + 2 lineWidth = lineWidth + (params[2] - 1) * params[3] + params[4] + 2 break end i = i + 1 end end -- Case: none of the above -- Character is a letter, add it to -- the word currently being written. else word = word .. chr -- At the end of the message, add one blank space at the end to trigger the word write condition. if i == #message then message = message .. " " end end if i == #message then break end i = i + 1 end return realWidth, cursorY - y + 5 end -- Rasterize a line between two screen space vectors, interpolate the z values, and send each pixel to to the render queue. function rasterizeLine(v0, z0, v1, z1) local l = {} -- Set reciprocals for the z values. z0Reci = 1 / z0 z1Reci = 1 / z1 -- Get the difference between v0 and v1. vDelta = v1 - v0 -- Get the unit vector to be used as the slope. vDeltaUnit = vDelta:clone() vDeltaUnit:normalize() -- Get the slope for the z reciprocal. zReciDelta = z1Reci - z0Reci zReciSlope = zReciDelta / vDelta:getMag() -- Then iterate each pixel unit and send it to the render queue. for i = 0, math.ceil(vDelta:getMag()) do p = v0 + i * vDeltaUnit z_reci = z0Reci + i * zReciSlope z = 1 / z_reci renderQueue:newObject(z) renderQueue:addShape(pix, {p.x, p.y, 12}) end end -- CLASSLESS OBJECTS -- audio = { musicPlaying = false } function audio:playMusic() if not musicPlaying then music(0,-1,-1) musicPlaying = true end end function audio:stopMusic() if musicPlaying then music() musicPlaying = false end end transition = { fadingIn = false, fadingOut = false, target = "", phase = 1 } function transition:update() if self.fadingOut then self.phase = self.phase - 0.1 if self.phase <= -1 then self.fadingOut = false self.fadingIn = true return true end elseif self.fadingIn then self.phase = self.phase + 0.1 if self.phase >= 1 then self.fadingIn = false return true end end return false end shop = { destroyer = {}, reminder = {} } function shop:init() self.selectorRow = 1 self.selectorColumn = 1 self.boosterCardsBought = 0 self.exit = false self.destroyer.active = false self.reminder.active = false self.booster = Deck:new() for i = 1, 5 do self.booster:add(cardEmptyShop) end self.singles = Deck:new() self.singles:add(getRandomCardByRarity(getRandomRarity(1))) self.singles:add(getRandomCardByRarity(getRandomRarity(1))) self.singles:add(getRandomCardByRarity(getRandomRarity(2))) audio:stopMusic() end function shop:input() -- Process movement. if self.selectorRow == 1 then if btnp(2) then self.selectorColumn = math.max(1, self.selectorColumn - 1) end if btnp(3) then self.selectorColumn = math.min(9, self.selectorColumn + 1) end if btnp(1) then self.selectorRow = 2 if self.selectorColumn == 9 then self.selectorColumn = 2 else self.selectorColumn = 1 end end else if btnp(2) and self.selectorColumn == 2 then self.selectorColumn = 1 end if btnp(3) and self.selectorColumn == 1 then self.selectorColumn = 2 end if btnp(0) then self.selectorRow = shop.selectorRow - 1 if self.selectorRow == 1 then if self.selectorColumn == 2 then self.selectorColumn = 9 else self.selectorColumn = 1 end end end if btnp(1) and self.selectorRow == 2 then self.selectorRow = 3 end end -- Process purchases. if btnp(4) then if self.selectorRow == 1 then -- Try to open new booster. if self.selectorColumn == 1 then if player:spend(100) then self.boosterCardsBought = 0 -- Get four commons or better, and one uncommon or better. for i = 1, 5 do if i == 5 then rarity = getRandomRarity(2) else rarity = getRandomRarity() end self.booster[i] = rarityPool[rarity][math.random(#rarityPool[rarity])] end end table.sort(self.booster, function (a, b) return a.rarity < b.rarity end) -- Try to get a card from the booster. elseif self.selectorColumn >= 2 and self.selectorColumn <= 6 then selectedCard = self.booster[self.selectorColumn - 1] if selectedCard.isACard then if player:spend(self:getBoosterCardPrice()) then player.deck:add(selectedCard) self.booster[self.selectorColumn - 1] = cardEmptyShop:new() self.boosterCardsBought = self.boosterCardsBought + 1 end end -- Try to get a card from the singles section. elseif self.selectorColumn >= 7 and self.selectorColumn <= 9 then selectedCard = self.singles[self.selectorColumn - 6] if selectedCard.isACard then if player:spend(selectedCard:getPrice()) then player.deck:add(selectedCard) self.singles[self.selectorColumn - 6] = cardEmptyShop:new() end end end else -- Try to buy stat upgrades. if self.selectorColumn == 1 then if self.selectorRow == 2 then if player:spend(self:getHPUpgradePrice()) then player.maxHp = player.maxHp + 1 player:heal(player.maxHp - player.hp) end end if shop.selectorRow == 3 then if player:spend(self:getAmmoUpgradePrice()) then player.maxAmmo = player.maxAmmo + 1 end end -- Try to open the card destroyer. else if player.money >= self.destroyPrice then self.destroyer:activate() end end end end -- Get the rules reminder text for a card. if btnp(7) then if self.selectorRow == 1 and self.selectorColumn >= 2 and self.selectorColumn <= 6 then selectedCard = self.booster[self.selectorColumn - 1] if selectedCard.isACard then self.reminder:activate(selectedCard:getReminderText()) end elseif self.selectorRow == 1 and self.selectorColumn >= 7 and self.selectorColumn <= 9 then selectedCard = self.singles[self.selectorColumn - 6] if selectedCard.isACard then self.reminder:activate(selectedCard:getReminderText()) end end end -- Leave the shop. if btnp(5) then transition.fadingOut = true end end function shop:draw() toolTips = {} rectb(0, 6, 160, 124, 12) line(0, 16, 159, 16, 12) line(100, 6, 100, 15, 12) richPrint("|S272_1_1_3_0|" .. player.money, 103, 9, 100) line(0, 50, 159, 50, 12) line(106, 16, 106, 50, 12) spr(1, 7, 22, 1, 1, false, false, 1, 2) c = 15 if self.selectorRow == 1 and self.selectorColumn == 1 then c = 12 end rectb(5, 20, 12, 20, c) writeNumber(100, 10, 42, "center") function shopDrawCardSection(numCards, deck, startX, startColumn, sectionId) for i = 1, numCards do spr(deck[i].spriteId, startX + i * 16, 22, 1, 1, false, false, 1, 2) c = 15 if self.selectorRow == 1 and self.selectorColumn == i + startColumn then c = 12 end rectb(startX - 2 + i * 16, 20, 12, 20, c) rectb(startX - 1 + i * 16, 21, 10, 18, deck[i]:getRarityAsColor()) if deck[i].isACard then if sectionId == 0 then price = self:getBoosterCardPrice() else price = deck[i]:getPrice() end writeNumber(price, startX + 3 + i * 16, 42, "center") end end end shopDrawCardSection(5, self.booster, 12, 1, 0) shopDrawCardSection(3, self.singles, 97, 6, 1) function shopDrawStatBar(y, stat, maxStat, statEmptySprId, statFullSprId, statSprWidth, price, rowNumber) c = 14 if self.selectorRow == rowNumber and self.selectorColumn == 1 then c = 12 end if player.money < price then if c == 12 then c = 2 else c = 1 end end rectb(5, y, 134, 10, c) for i = 1, maxStat do if i > stat then sprId = statEmptySprId else sprId = statFullSprId end spr(sprId, 7 + (i - 1) * statSprWidth, y + 2, 0) end writeNumber(price, 136, y + 2, "right") end shopDrawStatBar(54, player.hp, player.maxHp, 275, 273, 8, self:getHPUpgradePrice(), 2) shopDrawStatBar(65, player.ammo, player.maxAmmo, 276, 276, 2, self:getAmmoUpgradePrice(), 3) spr(3, 145, 57, 1, 1, false, false, 1, 2) c = 15 if self.selectorRow > 1 and self.selectorColumn == 2 then c = 12 end rectb(143, 55, 12, 20, c) if self.destroyPrice > 999 then writeNumber(self.destroyPrice, 154, 77, "right") else writeNumber(self.destroyPrice, 148, 77, "center") end line(0, 85, 159, 85, 12) function shopInfotext(title, info, price) price = price or 0 width = print(title, 0, 200) print(title, 2, 87, 12) line(2, 93, 2 + width, 93, 12) richPrint(info, 2, 95, 156) if price > 0 then width = print(price, 0, 200) rectb(0, 76, width + 8, 10, 12) richPrint("|S272_1_1_3_0|" .. price, 2, 78) end end if self.selectorRow == 1 and self.selectorColumn == 1 then shopInfotext("BOOSTER PACK", "Reveal 5 cards (at least one uncommon or better), get up to 2 for free, pay |S272_1_3_3_0|100 for each additional card", 100) table.insert(toolTips, {290, "Z", "Buy booster"}) end if self.selectorRow == 1 and self.selectorColumn >= 2 and self.selectorColumn <= 6 then selectedCard = self.booster[self.selectorColumn - 1] if selectedCard.isACard then selectedCard:displayInfotext(0, 85, 160, 45, self:getBoosterCardPrice()) table.insert(toolTips, {289, "S", "More info"}) if self:getBoosterCardPrice() == 0 then table.insert(toolTips, {290, "Z", "Take card"}) else table.insert(toolTips, {290, "Z", "Buy card"}) end else shopInfotext("BOOSTER PACK", "Buy a booster pack to reveal new cards") end end if self.selectorRow == 1 and self.selectorColumn >= 7 and self.selectorColumn <= 9 then selectedCard = self.singles[self.selectorColumn - 6] if selectedCard.isACard then selectedCard:displayInfotext(0, 85, 160, 45, selectedCard:getPrice()) table.insert(toolTips, {289, "S", "More info"}) table.insert(toolTips, {290, "Z", "Buy card"}) else shopInfotext("SINGLE CARDS", "Come back after the next level for more special deals!") end end if self.selectorRow == 2 and self.selectorColumn == 1 then shopInfotext("MAX HP", "Maximum health up by |S273_1_7_7_0|1 + full heal", 100 * fibonacci(player.maxHp)) table.insert(toolTips, {290, "Z", "Buy upgrade"}) end if self.selectorRow == 3 and self.selectorColumn == 1 then shopInfotext("MAX AMMO", "Increase your maximum ammo by |S276_1_3_3_0|1", 50 * (player.maxAmmo - 9)) table.insert(toolTips, {290, "Z", "Buy upgrade"}) end if self.selectorRow > 1 and self.selectorColumn == 2 then shopInfotext("DESTROY", "Remove a card from your deck permanently (the next removal costs double!)") table.insert(toolTips, {290, "Z", "Destroy card"}) end table.insert(toolTips, {291, "X", "Exit shop"}) y = 117 for i = #toolTips, 1, -1 do spr(toolTips[i][1], 161, y) print("/" .. toolTips[i][2] .. ":", 167, y+1, 12) print(toolTips[i][3], 161, y + 8, 12) y = y - 16 end end function shop.destroyer:activate() self.active = true self.confirm_dialog = false shop.selector = 1 end function shop.destroyer:input() if self.confirm_dialog then if btnp(4) then if player:spend(shop.destroyPrice) then table.remove(player.deck, shop.selector) shop.destroyPrice = shop.destroyPrice * 2 shop.selector = 1 self.active = false end elseif btnp(5) then self.confirm_dialog = false end else if btnp(2) then shop.selector = math.max(1, shop.selector - 1) end if btnp(3) then shop.selector = math.min(#player.deck, shop.selector + 1) end if btnp(4) and player.money >= shop.destroyPrice then self.confirm_dialog = true end if btnp(5) then self.active = false end end end function shop.destroyer:draw() x = 120 + #player.deck * 1.5 for i = #player.deck, 1, -1 do if i == shop.selector then x = x - 11 end player.deck[i]:display2D(x, 20) if i == shop.selector then rectb(x - 1, 19, 12, 20, 12) x = x - 11 end x = x - 2 end selectedCard = player.deck[shop.selector] selectedCard:displayInfotext(50, 55, 140, 50) if self.confirm_dialog then s = "Destroy " .. selectedCard.name .. "?" w = print(s, 0, 136) print(s, 120 - w / 2, 110, 12) richPrint("|S290_1_5_4_0|/Z: destroy, |S291_1_5_4_0|/X: cancel", 54, 117) box_w = math.max(140, w + 6) rectb(120 - box_w / 2, 107, box_w, 19, 12) else richPrint("|S290_1_5_4_0|/Z: destroy, |S291_1_5_4_0|/X: cancel", 54, 110) rectb(50, 107, 140, 12, 12) end end function shop.reminder:activate(text) self.active = true self.text = text end function shop.reminder:input() if btnp(5) then self.active = false end end function shop.reminder:draw() w, h = richPrint(self.text, 0, 0, 180) cls() rectb(28, 68 - h / 2 - 2, 184, h + 5, 12) richPrint(self.text, 30, 68 - h / 2, 180) richPrint("|S291_1_5_4_0|/X: return to shop", 108, 68 + h / 2 + 4) end function shop:getBoosterCardPrice() if shop.boosterCardsBought < 2 then return 0 else return 100 end end function shop:getHPUpgradePrice() return 100 * fibonacci(player.maxHp) end function shop:getAmmoUpgradePrice() return 50 * (player.maxAmmo - 9) end bullets = {} function bullets:update() -- Sort enemies for hit detection table.sort(enemies, function (a, b) return a.z > b.z end) for i, b in ipairs(self) do b:move() end for i = #self, 1, -1 do if self[i].expired then table.remove(self, i) end end end function bullets:queueRender() for i, b in ipairs(self) do b:queueRender() end end player = {} function player:init() self.money = 50 self.hp = 3 self.maxHp = 3 self.ammo = 10 self.maxAmmo = 10 self.shield = 0 self.shieldHalflife = 0 self.damageFlash = 0 self.totalMoneyGained = 0 self.totalMoneySpent = 0 self.totalHealed = 0 self.totalDamageDealt = 0 self.enemiesKilled = 0 self.dead = false self.cardSelector = 1 self.lane = 1 self.deck = Deck:new() for i = 1, 9 do self.deck:add(cardAmmo) end self.deck:add(cardYield) shop.destroyPrice = 100 end function player:update() self.damageFlash = math.floor(self.damageFlash * 0.75) if self.shield > 0 then self.shieldHalflife = self.shieldHalflife - timeDelta if self.shieldHalflife <= 0 then self.shield = math.floor(self.shield / 2) self.shieldHalflife = self.shieldHalflife + 1000 end end end function player:spend(amount) if self.money >= amount then self.money = self.money - amount self.totalMoneySpent = self.totalMoneySpent + amount return true end return false end function player:gainMoney(amount) player.money = player.money + amount player.totalMoneyGained = player.totalMoneyGained + amount end function player:heal(amount) self.hp = math.min(self.hp + amount, self.maxHp) self.totalHealed = self.totalHealed + amount end function player:reload(amount) self.ammo = math.min(self.ammo + amount, self.maxAmmo) end function player:damage(amount, bypassShield) self.damageFlash = 1500 bypassShield = bypassShield or false if bypassShield or self.shield == 0 then self.hp = self.hp - amount else actualDamage = amount - self.shield self.shield = math.max(0, self.shield - amount) if actualDamage > 0 then self.hp = self.hp - actualDamage end end if self.hp <= 0 then self.hp = 0 self.dead = true end end function player:gainShield(amount) if self.shield == 0 then self.shieldHalflife = 1000 end self.shield = self.shield + amount end function player:fire() if self.ammo > 0 then self.ammo = self.ammo - 1 table.insert(bullets, Bullet:new(self.lane)) end end game = {} game.level = 1 -- CLASSES -- -- Vector class and methods. Class -- specifically designed for this -- game's pseudo-3D needs. -- Vector format is (x, y, r), -- because some vectors represent -- bouding circles and thus have -- radii, and we want the -- transformation and projection -- methods to affect the radius too. Vector = {} Vector.__index = Vector function Vector.new(x, y, r) return setmetatable({ x = x or 0, y = y or 0, r = r or 0 }, Vector) end -- Replace the x, y and r values of the vector with another. function Vector:replace(v) self.x = v.x self.y = v.y self.r = v.r end -- Clone a vector. function Vector:clone() return Vector.new(self.x, self.y, self.r) end -- Get the perpendicular of a vector, -- rotated counterclockwise. function Vector:getPerp() return Vector.new(self.y, -self.x, self.r) end -- Get the magnitude of a vector. function Vector:getMag() return math.sqrt(self.x ^ 2 + self.y ^ 2) end -- Set the magnitude of a vector. function Vector:setMag(mag) self:normalize() self:replace(self * mag) end -- Normalize a vector. function Vector:normalize() self:replace(self / self:getMag()) end -- Get the direction of a vector. function Vector:getDirection() return math.atan2(self.y, self.x) end -- Rotate a vector in 2D. -- If theta is not a number, it is -- assumed to be a table containing -- a precalculated sine and a cosine -- (ie. a kind of simplified rotation -- matrix). function Vector:rotate(theta) local s local c if type(theta) == "number" then s = math.sin(theta) c = math.cos(theta) else s = theta.s c = theta.c end self:replace(Vector.new(c * self.x - s * self.y, s * self.x + c * self.y, self.r)) end -- Return a clone of the vector, -- projected to 2D (but not centered -- in screen space!) function Vector:project(z) return self:clone() * (-120 / z) end -- Apply camera transformations to a -- vector. function Vector:applyCamera() local v = self:clone() v = v + cam.current.translate + cam.shake.translate v:rotate(cam.current.sinCos) v = v * (1 + cam.shake.zoom) * cam.current.zoom v = v + Vector.new(VIEWPORT_LEFT + VIEWPORT_WIDTH / 2, VIEWPORT_TOP + VIEWPORT_HEIGHT / 2) return v end -- Set metamethods. function Vector.__unm(a) local v = Vector.new() v.x = -a.x v.y = -a.y v.r = a.r return v end function Vector.__add(a, b) local v = Vector.new() v.x = a.x + b.x v.y = a.y + b.y v.r = a.r return v end function Vector.__sub(a, b) local v = Vector.new() v.x = a.x - b.x v.y = a.y - b.y v.r = a.r return v end -- Multiplies a vector by a factor or returns the dot product of two vectors. function Vector.__mul(a, b) if type(a) == "number" then return Vector.new(a * b.x, a * b.y, a * b.r) elseif type(b) == "number" then return Vector.new(b * a.x, b * a.y, b * a.r) else return a.x * b.x + a.y * b.y end end function Vector.__div(a, b) return Vector.new(a.x / b, a.y / b, a.r / b) end -- Get the line equation between two vectors. We need this to rasterize the 3D representation of a card. lineEquation = {} function lineEquation:new(v0, v1) e = {} if v0 and v1 then -- Case: line is horizontal. if v0.y == v1.y then e.y = v0.y setmetatable(e, lineEquationHorizontal) -- Case: line is vertical. elseif v0.x == v1.x then e.x = v0.x setmetatable(e, lineEquationVertical) -- Case: line is sloping. else --m = slope, b = intercept e.m = (v1.y - v0.y) / (v1.x - v0.x) e.b = -v0.x * e.m + v0.y -- Calculate the square root we need for the distance. e.sq = math.sqrt(e.m ^ 2 + 1) setmetatable(e, lineEquationSlopeIntercept) end else setmetatable(e, self) e.__index = self end return e end lineEquationHorizontal = lineEquation:new() lineEquationHorizontal.__index = lineEquationHorizontal function lineEquationHorizontal:getDistance(v) return v.y - self.y end function lineEquationHorizontal:getY(x) return self.y end lineEquationVertical = lineEquation:new() lineEquationVertical.__index = lineEquationVertical function lineEquationVertical:getDistance(v) return v.x - self.x end function lineEquationVertical:getX(y) return self.x end lineEquationSlopeIntercept = lineEquation:new() lineEquationSlopeIntercept.__index = lineEquationSlopeIntercept function lineEquationSlopeIntercept:getDistance(v) return (-self.m * v.x + v.y - self.b) / self.sq end function lineEquationSlopeIntercept:getY(x) return self.m * x + self.b end function lineEquationSlopeIntercept:getX(y) return (y - self.b) / self.m end Lane = {} Lane.__index = Lane function Lane.new(p0, p1) return setmetatable({ p0 = p0, p1 = p1, center = (p0 + p1) / 2, last = false }, Lane) end function Lane:rasterize() -- Rasterize left side. v1 = self.p0:project(-12):applyCamera() v2 = self.p0:project(-4):applyCamera() rasterizeLine(v1, -12, v2, -4) -- Rasterize back side. v1 = self.p0:project(-12):applyCamera() v2 = self.p1:project(-12):applyCamera() rasterizeLine(v1, -12, v2, -12) -- Rasterize front side. v1 = self.p0:project(-4):applyCamera() v2 = self.p1:project(-4):applyCamera() rasterizeLine(v1, -4, v2, -4) -- If there are no lanes after this, rasterize right side. if self.last then v1 = self.p1:project(-12):applyCamera() v2 = self.p1:project(-4):applyCamera() rasterizeLine(v1, -12, v2, -4) end end Surface = {} Surface.__index = Surface -- The constructor takes a table of vectors, representing the intersections of the lanes, generates lane objects for -- each lane, and returns a table of all the lanes. function Surface.new(edges) local s = {} for i = 1, #edges - 1 do table.insert(s, Lane.new(edges[i], edges[i + 1])) end s[#s].last = true return setmetatable(s, Surface) end function Surface:rasterize() for i, l in ipairs(self) do l:rasterize() end end dynamicCamera = {} dynamicCamera.__index = dynamicCamera function dynamicCamera.new() return setmetatable({ target = {}, current = {}, shake = {}, targetSet = false, currentSet = false, hotspots = {} }, dynamicCamera) end -- Clear the list of hotspots. function dynamicCamera:initFrame() self.hotspots = {} end -- Add a hotspot. A hotspot is a vector with a radius, representing a circle that the camera should try to display in full. function dynamicCamera:addHotspot(p) table.insert(self.hotspots, p:clone()) end function dynamicCamera:setHotspots() local spottedEnemies = false for i, e in ipairs(enemies) do if e:isCritical() then self:addHotspot(e:getHotspot()) spottedEnemies = true end end if not spottedEnemies then for i, l in ipairs(surface) do spot = l.center:project(-12) spot.r = 12 self:addHotspot(spot) end end self:addHotspot((surface[player.lane].center + Vector.new(0, 0, 1)):project(-3)) end -- Set the target for the camera. function dynamicCamera:setTarget() -- Get a box that contains all the hotspots. spot = self.hotspots[1] box = { left = spot.x - spot.r, right = spot.x + spot.r, top = spot.y - spot.r, bottom = spot.y + spot.r } for i = 2, #self.hotspots do spot = self.hotspots[i] box.left = math.min(box.left, spot.x - spot.r) box.right = math.max(box.right, spot.x + spot.r) box.top = math.min(box.top, spot.y - spot.r) box.bottom = math.max(box.bottom, spot.y + spot.r) end -- Get the center of the box. The negative of this will be the target translate vector. box.center = { x = (box.left + box.right) / 2, y = (box.top + box.bottom) / 2 } -- Get the median atan2 of all the hotspots and clamp it to MAX_ROLL. This well be the target roll value of the camera. atans = {} for i, spot in ipairs(self.hotspots) do if spot.x > 0 then a = spot:getDirection() else a = -spot:getDirection() end table.insert(atans, a) end table.sort(atans) medianAtan = median(atans) MAX_ROLL = 0.5 roll = math.max(-MAX_ROLL, math.min(MAX_ROLL, medianAtan)) -- Get a zoom value that will fit all the hotspots into the viewport. This will be the target zoom value. o = self.hotspots[1] zoomX = (VIEWPORT_WIDTH / 2) / (math.abs(spot.x) + spot.r) zoomY = (VIEWPORT_HEIGHT / 2) / (math.abs(spot.y) + spot.r) zoom = math.min(zoomX, zoomY) for i = 2, #self.hotspots do spot = self.hotspots[i] zoomX = (VIEWPORT_WIDTH / 2) / (math.abs(spot.x) + spot.r) zoomY = (VIEWPORT_HEIGHT / 2) / (math.abs(spot.y) + spot.r) zoomThis = math.min(zoomX, zoomY) zoom = math.min(zoom, zoomThis) end self.target = { translate = Vector.new(-box.center.x, -box.center.y), roll = roll, zoom = zoom } self.targetSet = true end -- Accelerate the camera's actual values towards the target values. function dynamicCamera:setCurrent() -- If the current values have not been set, set them to be identical with the target. if not self.currentSet then self.current = { translate = self.target.translate:clone(), translateV = 0, roll = self.target.roll, rollV = 0, zoom = self.target.zoom, zoomV = 0 } self.shake = { translate = Vector.new(), roll = 0, zoom = 0 } self.current.sinCos = {s = 1, c = 0} self.currentSet = true -- Otherwise, accelerate them towards the target. else -- Set acceleration, deceleration, maximum velocity and snap range for translate, roll and zoom. translateAcc = 0.001 translateDec = 0.001 translateMaxV = 0.3 translateSnap = 1 rollAcc = 0.0001 rollDec = 0.0001 rollMaxV = 0.002 rollSnap = 0.005 zoomAcc = 0.0003 zoomDec = 0.0003 zoomMaxV = 0.003 zoomSnap = 0.01 -- Accelerate the values towards the targets. 2D acceleration for the translation, 1D acceleration for roll and zoom. self.current.translate, self.current.translateV = accel2D(self.current.translate, self.current.translateV, self.target.translate, translateMaxV, translateAcc, translateDec, translateSnap) self.current.roll, self.current.rollV = accel1D(self.current.roll, self.target.roll, self.current.rollV, rollAcc, rollDec, rollMaxV, rollSnap) self.current.zoom, self.current.zoomV = accel1D(self.current.zoom, self.target.zoom, self.current.zoomV, zoomAcc, zoomDec, zoomMaxV, zoomSnap) -- Set the shake values according to the player's damageFlash value. if player.damageFlash > 0 then self.shake.translate = Vector.new(player.damageFlash / 20, 0) self.shake.translate:rotate(2 * math.pi * math.random()) self.shake.roll = player.damageFlash * (0.5 - math.random()) / 1000 self.shake.zoom = player.damageFlash * (0.5 - math.random()) / 1000 else self.shake.translate = Vector.new() self.shake.roll = 0 self.shake.zoom = 0 end -- Calculate the trigonometry of the sum of the base roll and shake roll for rotation. self.current.sinCos = {s = math.sin(self.current.roll + self.shake.roll), c = math.cos(self.current.roll + self.shake.roll)} end end Enemy = {} Enemy.__index = Enemy function Enemy.new() return setmetatable({}, Enemy) end function Enemy:isCritical() if self.z > -7 and not self.dying then return true end return false end function Enemy:queueRender() if self.dying then renderQueue:newObject(self.z) for i, particle in ipairs(self.deathAnim.particles) do v = surface[self.lane].center:clone() v = v + particle.vector * (1 - 1 / (0.01 * self.deathAnim.time + 1)) v.r = 1 / (1 + 0.01 * self.deathAnim.time * particle.shrinkSpeed) v = v:project(self.z):applyCamera() renderQueue:addShape(circ, {v.x, v.y, v.r, 12}) end else p = surface[self.lane].center:clone() p.r = 0.7 pProj = p:project(self.z):applyCamera() renderQueue:newObject(self.z) c = 2 if self.flashTimeout > 0 then self.flashTimeout = self.flashTimeout - timeDelta c = 12 end renderQueue:addShape(circ, {pProj.x, pProj.y, pProj.r, c}) renderQueue:addShape(circb, {pProj.x, pProj.y, pProj.r, 12}) end end function Enemy:getHotspot() p = surface[self.lane].center:clone() p.r = 1 pProj = p:project(self.z) return pProj end function Enemy:move() if not self.dying then self.z = self.z + 0.015 * math.random() if self.z > -4 then self.dead = true player:damage(1) end end end function Enemy:damage(amount) player.totalDamageDealt = player.totalDamageDealt + amount self.hp = self.hp - amount self.flashTimeout = 100 if self.hp <= 0 then self:die() player.enemiesKilled = player.enemiesKilled + 1 end end function Enemy:spawn() table.insert(enemies, Enemy.new()) enemies[#enemies].lane = math.random(#surface - 1) enemies[#enemies].z = -12 enemies[#enemies].hp = 3 enemies[#enemies].dead = false enemies[#enemies].dying = false enemies[#enemies].flashTimeout = 0 enemies[#enemies].hittable = true table.remove(enemyQueue, 1) end function Enemy:die() game.enemiesKilledInLevel = game.enemiesKilledInLevel + 1 player:gainMoney(math.random(2, 5)) self.hittable = false self.dying = true self.deathAnim = {} self.deathAnim.particles = {} for i = 1, 20 do local newParticle = {} newParticle.vector = Vector.new(1 + 3 * math.random(), 0) newParticle.vector:rotate(2 * math.pi * math.random()) newParticle.moveSpeed = 0.2 + 0.2 * math.random() newParticle.shrinkSpeed = 0.2 + 0.2 * math.random() table.insert(self.deathAnim.particles, newParticle) end self.deathAnim.time = 0 end function Enemy:updateDeathAnim() self.deathAnim.time = self.deathAnim.time + timeDelta if self.deathAnim.time > 2000 then self.dead = true end end Bullet = {} function Bullet:new(l) local b = { z = -4, lane = l, expired = false } setmetatable(b, self) self.__index = self return b end function Bullet:move() self.z = self.z - 0.3 for i, e in ipairs(enemies) do if e.lane == self.lane and e.hittable and e.z <= self.z and e.z >= self.z - 0.3 then e:damage(1) self.expired = true break end end if self.z < -12 then self.expired = true end end function Bullet:queueRender() v = surface[self.lane].center:clone() v.r = 0.2 v = v:project(self.z):applyCamera() renderQueue:newObject(self.z) renderQueue:addShape(circ, {v.x, v.y, v.r, 12}) end Card = {} function Card:new(c) c = c or {} setmetatable(c, self) self.__index = self return c end Card.isACard = true Card.hasSupercharge = false function Card:getRarityAsString() rarityStrings = {"common", "uncommon", "rare", "rare AF", "starter", "uncollectable"} return rarityStrings[self.rarity] end function Card:getRarityAsColor() rarityColors = {12, 6, 10, 3, 14, 2} return rarityColors[self.rarity] end function Card:getPrice() rarityPrices = {50, 100, 200, 400} return rarityPrices[self.rarity] end function Card:getTexel(u, v) addr = 0x8000 + 64 * self:getSpriteId() + u // 1 if v < 8 then addr = addr + (v // 1) * 8 else addr = addr + 16 * 64 + ((v // 1) - 8) * 8 end return peek4(addr) end function Card:onDraw() end function Card:onDiscard() end function Card:discard(drawTime) table.insert(player.deckDiscard, self) self:onDiscard() player.hand[self.slot] = cardEmptyHand:new() player.hand[self.slot].drawTime = drawTime * 1000 player.hand[self.slot].drawTime_max = drawTime * 1000 end function Card:exhaust(drawTime) player.hand[self.slot] = cardEmptyShop:new() player.hand[self.slot].drawTime = drawTime * 1000 end function Card:diffuse(minTime, maxTime) for i, c in ipairs(player.hand) do if i ~= self.slot and c.isACard then c:discard(math.random(minTime, maxTime)) end end end function Card:adInfinitum(drawTime) local otherSlots = {} for i, c in ipairs(player.hand) do if i ~= self.slot and c.isACard then table.insert(otherSlots, i) end end if #otherSlots > 0 then player.hand[otherSlots[math.random(#otherSlots)]]:discard(drawTime) else self:discard(drawTime) end end function Card:boom(amount) local legalTargets = 0 for i, e in ipairs(enemies) do if not e.dying then legalTargets = legalTargets + 1 end end if legalTargets > 0 then divided_amount = amount / legalTargets for i, e in ipairs(enemies) do if not e.dying then e:damage(divided_amount) end end end end function Card:displayInfotext(x, y, width, height, price, showUncollectable) price = price or 0 showUncollectable = false or showUncollectable rect(x, y, width, height, 0) rectb(x, y, width, height, 12) timeBoxX = x if price > 0 then priceBoxWidth = print(price, 0, 136) + 8 rect(x, y - 9, priceBoxWidth, 10, 0) rectb(x, y - 9, priceBoxWidth, 10, 12) richPrint("|S272_1_3_3_0|" .. price, x + 2, y - 7, 100) timeBoxX = timeBoxX + priceBoxWidth + 2 end if self.timeCost > 0 then timeBoxWidth = print(self.timeCost, 0, 136) + 10 rect(timeBoxX, y - 9, timeBoxWidth, 10, 0) rectb(timeBoxX, y - 9, timeBoxWidth, 10, 12) richPrint("|S278_1_5_5_0|" .. self.timeCost, timeBoxX + 2, y - 7, 100) end print(self.name, x + 2, y + 2, 12) line(x, y + 8, x + width - 1, y + 8, 12) richPrint(self:getRulesText(), x + 2, y + 10, width - 4) if showUncollectable or self.rarity < 6 then rarityString = self:getRarityAsString() rarityStringWidth = print(rarityString, 0, 136) print(rarityString, x + width - rarityStringWidth - 1, y + height - 7, self:getRarityAsColor()) end end function Card:displayReminder(x, y, width, height) realWidth, realheight = richPrint(self:getReminderText(), x + 2, y + 200, width - 4) rect(120 - realWidth / 2 - 4, y - realheight - 8, realWidth + 8, realheight + 8, 0) rectb(120 - realWidth / 2 - 3, y - realheight - 6, realWidth + 6, realheight + 6, 12) richPrint(self:getReminderText(), 120 - realWidth / 2, y - realheight - 4, realWidth) end function Card:getSpriteId() return self.spriteId end function Card:display2D(x, y) c = self:getRarityAsColor() rect(x, y, 10, 18, 0) rectb(x, y, 10, 18, c) spr(self:getSpriteId(), x + 1, y + 1, -1, 1, false, false, 1, 2) end function Card:rulesLinebreak() return " |L|" end function Card:reminderLinebreak() return " |C12||L||L|" end function Card:rulesAdInfinitum() return "|S280_1_7_7_0|Ad infinitum" end function Card:reminderAdInfinitum() return self:rulesAdInfinitum() .. " |C14|(If you have other cards in your hand when you play this card, then instead of discarding it, instead discard another card at random for this card's time cost)" end function Card:rulesBoom(n) return "|S281_1_6_6_0|BOOM " .. n end function Card:reminderBoom(n) return self:rulesBoom(n) .. " |C14|(Deal " .. n .. " damage divided among all enemies" end function Card:rulesDevilsPact() return "|S279_1_5_5_0|Devil's pact" end function Card:reminderDevilsPact() return self:rulesDevilsPact() .. " |C14|(Take 1 damage when you draw this. This damage bypasses your shield.)" end function Card:rulesDiffuse(a, b) return "|S282_1_6_6_0|Diffuse " .. a .. "-" .. b end function Card:reminderDiffuse(a, b) return self:rulesDiffuse(a, b) .. " |C14|(When you play this card, also discard every other card in your hand for a random time cost between " .. a .. " and " .. b .. " seconds)" end function Card:rulesExhaust() return "|S283_1_3_3_0|Exhaust" end function Card:reminderExhaust() return self:rulesExhaust() .. " |C14|(when you play this card, instead of discarding it, remove it from your deck until the end of the level)" end function Card:rulesHeal(n) return "|S273_" .. n .. "_6_7_0|Heal " .. n end function Card:reminderHeal(n) return self:rulesHeal(n) .. " |C14|(heal " .. n .. " hearts of health)" end function Card:rulesReload(n) return "|S276_" .. n .. "_2_3_0|Reload " .. n end function Card:reminderReload(n) return self:rulesReload(n) .. " |C14|(add " .. n .. " ammo)" end function Card:rulesShield(n) return "|S292_1_4_4_0|Shield " .. n end function Card:reminderShield(n) return self:rulesShield(n) .. " |C14|(Gain " .. n .. " shield. If you have shield when you take damage, lose that much shield instead. Your shield is halved every second.)" end function Card:rulesSupercharge() local s = "|S294_1_4_4_0|Supercharge" if activeGameMode.name == "action" then s = s .. " (" .. self.superchargeCounters .. ")" end return s end function Card:reminderSupercharge() return self:rulesSupercharge() .. " |C14|(This card enters your hand with 1 supercharge counter. Double the counters whenever you shuffle. When you play this, trigger its effect once for each counter." end function Card:rulesYield(a, b) return "|S272_1_1_3_0|Yield " .. a .. "-" .. b end function Card:reminderYield(a, b) return self:rulesYield(a, b) .. " |C14|(gain " .. a .. "-" .. b .. " money at random)" end cardAmmo = Card:new() cardAmmo.timeCost = 1 cardAmmo.name = "Ammo" cardAmmo.spriteId = 5 function cardAmmo:getReminderText() return self:reminderReload(1) end function cardAmmo:getRulesText() return self:rulesReload(1) end function cardAmmo:play() player:reload(1) self:discard(self.timeCost) end cardAmmo.rarity = 5 cardAmmoBelt = Card:new() cardAmmoBelt.timeCost = 3 cardAmmoBelt.name = "Ammo Belt" cardAmmoBelt.spriteId = 8 function cardAmmoBelt:getReminderText() return self:reminderAdInfinitum() .. self:reminderLinebreak() .. self:reminderReload(1) end function cardAmmoBelt:getRulesText() return self:rulesAdInfinitum() .. self:rulesLinebreak() .. self:rulesReload(1) end function cardAmmoBelt:play() player:reload(1) self:adInfinitum(self.timeCost) end cardAmmoBelt.rarity = 2 cardBlazeCore = Card:new() cardBlazeCore.timeCost = 5 cardBlazeCore.name = "Blaze Core" cardBlazeCore.spriteId = 10 function cardBlazeCore:getReminderText() return self:reminderReload(1) .. self:reminderLinebreak() .. "|S293_1_6_6_0|Surplus value: |S281_1_6_6_0|BOOM 1 |C14|(When you discard this for any reason, including playing it, deal 2 damage divided among all enemies)" end function cardBlazeCore:getRulesText() return self:rulesReload(1) .. self:rulesLinebreak() .. "|S293_1_6_6_0|Surplus value: |S281_1_6_6_0|BOOM 2" end function cardBlazeCore:play() player:reload(1) self:discard(self.timeCost) end function cardBlazeCore:onDiscard() self:boom(2) end cardBlazeCore.rarity = 2 cardCosmicStorm = Card:new() cardCosmicStorm.timeCost = 15 cardCosmicStorm.name = "Cosmic Storm" cardCosmicStorm.spriteId = 32 function cardCosmicStorm:getReminderText() return self:reminderBoom(2) .. self:reminderLinebreak() .. self:reminderAdInfinitum() end function cardCosmicStorm:getRulesText() return self:rulesBoom(2) .. self:rulesLinebreak() .. self:rulesAdInfinitum() end function cardCosmicStorm:play() self:boom(2) self:adInfinitum(self.timeCost) end cardCosmicStorm.rarity = 3 cardDefenseBudget = Card:new() cardDefenseBudget.timeCost = 15 cardDefenseBudget.name = "Defense Budget" cardDefenseBudget.spriteId = 15 function cardDefenseBudget:getReminderText() return self:reminderShield(8) .. self:reminderLinebreak() .. "|S293_1_6_6_0|Surplus value: |S272_1_1_3_0|Yield 1-2 |C14|(When you discard this for any reason, including playing it, gain 1-2 money at random)" end function cardDefenseBudget:getRulesText() return self:rulesShield(8) .. self:rulesLinebreak() .. "|S293_1_6_6_0|Surplus value: " .. self:rulesYield(1, 2) end function cardDefenseBudget:play() player:gainShield(8) self:discard(self.timeCost) end function cardDefenseBudget:onDiscard() player:gainMoney(math.random(1, 2)) end cardDefenseBudget.rarity = 2 cardDevilsTincture = Card:new() cardDevilsTincture.timeCost = 15 cardDevilsTincture.name = "The Devil's Tincture" cardDevilsTincture.spriteId = 4 function cardDevilsTincture:getReminderText() return self:reminderDevilsPact() .. self:reminderLinebreak() .. self:chance() .. "% chance: +1 max HP and full heal (odds go down the more you've healed this game)" end function cardDevilsTincture:getRulesText() return self:rulesDevilsPact() .. self:rulesLinebreak() .. self:chance() .. "% chance: +1 max HP and full heal (odds go down the more you've healed this game)" end function cardDevilsTincture:onDraw() player:damage(1, true) end function cardDevilsTincture:chance() local denominator = math.max(1, player.totalHealed * .75) return math.ceil(100 / denominator) end function cardDevilsTincture:play() if math.random(100) <= self:chance() then player.maxHp = player.maxHp + 1 player:heal(player.maxHp - player.hp) end self:discard(self.timeCost) end cardDevilsTincture.rarity = 4 cardFreshClip = Card:new() cardFreshClip.timeCost = 3 cardFreshClip.name = "Fresh Clip" cardFreshClip.spriteId = 7 function cardFreshClip:getReminderText() return self:reminderReload(4) end function cardFreshClip:getRulesText() return self:rulesReload(4) end function cardFreshClip:play() player:reload(4) self:discard(self.timeCost) end cardFreshClip.rarity = 1 cardGammaRayBurst = Card:new() cardGammaRayBurst.timeCost = 15 cardGammaRayBurst.name = "Gamma Ray Burst" cardGammaRayBurst.spriteId = 9 function cardGammaRayBurst:getReminderText() return self:reminderBoom(20) .. self:reminderLinebreak() .. self:reminderDiffuse(3, 5) .. self:reminderLinebreak() .. self:reminderExhaust() end function cardGammaRayBurst:getRulesText() return self:rulesBoom(20) .. self:rulesLinebreak() .. self:rulesDiffuse(3, 5) .. self:rulesLinebreak() .. self:rulesExhaust() end function cardGammaRayBurst:play() self:boom(20) self:diffuse(3, 5) self:exhaust(self.timeCost) end cardGammaRayBurst.rarity = 3 cardMeltFurnace = Card:new() cardMeltFurnace.timeCost = 15 cardMeltFurnace.name = "Melt Furnace" cardMeltFurnace.spriteId = 13 function cardMeltFurnace:getReminderText() return self:reminderYield(1, 2) .. self:reminderLinebreak() .. self:reminderAdInfinitum() end function cardMeltFurnace:getRulesText() return self:rulesYield(1, 2) .. self:rulesLinebreak() .. self:rulesAdInfinitum() end function cardMeltFurnace:play() player:gainMoney(math.random(1, 2)) self:adInfinitum(self.timeCost) end cardMeltFurnace.rarity = 3 cardMiningDrone = Card:new() cardMiningDrone.timeCost = 10 cardMiningDrone.name = "Mining Drone" cardMiningDrone.spriteId = 14 function cardMiningDrone:getReminderText() return self:reminderYield(2, 5) end function cardMiningDrone:getRulesText() return self:rulesYield(2, 5) end function cardMiningDrone:play() player:gainMoney(math.random(2, 5)) self:discard(self.timeCost) end cardMiningDrone.rarity = 1 cardStonks = Card:new() cardStonks.timeCost = 10 cardStonks.name = "Stonks" cardStonks.spriteId = 11 cardStonks.hasSupercharge = true function cardStonks:getReminderText() return self:reminderSupercharge() .. self:reminderLinebreak() .. self:reminderYield(2, 3) end function cardStonks:getRulesText() return self:rulesSupercharge() .. self:rulesLinebreak() .. self:rulesYield(2, 3) end function cardStonks:onDraw() self.superchargeCounters = 1 end function cardStonks:play() for i = 1, self.superchargeCounters do player:gainMoney(math.random(2, 3)) end self:discard(self.timeCost) end cardStonks.rarity = 3 cardSupplyCrate = Card:new() cardSupplyCrate.timeCost = 5 cardSupplyCrate.name = "Supply Crate" cardSupplyCrate.spriteId = 12 function cardSupplyCrate:getReminderText() return self:reminderReload(1) .. self:reminderLinebreak() .. "|S293_1_6_6_0|Surplus value: |S292_1_4_4_0|Shield 4 |C14|(When you discard this for any reason, including playing it, gain 4 shield. If you have shield when you take damage, lose that much shield instead. Your shield is halved every second.)" end function cardSupplyCrate:getRulesText() return self:rulesReload(1) .. self:rulesLinebreak() .. "|S293_1_6_6_0|Surplus value: |S292_1_4_4_0|Shield 4" end function cardSupplyCrate:play() player:reload(1) self:discard(self.timeCost) end function cardSupplyCrate:onDiscard() player:gainShield(4) end cardSupplyCrate.rarity = 1 cardYield = Card:new() cardYield.timeCost = 15 cardYield.name = "Yield" cardYield.spriteId = 6 function cardYield:getReminderText() return self:reminderYield(1, 3) end function cardYield:getRulesText() return self:rulesYield(1, 3) end function cardYield:play() player:gainMoney(math.random(1, 3)) self:discard(self.timeCost) end cardYield.rarity = 5 -- A non-card, representing an empty slot in the shop. cardEmptyShop = Card:new() cardEmptyShop.spriteId = 2 cardEmptyShop.rarity = 6 cardEmptyShop.isACard = false function cardEmptyShop:play() end -- A non-card, representing an empty slot in the player's hand. cardEmptyHand = Card:new() cardEmptyHand.spriteId = 164 cardEmptyHand.rarity = 6 cardEmptyHand.isACard = false function cardEmptyHand:play() end -- Instead of returning a static sprite id, calculate the sprite id from remaining draw time. function cardEmptyHand:getSpriteId() spr_num = math.floor(11 * self.drawTime / self.drawTime_max + 0.5) return 207 - spr_num end -- The pool of all collectable cards, separated by rarity. rarityPool = {} -- Common cards rarityPool[1] = { cardFreshClip, cardMiningDrone, cardSupplyCrate } -- Uncommon cards rarityPool[2] = { cardAmmoBelt, cardBlazeCore, cardDefenseBudget } -- Rare cards rarityPool[3] = { cardCosmicStorm, cardGammaRayBurst, cardMeltFurnace, cardStonks } -- Rare AF cards rarityPool[4] = { cardDevilsTincture } -- Deck class. Any collection of cards is a deck object. These collections include the discard pile as well as -- the booster and singles sections of the shop. Deck = {} function Deck:new(d) d = d or {} setmetatable(d, self) self.__index = self return d end function Deck:sortByRarity() table.sort(self, function (a, b) return a.rarity < b.rarity end) end function Deck:add(newCard) table.insert(self, newCard:new()) end function Deck:shuffle() for i = #self, 2, -1 do local j = math.random(i) self[i], self[j] = self[j], self[i] end end function Deck:draw(slot) if #self == 0 then for i, c in ipairs(player.hand) do if c.hasSupercharge then c.superchargeCounters = c.superchargeCounters * 2 end end while #player.deckDiscard > 0 do table.insert(self, table.remove(player.deckDiscard)) end self:shuffle() end player.hand[slot] = table.remove(self, 1) player.hand[slot].slot = slot player.hand[slot]:onDraw() end RenderQueue = {} RenderQueue.__index = RenderQueue function RenderQueue.new() return setmetatable({}, RenderQueue) end -- Start a new object in the render queue. An object is a collection of shapes that all have the same z coordinate. function RenderQueue:newObject(z) table.insert(self, {shapes = {}, z = z}) end -- Add a new shape to the most recently created object. A shape is a graphics function and a set of parameters. function RenderQueue:addShape(func, params) table.insert(self[#self], {func = func, params = params}) end function RenderQueue:render() table.sort(self, function (a, b) return a.z < b.z end) for i, o in ipairs(self) do for j, s in ipairs(o) do s.func(table.unpack(s.params)) end end end -- The game mode class. -- A game mode has five methods: -- init() - initialize the game mode -- transition() - process the transition to another mode -- input() - process player input -- update() - update values -- draw() - draw the scene GameMode = {} function GameMode:new(g) g = g or {} setmetatable(g, self) self.__index = self return g end -- The action game mode. gameModeAction = GameMode:new() gameModeAction.name = "action" function gameModeAction:init() surface = Surface.new({ Vector.new(-4, 2), Vector.new(-3, 2), Vector.new(-2, 2), Vector.new(-1, 2), Vector.new(0, 2), Vector.new(1, 2), Vector.new(2, 2), Vector.new(3, 2), Vector.new(4, 2) }) player.deckDraw = Deck:new() player.deckDiscard = Deck:new() for i, c in ipairs(player.deck) do player.deckDraw:add(c) end player.deckDraw:shuffle() player.hand = Deck:new() for i = 1, 5 do player.deckDraw:draw(i) end player.ammo = player.maxAmmo enemyQueue = {} for i = 1, 16 + game.level * 8 do new_enemy = Enemy.new() new_enemy.z = -12 new_enemy.hp = 3 new_enemy.queueTime = math.random(5000, 120000) table.insert(enemyQueue, new_enemy) end table.sort(enemyQueue, function (a, b) return a.queueTime < b.queueTime end) game.totalEnemiesInLevel = #enemyQueue game.enemiesKilledInLevel = 0 enemies = {} -- Clean up bullets, if any exist. while #bullets > 0 do table.remove(bullets, 1) end cam = dynamicCamera.new() audio:playMusic() end function gameModeAction:transition() if transition.fadingIn then transition:update() elseif transition.fadingOut then if transition:update() then if transition.target == "shop" then game.level = game.level + 1 activeGameMode = gameModeShop:new() elseif transition.target == "gameover" then activeGameMode = gameModeGameOver:new() end activeGameMode:init() end end end function gameModeAction:input() if not transition.fadingOut then if btnp(2) then player.lane = math.max(1, player.lane - 1) end if btnp(3) then player.lane = math.min(#surface, player.lane + 1) end if btnp(6) then player:fire() end if btnp(0) then player.cardSelector = math.max(player.cardSelector - 1, 1) end if btnp(1) then player.cardSelector = math.min(player.cardSelector + 1, 5) end if btnp(4) and player.hand[player.cardSelector].isACard then player.hand[player.cardSelector]:play() end end end function gameModeAction:update() player:update() bullets:update() for i = #enemies, 1, -1 do if enemies[i].dead then table.remove(enemies, i) end end for i, e in ipairs(enemies) do if e.dying then e:updateDeathAnim() else e:move() end end -- If there are no enemies, make sure the player never has to wait more than 2 seconds for the next spawn. if #enemyQueue > 0 then if #enemies == 0 and enemyQueue[1].queueTime > 2000 then adjust = enemyQueue[1].queueTime - 2000 else adjust = 0 end for i, e in ipairs(enemyQueue) do e.queueTime = e.queueTime - timeDelta - adjust end if enemyQueue[1].queueTime <= 0 then enemyQueue[1]:spawn() end end for i, c in ipairs(player.hand) do if c.drawTime then c.drawTime = c.drawTime - timeDelta if c.drawTime <= 0 then player.deckDraw:draw(i) end end end for i, c in ipairs(player.hand) do c:display2D(3, 23 + i * 19) if c.drawTime then c.drawTime = c.drawTime - timeDelta end end if #enemies == 0 and #enemyQueue == 0 then transition.fadingOut = true transition.target = "shop" end if player.hp <= 0 then transition.fadingOut = true transition.target = "gameover" end end function gameModeAction:draw() renderQueue = RenderQueue.new() cam:initFrame() cam:setHotspots() cam:setTarget() cam:setCurrent() bullets:queueRender() surface:rasterize() for i, e in ipairs(enemies) do e:queueRender() end -- Rasterize the selected card. Since the card only ever rotates around the z axis, we can take advantage of -- the fact that the top and bottom edges are always parallel to optimize the texture mapper. -- First, rasterize the edges of the card. rasterizeLine(surface[player.lane].p0:project(-4):applyCamera(), -4, surface[player.lane].p0:project(-2.2):applyCamera(), -2.2) rasterizeLine(surface[player.lane].p1:project(-4):applyCamera(), -4, surface[player.lane].p1:project(-2.2):applyCamera(), -2.2) rasterizeLine(surface[player.lane].p0:project(-2.2):applyCamera(), -2.2, surface[player.lane].p1:project(-2.2):applyCamera(), -2.2) -- Get the vertices of the corners of the card art, plus its bounding box. cardArtVerts = {} cardArtBox = {} function addCardArtVert(p1, p2, dist, z) p = p1 + dist * (p2 - p1) pProj = p:project(z):applyCamera() table.insert(cardArtVerts, pProj:clone()) cardArtBox = { left = math.min(pProj.x, cardArtBox.left or pProj.x), right = math.max(pProj.x, cardArtBox.right or pProj.x), top = math.min(pProj.y, cardArtBox.top or pProj.y), bottom = math.max(pProj.y, cardArtBox.bottom or pProj.y) } end addCardArtVert(surface[player.lane].p0, surface[player.lane].p1, 0.1, -3.9) addCardArtVert(surface[player.lane].p0, surface[player.lane].p1, 0.9, -3.9) addCardArtVert(surface[player.lane].p0, surface[player.lane].p1, 0.9, -2.3) addCardArtVert(surface[player.lane].p0, surface[player.lane].p1, 0.1, -2.3) -- Clone the card art vertices. rotatedCardArtVerts = {} for i = 1, 4 do table.insert(rotatedCardArtVerts, cardArtVerts[i]:clone()) end -- Get the direction of the bottom edge. bottomDirection = (rotatedCardArtVerts[3] - rotatedCardArtVerts[4]):getDirection() sinCos = {s = math.sin(-bottomDirection), c = math.cos(-bottomDirection)} -- Rotate the vertices so that the bottom edge is horizontal. for i, v in ipairs(rotatedCardArtVerts) do v:rotate(sinCos) end -- Get the height of the rotated shape. This is used to calculate the affine v value. yDelta = rotatedCardArtVerts[3].y - rotatedCardArtVerts[1].y -- Get the line equations of the sides. We use them to calculate the affine u value. lineEq1 = lineEquation:new(rotatedCardArtVerts[4], rotatedCardArtVerts[1]) lineEq2 = lineEquation:new(rotatedCardArtVerts[2], rotatedCardArtVerts[3]) -- Get affine UV coordinates for a pixel. function getAffineUV(x, y) -- v = Where the pixel is between the top and the bottom. vAffine = (y - rotatedCardArtVerts[1].y) / yDelta -- Get the points where the sides intersect with a horizontal line at y. sideV1 = Vector.new(lineEq1:getX(y), y) sideV2 = Vector.new(lineEq2:getX(y), y) -- u = Where the pixel is between the intersection points. xDelta = sideV2.x - sideV1.x uAffine = (x - sideV1.x) / xDelta return uAffine, vAffine end -- Iterate through every pixel in the card art bounding box, rotate it according to the direction of the bottom edge -- and get its affine UV coordinates. for x = cardArtBox.left, cardArtBox.right do for y = cardArtBox.top, cardArtBox.bottom do p = Vector.new(x, y) p:rotate(sinCos) uAffine, vAffine = getAffineUV(p.x, p.y) -- If the affine UV coordinates are within range, perform perspective correction and send to the render queue. if uAffine >= 0 and uAffine <= 1 and vAffine >= 0 and vAffine <= 1 then uCorrect = math.max(0, math.min(8, uAffine * 8)) zReci = 1 / -3.9 + vAffine * ((1 / -2.3) - (1 / -3.9)) zCorrect = 1 / zReci vReci = vAffine * (16 / -2.3) vCorrect = math.max(0, math.min(16, vReci * zCorrect)) renderQueue:newObject(zCorrect) renderQueue:addShape(pix, {x, y, player.hand[player.cardSelector]:getTexel(uCorrect, vCorrect)}) end end end renderQueue:render() selectedCard = player.hand[player.cardSelector] if selectedCard.isACard then selectedCard:displayInfotext(14, 89, 223, 47) end y = 23 + player.cardSelector * 19 line(0, y, 0, y + 17, 12) pix(1, y, 12) pix(1, y + 17, 12) for i = 1, player.maxHp do if i > player.hp then sprId = 275 else sprId = 273 end spr(sprId, (i - 1) * 8, 0, 0) end for i = 1, player.shield do spr(292, player.maxHp * 8 + (i - 1) * 2, 0, 0) end for i = 1, player.maxAmmo do if i > player.ammo then sprId = 277 else sprId = 276 end spr(sprId, (i - 1) * 2, 7, 0) end richPrint("|S272_1_1_3_0|" .. player.money, 0, 15) levelString = "Level " .. game.level w = print(levelString, 0, 140) print(levelString, 240 - w, 1, 12) rect(185, 7, 54, 4, 0) rectb(185, 7, 54, 4, 12) progressBarWidth = 52 * game.enemiesKilledInLevel / game.totalEnemiesInLevel if progressBarWidth > 0 then rect(186, 8, progressBarWidth, 2, 5) end progressString = game.enemiesKilledInLevel .. "/" .. game.totalEnemiesInLevel w = print(progressString, 0, 140) print(progressString, 240 - w, 12, 12) end gameModeShop = GameMode:new() gameModeShop.name = "shop" function gameModeShop:init() shop:init() end function gameModeShop:transition() if transition.fadingIn then transition:update() elseif transition.fadingOut then if transition:update() then activeGameMode = gameModeAction:new() activeGameMode:init() end end end function gameModeShop:update() end function gameModeShop:input() if not transition.fadingIn and not transition.fadingOut then if shop.destroyer.active then shop.destroyer:input() elseif shop.reminder.active then shop.reminder:input() else shop:input() end end end function gameModeShop:draw() if shop.destroyer.active then shop.destroyer:draw() elseif shop.reminder.active then shop.reminder:draw() else shop:draw() end end gameModeGameOver = GameMode:new() gameModeGameOver.name = "gameover" function gameModeGameOver:init() self.exit = false self.summary = { "Level reached: " .. game.level, "Money gained: " .. player.totalMoneyGained, "Money spent: " .. player.totalMoneySpent, "Damage healed: " .. player.totalHealed, "Damage dealt to enemies: " .. math.floor(player.totalDamageDealt + 0.5), "Enemies killed: " .. player.enemiesKilled } audio:stopMusic() end function gameModeGameOver:transition() if transition.fadingIn then transition:update() elseif transition.fadingOut then if transition:update() then activeGameMode = gameModeNewGame:new() activeGameMode:init() end end end function gameModeGameOver:input() if btnp(4) then transition.fadingOut = true end end function gameModeGameOver:update() end function gameModeGameOver:draw() print("GAME OVER", 0, 0, 12, false, 3) y = 21 for i, l in ipairs(self.summary) do print(l, 0, y, 12) y = y + 7 end y = y + 7 richPrint("|S290_1_5_4_0|/Z: try again", 0, y) end gameModeNewGame = GameMode:new() gameModeNewGame.name = "newgame" function gameModeNewGame:init() player:init() game.level = 1 end function gameModeNewGame:transition() if transition.fadingIn then transition:update() elseif transition.fadingOut then if transition:update() then activeGameMode = gameModeAction:new() activeGameMode:init() end end end function gameModeNewGame:input() if btnp(4) then transition.fadingOut = true end end function gameModeNewGame:update() end function gameModeNewGame:draw() function printCenter(message, y, scale) scale = scale or 1 local w = print(message, 0, 200, 0, false, scale) print(message, 120 - w / 2, y, 12, false, scale) end map(0, 0, 25, 4, 20, 3) richPrint("left/right: choose lane", 58, 38) richPrint("|S288_1_5_4_0|/A: fire cannon", 78, 45) richPrint("up/down: choose card", 63, 55) richPrint("|S290_1_5_4_0|/Z: play card", 82, 62) printCenter("all cards have a time cost", 72) printCenter("played and discarded cards", 79) printCenter("will be replaced after", 86) printCenter("that time has passed", 93) richPrint("|S290_1_5_4_0|/Z: start game", 79, 120) end activeGameMode = gameModeNewGame:new() activeGameMode:init() function TIC() timePrevious = timeCurrent timeCurrent = time() timeDelta = timeCurrent - timePrevious cls() activeGameMode:transition() activeGameMode:input() activeGameMode:update() activeGameMode:draw() end function SCN(y) if activeGameMode.name == "action" and player.damageFlash > 0 then scanlineFlash = math.random(player.damageFlash) else scanlineFlash = 0 end brightness = math.max(0, math.min(1, transition.phase + (y % 8) / 8)) for i = 1, 48 do val = brightness * palette[i] + (1 - brightness) * palette[(i - 1) % 3 + 1] if activeGameMode.name == "action" then if player.damageFlash > 500 then val = 255 - val end if i % 3 == 1 then val = math.min(255, val + scanlineFlash) end end poke(16319 + i, val) end end