├── 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 |
--------------------------------------------------------------------------------