├── chess ├── assets │ ├── chess_king.png │ ├── chess_pawn.png │ ├── chess_queen.png │ ├── chess_rook.png │ ├── chess_bishop.png │ └── chess_knight.png ├── main.lua └── lib │ └── inspect.lua ├── hello_world └── main.lua ├── README.md └── card_game └── main.lua /chess/assets/chess_king.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/love-game-protoypes/HEAD/chess/assets/chess_king.png -------------------------------------------------------------------------------- /chess/assets/chess_pawn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/love-game-protoypes/HEAD/chess/assets/chess_pawn.png -------------------------------------------------------------------------------- /chess/assets/chess_queen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/love-game-protoypes/HEAD/chess/assets/chess_queen.png -------------------------------------------------------------------------------- /chess/assets/chess_rook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/love-game-protoypes/HEAD/chess/assets/chess_rook.png -------------------------------------------------------------------------------- /chess/assets/chess_bishop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/love-game-protoypes/HEAD/chess/assets/chess_bishop.png -------------------------------------------------------------------------------- /chess/assets/chess_knight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/healeycodes/love-game-protoypes/HEAD/chess/assets/chess_knight.png -------------------------------------------------------------------------------- /hello_world/main.lua: -------------------------------------------------------------------------------- 1 | x = 100 2 | 3 | -- update the state of the game every frame 4 | ---@param dt number time since the last update in seconds 5 | function love.update(dt) 6 | if love.keyboard.isDown('space') then 7 | x = x + 200 * dt 8 | end 9 | end 10 | 11 | -- draw on the screen every frame 12 | function love.draw() 13 | love.graphics.setColor(1, 1, 1) 14 | love.graphics.rectangle('fill', x, 100, 50, 50) 15 | end 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🃏 LÖVE Game Prototypes 2 | > My blog post: [Building Game Prototypes with LÖVE](https://healeycodes.com/building-game-prototypes-with-love) 3 | 4 |
5 | 6 | A collection of quick game prototypes made with the LÖVE game framework. 7 | 8 |
9 | 10 | ## Running the Games 11 | 12 | 1. Install LÖVE from https://love2d.org 13 | 2. Navigate to any of the game directories 14 | 3. Run `love .` to start the game 15 | 16 |
17 | 18 | https://github.com/user-attachments/assets/5b8d3144-739e-43e5-b355-e6ff1420185e 19 | 20 |
21 | 22 | https://github.com/user-attachments/assets/453f719e-0327-4148-ad96-13fa09cc46dc 23 | -------------------------------------------------------------------------------- /chess/main.lua: -------------------------------------------------------------------------------- 1 | local inspect = require('lib.inspect') 2 | 3 | -- Board configuration 4 | local boardSize = { 5 | x = 4, 6 | y = 4 7 | } 8 | local squareSize = 100 9 | 10 | -- Creates a new chess piece with movement and interaction behaviors 11 | function create_piece(name, color, pos) 12 | -- Currently hardcoded diagonal movement pattern 13 | local validSquares = {{ 14 | x = 0, 15 | y = 0 16 | }, { 17 | x = 1, 18 | y = 1 19 | }, { 20 | x = 2, 21 | y = 2 22 | }, { 23 | x = 3, 24 | y = 3 25 | }} 26 | 27 | return { 28 | name = name, 29 | color = color, 30 | pos = pos, 31 | -- Movement handling 32 | move = function(self, square) 33 | -- Only move if we're not dropping on the original position 34 | if self.pos.x ~= square.x or self.pos.y ~= square.y then 35 | self.pos.x = square.x 36 | self.pos.y = square.y 37 | self:unclick() 38 | self:undrag() 39 | end 40 | end, 41 | -- Click state management 42 | clicked = false, 43 | click = function(self, x, y) 44 | self.clicked = true 45 | end, 46 | unclick = function(self) 47 | self.clicked = false 48 | end, 49 | -- Drag state management 50 | dragging = false, 51 | drag = function(self) 52 | self.dragging = true 53 | end, 54 | undrag = function(self) 55 | self.dragging = false 56 | end, 57 | -- Move validation 58 | validSquare = function(self, square) 59 | if square.x == self.pos.x and square.y == self.pos.y then 60 | return false 61 | end 62 | for _, validSquare in ipairs(validSquares) do 63 | if square.x == validSquare.x and square.y == validSquare.y then 64 | return true 65 | end 66 | end 67 | return false 68 | end, 69 | validSquares = function(self) 70 | return validSquares 71 | end 72 | } 73 | end 74 | 75 | -- Initial game pieces 76 | local pieces = {create_piece("bishop", "black", { 77 | x = 1, 78 | y = 1 79 | }), create_piece("pawn", "white", { 80 | x = 3, 81 | y = 2 82 | })} 83 | 84 | -- LÖVE callbacks 85 | function love.load() 86 | love.window.setTitle("Chess UI") 87 | love.window.setMode(400, 400) 88 | love.graphics.setDefaultFilter("nearest", "nearest") 89 | end 90 | 91 | function love.update(dt) 92 | end 93 | 94 | -- Converts screen coordinates to game board coordinates 95 | function xyToGame(x, y) 96 | local squareSize = 100 97 | local boardX = math.floor(x / squareSize) 98 | local boardY = math.floor(y / squareSize) 99 | 100 | -- Check if click is within board bounds 101 | if boardX >= 0 and boardX < boardSize.x and boardY >= 0 and boardY < boardSize.y then 102 | 103 | -- Check if square contains a piece 104 | for _, piece in ipairs(pieces) do 105 | if piece.pos.x == boardX and piece.pos.y == boardY then 106 | return { 107 | square = { 108 | x = boardX, 109 | y = boardY 110 | }, 111 | piece = piece 112 | } 113 | end 114 | end 115 | 116 | -- Square is valid but no piece found 117 | return { 118 | square = { 119 | x = boardX, 120 | y = boardY 121 | }, 122 | piece = nil 123 | } 124 | end 125 | 126 | -- Click was outside board bounds 127 | return { 128 | square = nil, 129 | piece = nil 130 | } 131 | end 132 | 133 | -- Mouse interaction handlers 134 | function love.mousereleased(x, y, button) 135 | -- When mouse released, undrag all pieces 136 | for _, piece in ipairs(pieces) do 137 | piece:undrag() 138 | end 139 | 140 | -- Check if we've dragged a piece to a valid square 141 | local result = xyToGame(x, y) 142 | if result.square then 143 | for _, piece in ipairs(pieces) do 144 | if piece.clicked and piece:validSquare(result.square) then 145 | piece:move(result.square) 146 | end 147 | end 148 | end 149 | end 150 | 151 | function love.mousepressed(x, y) 152 | local result = xyToGame(x, y) 153 | 154 | -- Check if we've clicked on a valid square 155 | if result.square then 156 | for _, piece in ipairs(pieces) do 157 | 158 | -- If we have a piece clicked and it's a valid square, move it 159 | if piece.clicked and piece:validSquare(result.square) then 160 | piece:move(result.square) 161 | return 162 | end 163 | end 164 | end 165 | 166 | -- Check if we've clicked on a piece 167 | if result.piece then 168 | result.piece:click(x, y) 169 | result.piece:drag() 170 | return 171 | end 172 | 173 | -- Otherwise, unclick all pieces 174 | for _, piece in ipairs(pieces) do 175 | piece:unclick() 176 | end 177 | end 178 | 179 | -- Rendering 180 | function love.draw() 181 | local mouseX, mouseY = love.mouse.getPosition() 182 | local startX = 0 183 | local startY = 0 184 | 185 | -- Draw board squares 186 | for row = 0, boardSize.y - 1 do 187 | for col = 0, boardSize.x - 1 do 188 | -- Alternate colors based on position 189 | if (row + col) % 2 == 0 then 190 | love.graphics.setColor(0.93, 0.93, 0.82) -- Beige 191 | else 192 | love.graphics.setColor(0.82, 0.77, 0.62) -- Dark beige 193 | end 194 | 195 | -- Draw filled square 196 | love.graphics.rectangle("fill", startX + (col * squareSize), startY + (row * squareSize), squareSize, 197 | squareSize) 198 | 199 | -- Draw black border 200 | love.graphics.setColor(0, 0, 0) 201 | love.graphics.setLineWidth(2) 202 | love.graphics.rectangle("line", startX + (col * squareSize), startY + (row * squareSize), squareSize, 203 | squareSize) 204 | end 205 | end 206 | 207 | -- Reset color to white for pieces 208 | love.graphics.setColor(1, 1, 1) 209 | 210 | -- Draw all pieces 211 | for _, piece in ipairs(pieces) do 212 | local pieceImage = love.graphics.newImage("assets/chess_" .. piece.name .. ".png") 213 | -- Calculate scale to fit the piece in the square (using 0.8 to leave some padding) 214 | local scale = (squareSize * 0.8) / math.max(pieceImage:getWidth(), pieceImage:getHeight()) 215 | -- Calculate position to center the piece in the square 216 | local pieceX = startX + (piece.pos.x * squareSize) + (squareSize - pieceImage:getWidth() * scale) / 2 217 | local pieceY = startY + (piece.pos.y * squareSize) + (squareSize - pieceImage:getHeight() * scale) / 2 218 | 219 | -- Draw valid move indicators and highlight current square 220 | if piece.clicked or piece.dragging then 221 | -- Set color for valid move indicators (faded green) 222 | love.graphics.setColor(0, 0.5, 0, 0.3) 223 | 224 | -- Draw current square highlight 225 | local currentX = startX + (piece.pos.x * squareSize) 226 | local currentY = startY + (piece.pos.y * squareSize) 227 | love.graphics.rectangle("fill", currentX, currentY, squareSize, squareSize) 228 | 229 | -- Draw indicators for valid moves 230 | for _, square in ipairs(piece:validSquares()) do 231 | -- Only draw circle if it's not the current square 232 | if square.x ~= piece.pos.x or square.y ~= piece.pos.y then 233 | local validX = startX + (square.x * squareSize) + squareSize / 2 234 | local validY = startY + (square.y * squareSize) + squareSize / 2 235 | love.graphics.circle("fill", validX, validY, squareSize * 0.2) 236 | end 237 | end 238 | end 239 | 240 | -- Highlight square under dragged piece if it's a valid move 241 | if piece.dragging then 242 | local mouseX, mouseY = love.mouse.getPosition() 243 | -- Convert mouse position to board coordinates 244 | local boardX = math.floor((mouseX - startX) / squareSize) 245 | local boardY = math.floor((mouseY - startY) / squareSize) 246 | 247 | -- Check if mouse is over a valid square 248 | for _, square in ipairs(piece:validSquares()) do 249 | if (square.x == boardX and square.y == boardY) and (square.x ~= piece.pos.x or square.y ~= piece.pos.y) then 250 | love.graphics.setColor(0, 0.5, 0, 0.3) 251 | local highlightX = startX + (boardX * squareSize) 252 | local highlightY = startY + (boardY * squareSize) 253 | love.graphics.rectangle("fill", highlightX, highlightY, squareSize, squareSize) 254 | break 255 | end 256 | end 257 | end 258 | 259 | -- Draw piece with appropriate color and transparency 260 | local alpha = piece.dragging and 0.5 or 1 261 | if piece.color == "white" then 262 | love.graphics.setColor(1, 1, 1, alpha) 263 | else 264 | love.graphics.setColor(0.2, 0.2, 0.2, alpha) 265 | end 266 | love.graphics.draw(pieceImage, pieceX, pieceY, 0, scale, scale) 267 | 268 | -- Draw dragged piece at cursor position 269 | if piece.dragging then 270 | local mouseX, mouseY = love.mouse.getPosition() 271 | -- Center the piece on cursor 272 | local floatingX = mouseX - (pieceImage:getWidth() * scale) / 2 273 | local floatingY = mouseY - (pieceImage:getHeight() * scale) / 2 274 | -- Draw the floating piece with correct color 275 | if piece.color == "white" then 276 | love.graphics.setColor(1, 1, 1) 277 | else 278 | love.graphics.setColor(0.2, 0.2, 0.2) 279 | end 280 | love.graphics.draw(pieceImage, floatingX, floatingY, 0, scale, scale) 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /chess/lib/inspect.lua: -------------------------------------------------------------------------------- 1 | local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = pcall(require, 'compat53.module'); if p then _tl_compat = m end end; local math = _tl_compat and _tl_compat.math or math; local string = _tl_compat and _tl_compat.string or string; local table = _tl_compat and _tl_compat.table or table 2 | local inspect = {Options = {}, } 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | inspect._VERSION = 'inspect.lua 3.1.0' 21 | inspect._URL = 'http://github.com/kikito/inspect.lua' 22 | inspect._DESCRIPTION = 'human-readable representations of tables' 23 | inspect._LICENSE = [[ 24 | MIT LICENSE 25 | 26 | Copyright (c) 2022 Enrique García Cota 27 | 28 | Permission is hereby granted, free of charge, to any person obtaining a 29 | copy of this software and associated documentation files (the 30 | "Software"), to deal in the Software without restriction, including 31 | without limitation the rights to use, copy, modify, merge, publish, 32 | distribute, sublicense, and/or sell copies of the Software, and to 33 | permit persons to whom the Software is furnished to do so, subject to 34 | the following conditions: 35 | 36 | The above copyright notice and this permission notice shall be included 37 | in all copies or substantial portions of the Software. 38 | 39 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 40 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 41 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 42 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 43 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 44 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 45 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 46 | ]] 47 | inspect.KEY = setmetatable({}, { __tostring = function() return 'inspect.KEY' end }) 48 | inspect.METATABLE = setmetatable({}, { __tostring = function() return 'inspect.METATABLE' end }) 49 | 50 | local tostring = tostring 51 | local rep = string.rep 52 | local match = string.match 53 | local char = string.char 54 | local gsub = string.gsub 55 | local fmt = string.format 56 | 57 | local _rawget 58 | if rawget then 59 | _rawget = rawget 60 | else 61 | _rawget = function(t, k) return t[k] end 62 | end 63 | 64 | local function rawpairs(t) 65 | return next, t, nil 66 | end 67 | 68 | 69 | 70 | local function smartQuote(str) 71 | if match(str, '"') and not match(str, "'") then 72 | return "'" .. str .. "'" 73 | end 74 | return '"' .. gsub(str, '"', '\\"') .. '"' 75 | end 76 | 77 | 78 | local shortControlCharEscapes = { 79 | ["\a"] = "\\a", ["\b"] = "\\b", ["\f"] = "\\f", ["\n"] = "\\n", 80 | ["\r"] = "\\r", ["\t"] = "\\t", ["\v"] = "\\v", ["\127"] = "\\127", 81 | } 82 | local longControlCharEscapes = { ["\127"] = "\127" } 83 | for i = 0, 31 do 84 | local ch = char(i) 85 | if not shortControlCharEscapes[ch] then 86 | shortControlCharEscapes[ch] = "\\" .. i 87 | longControlCharEscapes[ch] = fmt("\\%03d", i) 88 | end 89 | end 90 | 91 | local function escape(str) 92 | return (gsub(gsub(gsub(str, "\\", "\\\\"), 93 | "(%c)%f[0-9]", longControlCharEscapes), 94 | "%c", shortControlCharEscapes)) 95 | end 96 | 97 | local luaKeywords = { 98 | ['and'] = true, 99 | ['break'] = true, 100 | ['do'] = true, 101 | ['else'] = true, 102 | ['elseif'] = true, 103 | ['end'] = true, 104 | ['false'] = true, 105 | ['for'] = true, 106 | ['function'] = true, 107 | ['goto'] = true, 108 | ['if'] = true, 109 | ['in'] = true, 110 | ['local'] = true, 111 | ['nil'] = true, 112 | ['not'] = true, 113 | ['or'] = true, 114 | ['repeat'] = true, 115 | ['return'] = true, 116 | ['then'] = true, 117 | ['true'] = true, 118 | ['until'] = true, 119 | ['while'] = true, 120 | } 121 | 122 | local function isIdentifier(str) 123 | return type(str) == "string" and 124 | not not str:match("^[_%a][_%a%d]*$") and 125 | not luaKeywords[str] 126 | end 127 | 128 | local flr = math.floor 129 | local function isSequenceKey(k, sequenceLength) 130 | return type(k) == "number" and 131 | flr(k) == k and 132 | 1 <= (k) and 133 | k <= sequenceLength 134 | end 135 | 136 | local defaultTypeOrders = { 137 | ['number'] = 1, ['boolean'] = 2, ['string'] = 3, ['table'] = 4, 138 | ['function'] = 5, ['userdata'] = 6, ['thread'] = 7, 139 | } 140 | 141 | local function sortKeys(a, b) 142 | local ta, tb = type(a), type(b) 143 | 144 | 145 | if ta == tb and (ta == 'string' or ta == 'number') then 146 | return (a) < (b) 147 | end 148 | 149 | local dta = defaultTypeOrders[ta] or 100 150 | local dtb = defaultTypeOrders[tb] or 100 151 | 152 | 153 | return dta == dtb and ta < tb or dta < dtb 154 | end 155 | 156 | local function getKeys(t) 157 | 158 | local seqLen = 1 159 | while _rawget(t, seqLen) ~= nil do 160 | seqLen = seqLen + 1 161 | end 162 | seqLen = seqLen - 1 163 | 164 | local keys, keysLen = {}, 0 165 | for k in rawpairs(t) do 166 | if not isSequenceKey(k, seqLen) then 167 | keysLen = keysLen + 1 168 | keys[keysLen] = k 169 | end 170 | end 171 | table.sort(keys, sortKeys) 172 | return keys, keysLen, seqLen 173 | end 174 | 175 | local function countCycles(x, cycles) 176 | if type(x) == "table" then 177 | if cycles[x] then 178 | cycles[x] = cycles[x] + 1 179 | else 180 | cycles[x] = 1 181 | for k, v in rawpairs(x) do 182 | countCycles(k, cycles) 183 | countCycles(v, cycles) 184 | end 185 | countCycles(getmetatable(x), cycles) 186 | end 187 | end 188 | end 189 | 190 | local function makePath(path, a, b) 191 | local newPath = {} 192 | local len = #path 193 | for i = 1, len do newPath[i] = path[i] end 194 | 195 | newPath[len + 1] = a 196 | newPath[len + 2] = b 197 | 198 | return newPath 199 | end 200 | 201 | 202 | local function processRecursive(process, 203 | item, 204 | path, 205 | visited) 206 | if item == nil then return nil end 207 | if visited[item] then return visited[item] end 208 | 209 | local processed = process(item, path) 210 | if type(processed) == "table" then 211 | local processedCopy = {} 212 | visited[item] = processedCopy 213 | local processedKey 214 | 215 | for k, v in rawpairs(processed) do 216 | processedKey = processRecursive(process, k, makePath(path, k, inspect.KEY), visited) 217 | if processedKey ~= nil then 218 | processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited) 219 | end 220 | end 221 | 222 | local mt = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited) 223 | if type(mt) ~= 'table' then mt = nil end 224 | setmetatable(processedCopy, mt) 225 | processed = processedCopy 226 | end 227 | return processed 228 | end 229 | 230 | local function puts(buf, str) 231 | buf.n = buf.n + 1 232 | buf[buf.n] = str 233 | end 234 | 235 | 236 | 237 | local Inspector = {} 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | local Inspector_mt = { __index = Inspector } 249 | 250 | local function tabify(inspector) 251 | puts(inspector.buf, inspector.newline .. rep(inspector.indent, inspector.level)) 252 | end 253 | 254 | function Inspector:getId(v) 255 | local id = self.ids[v] 256 | local ids = self.ids 257 | if not id then 258 | local tv = type(v) 259 | id = (ids[tv] or 0) + 1 260 | ids[v], ids[tv] = id, id 261 | end 262 | return tostring(id) 263 | end 264 | 265 | function Inspector:putValue(v) 266 | local buf = self.buf 267 | local tv = type(v) 268 | if tv == 'string' then 269 | puts(buf, smartQuote(escape(v))) 270 | elseif tv == 'number' or tv == 'boolean' or tv == 'nil' or 271 | tv == 'cdata' or tv == 'ctype' then 272 | puts(buf, tostring(v)) 273 | elseif tv == 'table' and not self.ids[v] then 274 | local t = v 275 | 276 | if t == inspect.KEY or t == inspect.METATABLE then 277 | puts(buf, tostring(t)) 278 | elseif self.level >= self.depth then 279 | puts(buf, '{...}') 280 | else 281 | if self.cycles[t] > 1 then puts(buf, fmt('<%d>', self:getId(t))) end 282 | 283 | local keys, keysLen, seqLen = getKeys(t) 284 | 285 | puts(buf, '{') 286 | self.level = self.level + 1 287 | 288 | for i = 1, seqLen + keysLen do 289 | if i > 1 then puts(buf, ',') end 290 | if i <= seqLen then 291 | puts(buf, ' ') 292 | self:putValue(t[i]) 293 | else 294 | local k = keys[i - seqLen] 295 | tabify(self) 296 | if isIdentifier(k) then 297 | puts(buf, k) 298 | else 299 | puts(buf, "[") 300 | self:putValue(k) 301 | puts(buf, "]") 302 | end 303 | puts(buf, ' = ') 304 | self:putValue(t[k]) 305 | end 306 | end 307 | 308 | local mt = getmetatable(t) 309 | if type(mt) == 'table' then 310 | if seqLen + keysLen > 0 then puts(buf, ',') end 311 | tabify(self) 312 | puts(buf, ' = ') 313 | self:putValue(mt) 314 | end 315 | 316 | self.level = self.level - 1 317 | 318 | if keysLen > 0 or type(mt) == 'table' then 319 | tabify(self) 320 | elseif seqLen > 0 then 321 | puts(buf, ' ') 322 | end 323 | 324 | puts(buf, '}') 325 | end 326 | 327 | else 328 | puts(buf, fmt('<%s %d>', tv, self:getId(v))) 329 | end 330 | end 331 | 332 | 333 | 334 | 335 | function inspect.inspect(root, options) 336 | options = options or {} 337 | 338 | local depth = options.depth or (math.huge) 339 | local newline = options.newline or '\n' 340 | local indent = options.indent or ' ' 341 | local process = options.process 342 | 343 | if process then 344 | root = processRecursive(process, root, {}, {}) 345 | end 346 | 347 | local cycles = {} 348 | countCycles(root, cycles) 349 | 350 | local inspector = setmetatable({ 351 | buf = { n = 0 }, 352 | ids = {}, 353 | cycles = cycles, 354 | depth = depth, 355 | level = 0, 356 | newline = newline, 357 | indent = indent, 358 | }, Inspector_mt) 359 | 360 | inspector:putValue(root) 361 | 362 | return table.concat(inspector.buf) 363 | end 364 | 365 | setmetatable(inspect, { 366 | __call = function(_, root, options) 367 | return inspect.inspect(root, options) 368 | end, 369 | }) 370 | 371 | return inspect -------------------------------------------------------------------------------- /card_game/main.lua: -------------------------------------------------------------------------------- 1 | -- Game configuration 2 | local Config = { 3 | window = { 4 | title = "untitled card game" 5 | }, 6 | cards = { 7 | count = 8, 8 | spacing = 60, 9 | width = 80, 10 | height = 120, 11 | hoverRise = 155, 12 | hoverScale = 1.8, 13 | manaCostSize = 20, 14 | manaCostPadding = 2, 15 | manaCostFontSize = 12, 16 | border = 2, 17 | imageHeight = 42, -- 35% of card height 18 | titleFontSize = 10, 19 | titleYOffset = 35, 20 | textFontSize = 8, 21 | textPadding = 4, 22 | textYOffset = 65 23 | }, 24 | slots = { 25 | count = 7, 26 | spacing = 20, 27 | dashLength = 5, 28 | dashGap = 5 29 | }, 30 | resources = { 31 | barWidth = 100, 32 | barHeight = 20, 33 | spacing = 10, 34 | border = 2, 35 | maxHealth = 30, 36 | maxMana = 10 37 | }, 38 | ui = { 39 | roundFontSize = 16, 40 | buttonWidth = 100, 41 | buttonHeight = 30, 42 | buttonMargin = 10 43 | } 44 | } 45 | 46 | love.window.setTitle(Config.window.title) 47 | 48 | -- Core game state 49 | local State = { 50 | cards = {}, 51 | opponentCards = {}, 52 | hoveredCard = nil, 53 | draggedCard = nil, 54 | dragStart = {x = 0, y = 0}, 55 | resources = { 56 | player = { 57 | health = Config.resources.maxHealth, 58 | mana = 1, 59 | maxMana = 1 60 | }, 61 | opponent = { 62 | health = Config.resources.maxHealth, 63 | mana = 1, 64 | maxMana = 1 65 | } 66 | }, 67 | round = { 68 | current = 1, 69 | isPlayerTurn = true 70 | }, 71 | slots = { 72 | player = {}, 73 | opponent = {} 74 | }, 75 | hoveredSlot = nil, 76 | ui = { 77 | endTurnHovered = false 78 | } 79 | } 80 | 81 | -- Creates a new card with visual properties based on its position in hand 82 | local function createCard(index, total, isOpponent) 83 | local rotation = (isOpponent and 0.2 or -0.2) + 84 | (isOpponent and -1 or 1) * (0.4 * (index-1)/(total-1)) 85 | 86 | local heightOffset = (isOpponent and -1 or 1) * 87 | math.abs(index - (total + 1)/2) * 10 88 | 89 | -- Generate rainbow color based on card index 90 | local hue = (index-1)/total 91 | local r, g, b = 0, 0, 0 92 | 93 | if hue < 1/6 then 94 | r, g = 1, hue * 6 95 | elseif hue < 2/6 then 96 | r, g = 1 - (hue-1/6) * 6, 1 97 | elseif hue < 3/6 then 98 | g, b = 1, (hue-2/6) * 6 99 | elseif hue < 4/6 then 100 | g, b = 1 - (hue-3/6) * 6, 1 101 | elseif hue < 5/6 then 102 | r, b = (hue-4/6) * 6, 1 103 | else 104 | r, b = 1, 1 - (hue-5/6) * 6 105 | end 106 | 107 | return { 108 | color = {r*0.7, g*0.7, b*0.7, 1}, 109 | rotation = rotation, 110 | heightOffset = heightOffset, 111 | currentRise = 0, 112 | currentScale = 1, 113 | dragOffset = {x = 0, y = 0}, 114 | manaCost = index, 115 | title = "Card " .. index, 116 | text = "This card does *something* cool when played." 117 | } 118 | end 119 | 120 | local function getCardPosition(index, startX, baseY) 121 | return startX + (index-1) * Config.cards.spacing, baseY or 520 122 | end 123 | 124 | local function updateCardAnimation(card, targetRise, targetScale, dt) 125 | local speed = 10 126 | card.currentRise = card.currentRise + (targetRise - card.currentRise) * dt * speed 127 | card.currentScale = card.currentScale + (targetScale - card.currentScale) * dt * speed 128 | end 129 | 130 | local function updateCardDrag(card, dt) 131 | if not State.draggedCard then 132 | local speed = 10 133 | card.dragOffset.x = card.dragOffset.x + (0 - card.dragOffset.x) * dt * speed 134 | card.dragOffset.y = card.dragOffset.y + (0 - card.dragOffset.y) * dt * speed 135 | end 136 | end 137 | 138 | local function isPointInRect(x, y, rx, ry, rw, rh) 139 | return x >= rx and x <= rx + rw and y >= ry and y <= ry + rh 140 | end 141 | 142 | local function drawDottedRect(x, y, width, height, isHighlighted) 143 | love.graphics.setLineWidth(1) 144 | love.graphics.setLineStyle("rough") 145 | 146 | if isHighlighted then 147 | love.graphics.setColor(0, 1, 0, 0.3) 148 | love.graphics.rectangle("fill", x, y, width, height) 149 | love.graphics.setColor(0.5, 0.5, 0.5, 0.5) 150 | end 151 | 152 | -- Draw dotted border lines 153 | local function drawDottedLine(startX, startY, endX, endY) 154 | local dx = endX - startX 155 | local dy = endY - startY 156 | local length = math.sqrt(dx * dx + dy * dy) 157 | local segments = math.floor(length / (Config.slots.dashLength + Config.slots.dashGap)) 158 | 159 | for i = 0, segments do 160 | local start = i * (Config.slots.dashLength + Config.slots.dashGap) 161 | local finish = math.min(start + Config.slots.dashLength, length) 162 | if finish > start then 163 | local x1 = startX + dx * start / length 164 | local y1 = startY + dy * start / length 165 | local x2 = startX + dx * finish / length 166 | local y2 = startY + dy * finish / length 167 | love.graphics.line(x1, y1, x2, y2) 168 | end 169 | end 170 | end 171 | 172 | drawDottedLine(x, y, x + width, y) 173 | drawDottedLine(x + width, y, x + width, y + height) 174 | drawDottedLine(x + width, y + height, x, y + height) 175 | drawDottedLine(x, y + height, x, y) 176 | end 177 | 178 | local function drawCardSlots() 179 | local totalWidth = Config.slots.count * (Config.cards.width + Config.slots.spacing) - Config.slots.spacing 180 | local startX = love.graphics.getWidth()/2 - totalWidth/2 181 | local centerY = love.graphics.getHeight()/2 182 | local slotSpacing = Config.cards.height + Config.slots.spacing 183 | 184 | love.graphics.setColor(0.5, 0.5, 0.5, 0.5) 185 | 186 | -- Draw opponent slots 187 | for i = 1, Config.slots.count do 188 | local x = startX + (i-1) * (Config.cards.width + Config.slots.spacing) 189 | local y = centerY - slotSpacing 190 | if not State.slots.opponent[i] then 191 | drawDottedRect(x, y, Config.cards.width, Config.cards.height) 192 | end 193 | end 194 | 195 | -- Draw player slots 196 | for i = 1, Config.slots.count do 197 | local x = startX + (i-1) * (Config.cards.width + Config.slots.spacing) 198 | local y = centerY + Config.slots.spacing 199 | if not State.slots.player[i] then 200 | local isHighlighted = State.draggedCard and State.hoveredSlot == i and State.round.isPlayerTurn 201 | drawDottedRect(x, y, Config.cards.width, Config.cards.height, isHighlighted) 202 | end 203 | end 204 | end 205 | 206 | local function drawCard(card, x, y, rotation, isFaceDown) 207 | love.graphics.push() 208 | love.graphics.translate(x, y) 209 | 210 | if rotation then 211 | love.graphics.rotate(card.rotation + (isFaceDown and math.pi or 0)) 212 | end 213 | love.graphics.scale(card.currentScale) 214 | 215 | if isFaceDown then 216 | -- Draw card back 217 | love.graphics.setColor(0.2, 0.2, 0.3, 1) 218 | love.graphics.rectangle("fill", -Config.cards.width/2, 0, Config.cards.width, Config.cards.height) 219 | 220 | love.graphics.setColor(0.3, 0.3, 0.4, 1) 221 | love.graphics.rectangle("line", -Config.cards.width/2 + 5, 5, Config.cards.width - 10, Config.cards.height - 10) 222 | else 223 | -- Draw card border (background) 224 | love.graphics.setColor(0.3, 0.3, 0.3, 1) 225 | love.graphics.rectangle("fill", -Config.cards.width/2, 0, Config.cards.width, Config.cards.height) 226 | 227 | -- Draw card front 228 | love.graphics.setColor(card.color) 229 | love.graphics.rectangle("fill", 230 | -Config.cards.width/2 + Config.cards.border, 231 | Config.cards.border, 232 | Config.cards.width - Config.cards.border*2, 233 | Config.cards.height - Config.cards.border*2 234 | ) 235 | 236 | -- Draw card image area 237 | love.graphics.setColor(0.5, 0.5, 0.5, 1) 238 | love.graphics.rectangle("fill", 239 | -Config.cards.width/2 + Config.cards.border, 240 | Config.cards.border, 241 | Config.cards.width - Config.cards.border*2, 242 | Config.cards.imageHeight 243 | ) 244 | 245 | -- Draw card title 246 | love.graphics.setColor(0, 0, 0) 247 | local titleFont = love.graphics.newFont(Config.cards.titleFontSize) 248 | love.graphics.setFont(titleFont) 249 | local titleWidth = titleFont:getWidth(card.title) 250 | love.graphics.print( 251 | card.title, 252 | -titleWidth/2, 253 | Config.cards.titleYOffset 254 | ) 255 | 256 | -- Draw card text 257 | love.graphics.setColor(0, 0, 0) 258 | local textFont = love.graphics.newFont(Config.cards.textFontSize) 259 | love.graphics.setFont(textFont) 260 | love.graphics.printf( 261 | card.text, 262 | -Config.cards.width/2 + Config.cards.textPadding, 263 | Config.cards.textYOffset, 264 | Config.cards.width - Config.cards.textPadding*2, 265 | "center" 266 | ) 267 | 268 | -- Draw mana cost 269 | love.graphics.setColor(0.2, 0.2, 0.8, 0.8) 270 | love.graphics.circle( 271 | "fill", 272 | Config.cards.width/2, 273 | 0, 274 | Config.cards.manaCostSize/2 275 | ) 276 | 277 | love.graphics.setColor(1, 1, 1) 278 | local font = love.graphics.newFont(Config.cards.manaCostFontSize) 279 | love.graphics.setFont(font) 280 | local text = tostring(card.manaCost) 281 | local textWidth = font:getWidth(text) 282 | local textHeight = font:getHeight() 283 | love.graphics.print( 284 | text, 285 | Config.cards.width/2 - textWidth/2, 286 | -textHeight/2 287 | ) 288 | end 289 | 290 | love.graphics.pop() 291 | end 292 | 293 | local function drawResourceBar(x, y, currentValue, maxValue, color) 294 | -- Background 295 | love.graphics.setColor(0.2, 0.2, 0.2, 0.8) 296 | love.graphics.rectangle("fill", x, y, Config.resources.barWidth, Config.resources.barHeight) 297 | 298 | -- Fill 299 | local fillWidth = (currentValue / maxValue) * Config.resources.barWidth 300 | love.graphics.setColor(color[1], color[2], color[3], 0.8) 301 | love.graphics.rectangle("fill", x, y, fillWidth, Config.resources.barHeight) 302 | 303 | -- Border 304 | love.graphics.setColor(0.3, 0.3, 0.3, 1) 305 | love.graphics.setLineWidth(Config.resources.border) 306 | love.graphics.rectangle("line", x, y, Config.resources.barWidth, Config.resources.barHeight) 307 | 308 | -- Value text 309 | love.graphics.setColor(1, 1, 1) 310 | local font = love.graphics.newFont(12) 311 | love.graphics.setFont(font) 312 | local text = string.format("%d/%d", currentValue, maxValue) 313 | local textWidth = font:getWidth(text) 314 | local textHeight = font:getHeight() 315 | love.graphics.print(text, 316 | x + Config.resources.barWidth/2 - textWidth/2, 317 | y + Config.resources.barHeight/2 - textHeight/2 318 | ) 319 | end 320 | 321 | local function drawResourceBars(resources, isOpponent) 322 | local margin = 20 323 | local y = isOpponent and margin or 324 | love.graphics.getHeight() - margin - Config.resources.barHeight * 2 - Config.resources.spacing 325 | 326 | drawResourceBar(margin, y, resources.health, Config.resources.maxHealth, {0.8, 0.2, 0.2}) 327 | drawResourceBar(margin, y + Config.resources.barHeight + Config.resources.spacing, 328 | resources.mana, resources.maxMana, {0.2, 0.2, 0.8}) 329 | end 330 | 331 | local function drawRoundInfo() 332 | local text = string.format("Round %d\n%s's Turn", 333 | State.round.current, 334 | State.round.isPlayerTurn and "Player" or "Opponent" 335 | ) 336 | 337 | love.graphics.setColor(1, 1, 1) 338 | local font = love.graphics.newFont(Config.ui.roundFontSize) 339 | love.graphics.setFont(font) 340 | 341 | local margin = Config.ui.buttonMargin 342 | local x = love.graphics.getWidth() - font:getWidth("Opponent's Turn") - margin 343 | local y = love.graphics.getHeight() - margin - font:getHeight() * 2 - Config.ui.buttonHeight - margin 344 | 345 | love.graphics.print(text, x, y) 346 | 347 | -- Only show End Turn button during player's turn 348 | if State.round.isPlayerTurn then 349 | -- Draw End Turn button 350 | local buttonX = x 351 | local buttonY = y + font:getHeight() * 2 + margin 352 | 353 | if State.ui.endTurnHovered then 354 | love.graphics.setColor(0.4, 0.4, 0.4, 1) 355 | else 356 | love.graphics.setColor(0.3, 0.3, 0.3, 1) 357 | end 358 | 359 | love.graphics.rectangle("fill", buttonX, buttonY, 360 | Config.ui.buttonWidth, Config.ui.buttonHeight) 361 | 362 | love.graphics.setColor(1, 1, 1) 363 | local buttonText = "End Turn" 364 | local textWidth = font:getWidth(buttonText) 365 | local textHeight = font:getHeight() 366 | love.graphics.print(buttonText, 367 | buttonX + (Config.ui.buttonWidth - textWidth)/2, 368 | buttonY + (Config.ui.buttonHeight - textHeight)/2 369 | ) 370 | end 371 | end 372 | 373 | -- Initialize starting hands 374 | for i = 1, Config.cards.count do 375 | table.insert(State.cards, createCard(i, Config.cards.count, false)) 376 | table.insert(State.opponentCards, createCard(i, Config.cards.count, true)) 377 | end 378 | 379 | local function recalculateCardPositions() 380 | local total = #State.cards 381 | for i = 1, total do 382 | local card = State.cards[i] 383 | card.rotation = -0.2 + (0.4 * (i-1)/(total-1)) 384 | card.heightOffset = math.abs(i - (total + 1)/2) * 10 385 | end 386 | end 387 | 388 | local function endTurn() 389 | State.round.isPlayerTurn = not State.round.isPlayerTurn 390 | if not State.round.isPlayerTurn then 391 | -- Opponent's turn starts 392 | else 393 | -- Player's turn starts 394 | State.round.current = State.round.current + 1 395 | -- Increase max mana for both players at the start of player's turn 396 | local newMaxMana = math.min(State.round.current, Config.resources.maxMana) 397 | State.resources.player.maxMana = newMaxMana 398 | State.resources.player.mana = newMaxMana 399 | State.resources.opponent.maxMana = newMaxMana 400 | State.resources.opponent.mana = newMaxMana 401 | end 402 | end 403 | 404 | function love.mousepressed(x, y, button) 405 | if button == 1 then 406 | -- Check End Turn button (only during player's turn) 407 | if State.round.isPlayerTurn then 408 | local font = love.graphics.newFont(Config.ui.roundFontSize) 409 | local buttonX = love.graphics.getWidth() - Config.ui.buttonWidth - Config.ui.buttonMargin 410 | local buttonY = love.graphics.getHeight() - Config.ui.buttonHeight - Config.ui.buttonMargin 411 | 412 | if isPointInRect(x, y, buttonX, buttonY, Config.ui.buttonWidth, Config.ui.buttonHeight) then 413 | endTurn() 414 | return 415 | end 416 | end 417 | 418 | -- Allow dragging cards on any turn 419 | if State.hoveredCard then 420 | State.draggedCard = State.hoveredCard 421 | local totalWidth = (Config.cards.count - 1) * Config.cards.spacing 422 | local startX = love.graphics.getWidth()/2 - totalWidth/2 423 | local card = State.cards[State.hoveredCard] 424 | State.dragStart.x = startX + (State.hoveredCard-1) * Config.cards.spacing 425 | State.dragStart.y = 520 + card.heightOffset 426 | end 427 | end 428 | end 429 | 430 | function love.mousereleased(x, y, button) 431 | if button == 1 and State.draggedCard then 432 | if State.hoveredSlot and not State.slots.player[State.hoveredSlot] and State.round.isPlayerTurn then 433 | -- Place card in slot and reset its animation values 434 | local card = table.remove(State.cards, State.draggedCard) 435 | card.currentRise = 0 436 | card.currentScale = 1 437 | card.dragOffset = {x = 0, y = 0} 438 | State.slots.player[State.hoveredSlot] = card 439 | 440 | if State.hoveredCard and State.hoveredCard > State.draggedCard then 441 | State.hoveredCard = State.hoveredCard - 1 442 | end 443 | recalculateCardPositions() 444 | end 445 | State.draggedCard = nil 446 | end 447 | end 448 | 449 | function love.update(dt) 450 | local mouseX, mouseY = love.mouse.getPosition() 451 | local totalWidth = (Config.cards.count - 1) * Config.cards.spacing 452 | local startX = love.graphics.getWidth()/2 - totalWidth/2 453 | 454 | State.hoveredCard = nil 455 | State.hoveredSlot = nil 456 | 457 | -- Check End Turn button hover (only during player's turn) 458 | if State.round.isPlayerTurn then 459 | local buttonX = love.graphics.getWidth() - Config.ui.buttonWidth - Config.ui.buttonMargin 460 | local buttonY = love.graphics.getHeight() - Config.ui.buttonHeight - Config.ui.buttonMargin 461 | State.ui.endTurnHovered = isPointInRect(mouseX, mouseY, buttonX, buttonY, 462 | Config.ui.buttonWidth, Config.ui.buttonHeight) 463 | else 464 | State.ui.endTurnHovered = false 465 | end 466 | 467 | -- Check for slot hover when dragging 468 | if State.draggedCard then 469 | local totalSlotWidth = Config.slots.count * (Config.cards.width + Config.slots.spacing) - Config.slots.spacing 470 | local slotStartX = love.graphics.getWidth()/2 - totalSlotWidth/2 471 | local centerY = love.graphics.getHeight()/2 472 | local slotY = centerY + Config.slots.spacing 473 | 474 | for i = 1, Config.slots.count do 475 | local slotX = slotStartX + (i-1) * (Config.cards.width + Config.slots.spacing) 476 | if isPointInRect(mouseX, mouseY, slotX, slotY, Config.cards.width, Config.cards.height) then 477 | State.hoveredSlot = i 478 | break 479 | end 480 | end 481 | end 482 | 483 | -- Update dragged card position 484 | if State.draggedCard then 485 | local card = State.cards[State.draggedCard] 486 | card.dragOffset.x = mouseX - State.dragStart.x 487 | card.dragOffset.y = mouseY - (State.dragStart.y + Config.cards.height/2) 488 | end 489 | 490 | -- Check for card hover when not dragging 491 | if not State.draggedCard then 492 | for i = 1, #State.cards do 493 | local card = State.cards[i] 494 | local x, y = getCardPosition(i, startX) 495 | y = y + card.heightOffset 496 | 497 | local dx = mouseX - x 498 | local dy = mouseY - y 499 | local rotatedX = dx * math.cos(-card.rotation) - dy * math.sin(-card.rotation) 500 | local rotatedY = dx * math.sin(-card.rotation) + dy * math.cos(-card.rotation) 501 | 502 | if rotatedX >= -Config.cards.width/2 and rotatedX <= Config.cards.width/2 and 503 | rotatedY >= 0 and rotatedY <= Config.cards.height then 504 | State.hoveredCard = i 505 | break 506 | end 507 | end 508 | end 509 | 510 | -- Update card animations 511 | for i = 1, #State.cards do 512 | local card = State.cards[i] 513 | if i == State.hoveredCard and not State.draggedCard then 514 | updateCardAnimation(card, Config.cards.hoverRise, Config.cards.hoverScale, dt) 515 | else 516 | updateCardAnimation(card, 0, 1, dt) 517 | end 518 | updateCardDrag(card, dt) 519 | end 520 | end 521 | 522 | function love.draw() 523 | local totalWidth = (Config.cards.count - 1) * Config.cards.spacing 524 | local startX = love.graphics.getWidth()/2 - totalWidth/2 525 | 526 | drawCardSlots() 527 | 528 | -- Draw cards in slots 529 | local totalSlotWidth = Config.slots.count * (Config.cards.width + Config.slots.spacing) - Config.slots.spacing 530 | local slotStartX = love.graphics.getWidth()/2 - totalSlotWidth/2 531 | local centerY = love.graphics.getHeight()/2 532 | 533 | -- Draw opponent's cards in slots 534 | for i = 1, Config.slots.count do 535 | if State.slots.opponent[i] then 536 | local x = slotStartX + (i-1) * (Config.cards.width + Config.slots.spacing) + Config.cards.width/2 537 | local y = centerY - Config.cards.height - Config.slots.spacing 538 | drawCard(State.slots.opponent[i], x, y, false, true) 539 | end 540 | end 541 | 542 | -- Draw player's cards in slots 543 | for i = 1, Config.slots.count do 544 | if State.slots.player[i] then 545 | local x = slotStartX + (i-1) * (Config.cards.width + Config.slots.spacing) + Config.cards.width/2 546 | local y = centerY + Config.slots.spacing 547 | drawCard(State.slots.player[i], x, y, false, false) 548 | end 549 | end 550 | 551 | -- Draw opponent's hand 552 | for i = 1, Config.cards.count do 553 | local card = State.opponentCards[i] 554 | local x = startX + (i-1) * Config.cards.spacing 555 | local y = 80 + card.heightOffset 556 | drawCard(card, x, y, true, true) 557 | end 558 | 559 | -- Draw player's non-active cards 560 | for i = #State.cards, 1, -1 do 561 | if i ~= State.hoveredCard and i ~= State.draggedCard then 562 | local card = State.cards[i] 563 | local x = startX + (i-1) * Config.cards.spacing + card.dragOffset.x 564 | local y = 520 + card.heightOffset - card.currentRise + card.dragOffset.y 565 | drawCard(card, x, y, true, false) 566 | end 567 | end 568 | 569 | -- Draw player's active card last for proper layering 570 | local activeCard = State.draggedCard or State.hoveredCard 571 | if activeCard then 572 | local card = State.cards[activeCard] 573 | local x = startX + (activeCard-1) * Config.cards.spacing + card.dragOffset.x 574 | local y = 520 + card.heightOffset - card.currentRise + card.dragOffset.y 575 | drawCard(card, x, y, activeCard ~= State.draggedCard, false) 576 | end 577 | 578 | drawResourceBars(State.resources.player, false) 579 | drawResourceBars(State.resources.opponent, true) 580 | drawRoundInfo() 581 | end 582 | 583 | -- Debug controls 584 | function love.keypressed(key) 585 | if key == "h" then 586 | State.resources.player.health = math.max(0, State.resources.player.health - 10) 587 | elseif key == "j" then 588 | State.resources.player.health = math.min(Config.resources.maxHealth, State.resources.player.health + 10) 589 | elseif key == "n" then 590 | State.resources.player.mana = math.max(0, State.resources.player.mana - 1) 591 | elseif key == "m" then 592 | State.resources.player.mana = math.min(State.resources.player.maxMana, State.resources.player.mana + 1) 593 | elseif key == "space" then 594 | endTurn() 595 | elseif key == "o" then -- Debug key to end opponent's turn 596 | if not State.round.isPlayerTurn then 597 | endTurn() 598 | end 599 | end 600 | end 601 | --------------------------------------------------------------------------------