ring.lua
1 --[[ 2 ToasterGen Spin 3 4 Copyright (C) 2025 Clifton Toaster Reid <cliftontreid@duck.com> 5 6 This library is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Lesser General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 11 This library is distributed in the hope that it will be useful, 12 but WITHOUT ANY WARRANTY; without even the implied warranty of 13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 GNU Lesser General Public License for more details. 15 16 You should have received a copy of the GNU Lesser General Public License 17 along with this library. If not, see <https://www.gnu.org/licenses/>. 18 ]] 19 20 -- ============================== 21 -- Configuration and Initialization 22 -- ============================== 23 local mon = nil 24 local ring = {} 25 local ballPos = 1 26 local lastBallPos = { x = nil, y = nil } 27 local Tracer = require("src.trace") -- Add Tracer require 28 29 -- ============================== 30 -- Constants 31 -- ============================== 32 local RING_SIZE = 36 33 local ELEMENT_WIDTH = 6 34 local ELEMENT_HEIGHT = 3 35 local SPACING_X = ELEMENT_WIDTH 36 local SPACING_Y = ELEMENT_HEIGHT 37 local START_X = 4 38 local START_Y = 4 39 40 -- Colors 41 local COLOR = { 42 BG = colors.green, 43 RED = colors.red, 44 BLACK = colors.black, 45 WHITE = colors.white, 46 BLUE = colors.blue, 47 GRAY = colors.gray, 48 LIGHT = colors.lightGray, 49 } 50 51 -- ============================== 52 -- Helper Functions 53 -- ============================== 54 55 ---Checks if a coordinate is within monitor bounds 56 ---@param x number X coordinate 57 ---@param y number Y coordinate 58 ---@param parentId string|nil Optional parent trace ID 59 ---@return boolean inBounds True if coordinates are within monitor bounds 60 local function isInBounds(x, y, parentId) 61 if mon == nil then return false end -- Add nil check 62 local tr = Tracer.new() 63 tr:setName("ring.isInBounds") 64 tr:addTag("x", string.format("%d", x)) 65 tr:addTag("y", string.format("%d", y)) 66 if parentId then 67 tr:setParentId(parentId) 68 end 69 70 local monW, monH = mon.getSize() 71 local result = x >= 1 and x <= monW and y >= 1 and y <= monH 72 73 tr:addAnnotation(string.format("Result: %s", tostring(result))) 74 Tracer.addSpan(tr:endSpan()) 75 return result 76 end 77 78 ---Draws a single element on the monitor 79 ---@param x number Left position 80 ---@param y number Top position 81 ---@param color number Color from colors table 82 ---@param number number|nil Optional number to display in the element 83 ---@param parentId string|nil Optional parent trace ID 84 local function drawElement(x, y, color, number, parentId) 85 if mon == nil then return end -- Add nil check 86 local tr = Tracer.new() 87 tr:setName("ring.drawElement") 88 tr:addTag("x", string.format("%d", x)) 89 tr:addTag("y", string.format("%d", y)) 90 tr:addTag("color", string.format("%d", color)) 91 tr:addTag("number", number and string.format("%d", number) or "nil") 92 if parentId then 93 tr:setParentId(parentId) 94 end 95 96 -- Draw a rectangle 97 mon.setBackgroundColor(color) 98 mon.setTextColor(COLOR.WHITE) 99 100 for i = 0, ELEMENT_WIDTH - 1 do 101 for j = 0, ELEMENT_HEIGHT - 1 do 102 mon.setCursorPos(x + i, y + j) 103 mon.write(" ") 104 end 105 end 106 107 -- Center the number in the element if provided 108 if number == nil then 109 tr:addAnnotation("No number provided") 110 Tracer.addSpan(tr:endSpan()) 111 return 112 end 113 114 local numberX = x + math.floor(ELEMENT_WIDTH / 2) 115 if number >= 10 then 116 numberX = numberX - 1 117 end 118 119 mon.setCursorPos(numberX, y + math.floor(ELEMENT_HEIGHT / 2)) 120 mon.write(tostring(number)) 121 tr:addAnnotation(string.format("Drew number %d", number)) 122 Tracer.addSpan(tr:endSpan()) 123 end 124 125 ---Draws a ball element on the monitor 126 ---@param x number Left position 127 ---@param y number Top position 128 ---@param parentId string|nil Optional parent trace ID 129 local function drawBallElement(x, y, parentId) 130 if mon == nil then return end -- Add nil check 131 local tr = Tracer.new() 132 tr:setName("ring.drawBallElement") 133 tr:addTag("x", string.format("%d", x)) 134 tr:addTag("y", string.format("%d", y)) 135 if parentId then 136 tr:setParentId(parentId) 137 end 138 139 local ball = { 140 " 00 ", 141 "000000", 142 " 00 ", 143 } 144 145 for i = 1, #ball do 146 for j = 1, #ball[i] do 147 mon.setCursorPos(x + j - 1, y + i - 1) 148 mon.setBackgroundColor(ball[i]:sub(j, j) == "0" and COLOR.BLUE or COLOR.BG) 149 mon.write(" ") 150 end 151 end 152 tr:addAnnotation("Ball element drawn") 153 Tracer.addSpan(tr:endSpan()) 154 end 155 156 ---Converts a roulette number to x,y coordinates 157 ---@param number number The roulette number (1-36) 158 ---@param parentId string|nil Optional parent trace ID 159 ---@return number|nil x The x coordinate 160 ---@return number|nil y The y coordinate 161 local function numberToPos(number, parentId) 162 if mon == nil then return nil, nil end -- Add nil check 163 local tr = Tracer.new() 164 tr:setName("ring.numberToPos") 165 tr:addTag("number", string.format("%d", number)) 166 if parentId then 167 tr:setParentId(parentId) 168 end 169 170 local posx = START_X + ELEMENT_WIDTH 171 local posy = START_Y + ELEMENT_HEIGHT 172 local newx, newy 173 174 if number <= 10 then 175 newx = posx + (number - 1) * SPACING_X 176 newy = posy 177 elseif number <= 19 then 178 newx = posx + SPACING_X * 9 179 newy = posy + (number - 10) * SPACING_Y 180 elseif number <= 28 then 181 newx = posx + SPACING_X * 9 - (number - 19) * SPACING_X 182 newy = posy + SPACING_Y * 9 183 elseif number <= 36 then 184 newx = posx 185 newy = posy + SPACING_Y * 9 - (number - 28) * SPACING_Y 186 else 187 tr:addAnnotation("Invalid number") 188 Tracer.addSpan(tr:endSpan()) 189 return nil, nil 190 end 191 192 tr:addAnnotation(string.format("Calculated pos: (%d, %d)", newx, newy)) 193 Tracer.addSpan(tr:endSpan()) 194 return newx, newy 195 end 196 197 ---Draws a line on the monitor 198 ---@param startX number Start X coordinate 199 ---@param startY number Start Y coordinate 200 ---@param endX number End X coordinate 201 ---@param endY number End Y coordinate 202 ---@param color number|nil Optional color (defaults to white) 203 ---@param thickness number|nil Optional line thickness (defaults to 1) 204 ---@param parentId string|nil Optional parent trace ID 205 local function drawLine(startX, startY, endX, endY, color, thickness, parentId) 206 if mon == nil then return end -- Add nil check 207 local tr = Tracer.new() 208 tr:setName("ring.drawLine") 209 tr:addTag("startX", string.format("%d", startX)) 210 tr:addTag("startY", string.format("%d", startY)) 211 tr:addTag("endX", string.format("%d", endX)) 212 tr:addTag("endY", string.format("%d", endY)) 213 tr:addTag("color", color and string.format("%d", color) or "nil") 214 tr:addTag("thickness", thickness and string.format("%d", thickness) or "nil") 215 if parentId then 216 tr:setParentId(parentId) 217 end 218 219 mon.setBackgroundColor(color or COLOR.WHITE) 220 thickness = thickness or 1 221 222 local dx = math.abs(endX - startX) 223 local dy = math.abs(endY - startY) 224 local sx = startX < endX and 1 or -1 225 local sy = startY < endY and 1 or -1 226 local err = dx - dy 227 local x, y = startX, startY 228 local halfThick = math.floor(thickness / 2) 229 local pixelsDrawn = 0 230 231 while true do 232 if thickness == 1 then 233 if isInBounds(x, y, tr.traceId) then 234 mon.setCursorPos(x, y) 235 mon.write(" ") 236 pixelsDrawn = pixelsDrawn + 1 237 end 238 else 239 -- Draw perpendicular to the major axis for thickness 240 if dx >= dy then -- More horizontal 241 for i = -halfThick, halfThick do 242 local drawY = y + i 243 if isInBounds(x, drawY, tr.traceId) then 244 mon.setCursorPos(x, drawY) 245 mon.write(" ") 246 pixelsDrawn = pixelsDrawn + 1 247 end 248 end 249 else -- More vertical 250 for i = -halfThick, halfThick do 251 local drawX = x + i 252 if isInBounds(drawX, y, tr.traceId) then 253 mon.setCursorPos(drawX, y) 254 mon.write(" ") 255 pixelsDrawn = pixelsDrawn + 1 256 end 257 end 258 end 259 end 260 261 -- Check if we've reached the end point 262 if x == endX and y == endY then 263 break 264 end 265 266 -- Calculate next position 267 local e2 = 2 * err 268 if e2 > -dy then 269 err = err - dy 270 x = x + sx 271 end 272 if e2 < dx then 273 err = err + dx 274 y = y + sy 275 end 276 end 277 tr:addAnnotation(string.format("Drew %d pixels", pixelsDrawn)) 278 Tracer.addSpan(tr:endSpan()) 279 end 280 281 ---Draws a sequence of elements in a row or column 282 ---@param startNum number Starting number 283 ---@param count number Number of elements to draw 284 ---@param startX number Starting X position 285 ---@param startY number Starting Y position 286 ---@param incrementX number X increment between elements 287 ---@param incrementY number Y increment between elements 288 ---@param parentId string|nil Optional parent trace ID 289 local function drawSequence(startNum, count, startX, startY, incrementX, incrementY, parentId) 290 if mon == nil then return end -- Add nil check (indirect usage via drawElement) 291 local tr = Tracer.new() 292 tr:setName("ring.drawSequence") 293 tr:addTag("startNum", string.format("%d", startNum)) 294 tr:addTag("count", string.format("%d", count)) 295 tr:addTag("startX", string.format("%d", startX)) 296 tr:addTag("startY", string.format("%d", startY)) 297 tr:addTag("incrementX", string.format("%d", incrementX)) 298 tr:addTag("incrementY", string.format("%d", incrementY)) 299 if parentId then 300 tr:setParentId(parentId) 301 end 302 303 local x, y = startX, startY 304 for i = 0, count - 1 do 305 local num = startNum + i 306 drawElement(x, y, num % 2 == 0 and COLOR.RED or COLOR.BLACK, num, tr.traceId) 307 x = x + incrementX 308 y = y + incrementY 309 end 310 tr:addAnnotation(string.format("Drew %d elements", count)) 311 Tracer.addSpan(tr:endSpan()) 312 end 313 314 ---Draws the ball at a specific roulette number position 315 ---@param number number Roulette number position 316 ---@param parentId string|nil Optional parent trace ID 317 local function drawBall(number, parentId) 318 if mon == nil then return end -- Add nil check (indirect usage via drawElement/drawBallElement) 319 local tr = Tracer.new() 320 tr:setName("ring.drawBall") 321 tr:addTag("number", string.format("%d", number)) 322 if parentId then 323 tr:setParentId(parentId) 324 end 325 326 local x, y = numberToPos(number, tr.traceId) 327 if x == nil or y == nil then 328 tr:addAnnotation("Invalid number, cannot draw ball") 329 Tracer.addSpan(tr:endSpan()) 330 return 331 end 332 333 -- Clear the last ball position 334 if lastBallPos.x ~= nil then 335 tr:addAnnotation(string.format("Clearing last ball at (%d, %d)", lastBallPos.x, lastBallPos.y)) 336 drawElement(lastBallPos.x, lastBallPos.y, COLOR.BG, nil, tr.traceId) 337 end 338 339 -- Draw the new ball 340 tr:addAnnotation(string.format("Drawing new ball at (%d, %d)", x, y)) 341 drawBallElement(x, y, tr.traceId) 342 343 -- Remember this position 344 lastBallPos.x = x 345 lastBallPos.y = y 346 Tracer.addSpan(tr:endSpan()) 347 end 348 349 ---Draws the decorative middle area of the roulette board 350 ---@param startX number Starting X position 351 ---@param startY number Starting Y position 352 ---@param parentId string|nil Optional parent trace ID 353 local function drawMiddleDecoration(startX, startY, parentId) 354 if mon == nil then return end -- Add nil check (indirect usage via drawLine/isInBounds) 355 local tr = Tracer.new() 356 tr:setName("ring.drawMiddleDecoration") 357 tr:addTag("startX", string.format("%d", startX)) 358 tr:addTag("startY", string.format("%d", startY)) 359 if parentId then 360 tr:setParentId(parentId) 361 end 362 363 -- Calculate the center of the ring 364 local endX = SPACING_X * 12 + START_X - 1 365 local endY = SPACING_Y * 12 + START_Y - 1 366 local centerX = (startX + endX) / 2 367 local centerY = (startY + endY) / 2 368 369 local writableStartX = startX + ELEMENT_WIDTH * 2 370 local writableEndX = endX - ELEMENT_WIDTH * 2 371 local writableStartY = startY + ELEMENT_HEIGHT * 2 372 local writableEndY = endY - ELEMENT_HEIGHT * 2 373 374 -- Ball size 375 local ballRadius = 4 376 377 -- Draw a line at the horizontal center going from left to right 378 drawLine(writableStartX, centerY, writableEndX, centerY, COLOR.WHITE, 1, tr.traceId) 379 380 -- Draw a line at the vertical center going from top to bottom 381 drawLine(centerX, writableStartY, centerX, writableEndY, COLOR.WHITE, 1, tr.traceId) 382 383 -- Write a diagonal line from top-left to bottom-right 384 drawLine(writableStartX, writableStartY, writableEndX, writableEndY, COLOR.WHITE, 1, tr.traceId) 385 386 -- Write a diagonal line from top-right to bottom-left 387 drawLine(writableEndX, writableStartY, writableStartX, writableEndY, COLOR.WHITE, 1, tr.traceId) 388 389 -- Draw a square around the center, with a line encasing the entire writable area 390 drawLine(writableStartX, writableStartY, writableEndX, writableStartY, COLOR.LIGHT, 1, tr.traceId) 391 drawLine(writableStartX, writableStartY, writableStartX, writableEndY, COLOR.LIGHT, 1, tr.traceId) 392 drawLine(writableEndX, writableStartY, writableEndX, writableEndY, COLOR.LIGHT, 1, tr.traceId) 393 drawLine(writableStartX, writableEndY, writableEndX, writableEndY, COLOR.LIGHT, 1, tr.traceId) 394 395 -- Draw the central ball 396 mon.setBackgroundColor(COLOR.GRAY) 397 local circlePixels = 0 398 for y = -ballRadius, ballRadius do 399 for x = -ballRadius, ballRadius do 400 -- Check if point is within circle 401 if x * x + y * y <= ballRadius * ballRadius then 402 local drawX = centerX + x 403 local drawY = centerY + y 404 if isInBounds(drawX, drawY, tr.traceId) then 405 mon.setCursorPos(drawX, drawY) 406 mon.write(" ") 407 circlePixels = circlePixels + 1 408 end 409 end 410 end 411 end 412 tr:addAnnotation(string.format("Drew center decoration, circle pixels: %d", circlePixels)) 413 Tracer.addSpan(tr:endSpan()) 414 end 415 416 -- ============================== 417 -- Main Drawing Functions 418 -- ============================== 419 420 ---Draws the complete roulette ring 421 ---@param parentId string|nil Optional parent trace ID 422 local function drawRing(parentId) 423 if mon == nil then return end -- Add nil check 424 local tr = Tracer.new() 425 tr:setName("ring.drawRing") 426 if parentId then 427 tr:setParentId(parentId) 428 end 429 430 mon.setBackgroundColor(COLOR.BG) 431 mon.clear() 432 433 -- Calculate positions 434 local endX = SPACING_X * 11 + START_X 435 local endY = SPACING_Y * 11 + START_Y 436 local midX = endX - ELEMENT_WIDTH 437 local midY = endY - ELEMENT_HEIGHT 438 439 -- Draw corners 440 drawElement(START_X, START_Y, COLOR.BLACK, nil, tr.traceId) 441 drawElement(START_X + ELEMENT_WIDTH, START_Y, COLOR.BLACK, 1, tr.traceId) 442 drawElement(START_X, START_Y + ELEMENT_HEIGHT, COLOR.BLACK, 1, tr.traceId) 443 444 -- Draw top row (2-9) 445 drawSequence(2, 8, START_X + 2 * ELEMENT_WIDTH, START_Y, SPACING_X, 0, tr.traceId) 446 447 -- Draw top-right corner 448 drawElement(midX, START_Y, COLOR.RED, 10, tr.traceId) 449 drawElement(endX, START_Y, COLOR.RED, nil, tr.traceId) 450 drawElement(endX, START_Y + ELEMENT_HEIGHT, COLOR.RED, 10, tr.traceId) 451 452 -- Draw right column (11-18) 453 drawSequence(11, 8, endX, START_Y + 2 * ELEMENT_HEIGHT, 0, SPACING_Y, tr.traceId) 454 455 -- Draw bottom-right corner 456 drawElement(endX, midY, COLOR.BLACK, 19, tr.traceId) 457 drawElement(endX, endY, COLOR.BLACK, nil, tr.traceId) 458 drawElement(midX, endY, COLOR.BLACK, 19, tr.traceId) 459 460 -- Draw bottom row (20-27) 461 drawSequence(20, 8, midX - ELEMENT_WIDTH, endY, -SPACING_X, 0, tr.traceId) 462 463 -- Draw bottom-left corner 464 drawElement(START_X + ELEMENT_WIDTH, endY, COLOR.RED, 28, tr.traceId) 465 drawElement(START_X, endY, COLOR.RED, nil, tr.traceId) 466 drawElement(START_X, midY, COLOR.RED, 28, tr.traceId) 467 468 -- Draw left column (29-36) 469 drawSequence(29, 8, START_X, midY - ELEMENT_HEIGHT, 0, -SPACING_Y, tr.traceId) 470 471 -- Draw the decorative middle 472 drawMiddleDecoration(START_X, START_Y, tr.traceId) 473 474 tr:addAnnotation("Ring drawn") 475 Tracer.addSpan(tr:endSpan()) 476 end 477 478 ---Animates the ball movement with easing 479 ---@param force number How many positions to move 480 ---@param parentId string|nil Optional parent trace ID 481 ---@return number The final ball position 482 local function launchBall(force, parentId) 483 if mon == nil then return ballPos end -- Add nil check, return current pos 484 local tr = Tracer.new() 485 tr:setName("ring.launchBall") 486 tr:addTag("force", string.format("%d", force)) 487 tr:addTag("startPos", string.format("%d", ballPos)) 488 if parentId then 489 tr:setParentId(parentId) 490 end 491 492 -- Pre-calculate final position 493 local newBallPos = (ballPos + force) % RING_SIZE 494 if newBallPos == 0 then 495 newBallPos = RING_SIZE 496 end 497 tr:addTag("finalPos", string.format("%d", newBallPos)) 498 499 -- Draw the ring once before animation 500 drawRing(tr.traceId) 501 502 -- Define animation parameters 503 local minSleep = 0.01 504 local maxSleep = 0.45 505 local sleepRange = maxSleep - minSleep 506 507 -- Easing function for smooth animation 508 local function ease(step) 509 if step < 0.8 then 510 return 0.5 * step / 0.8 511 else 512 return 0.5 + 0.5 * (1 - math.pow(1 - (step - 0.8) / 0.2, 3)) 513 end 514 end 515 516 -- Animate through every position 517 for step = 1, force do 518 -- Calculate current position 519 local currentPos = (ballPos + step) % RING_SIZE 520 if currentPos == 0 then 521 currentPos = RING_SIZE 522 end 523 524 -- Draw the ball at each position 525 drawBall(currentPos, tr.traceId) 526 527 -- Add a tiny bit of randomness to the sleep time 528 local randomFactor = math.random() * 0.02 - 0.01 529 local sleepTime = minSleep + ease(step / force) * sleepRange + randomFactor 530 sleep(sleepTime) 531 end 532 533 -- Update ball position to final location 534 ballPos = newBallPos 535 tr:addAnnotation(string.format("Animation complete, final position: %d", ballPos)) 536 537 -- Make the winning number blink 538 local x, y = numberToPos(ballPos, tr.traceId) 539 if x and y then 540 local originalColor = ballPos % 2 == 0 and COLOR.RED or COLOR.BLACK 541 local blinkCount = 10 542 tr:addAnnotation(string.format("Blinking winning number %d times", blinkCount)) 543 544 for i = 1, blinkCount do 545 -- Invert colors 546 drawElement(x, y, COLOR.WHITE, ballPos, tr.traceId) 547 mon.setTextColor(originalColor) 548 local numberX = x + math.floor(ELEMENT_WIDTH / 2) 549 if ballPos >= 10 then 550 numberX = numberX - 1 551 end 552 mon.setCursorPos(numberX, y + math.floor(ELEMENT_HEIGHT / 2)) 553 mon.write(tostring(ballPos)) 554 sleep(0.3) 555 556 -- Return to original 557 drawElement(x, y, originalColor, ballPos, tr.traceId) 558 sleep(0.3) 559 end 560 561 -- Draw ball at final position 562 drawBall(ballPos, tr.traceId) 563 tr:addAnnotation("Blinking finished") 564 else 565 tr:addAnnotation("Could not get position for blinking") 566 end 567 Tracer.addSpan(tr:endSpan()) 568 return ballPos 569 end 570 571 -- ============================== 572 -- Main Program Loop 573 -- ============================== 574 575 local ring = {} 576 577 -- Public API 578 ring.drawRing = function() 579 drawRing() 580 end 581 ring.launchBall = function(force) 582 return launchBall(force) 583 end 584 ring.drawBall = function(number) 585 drawBall(number) 586 end 587 ring.numberToPos = function(number) 588 return numberToPos(number) 589 end 590 591 -- Getters 592 function ring.getBallPosition() 593 local tr = Tracer.new() 594 tr:setName("ring.getBallPosition") 595 tr:addAnnotation(string.format("Current ball position: %d", ballPos)) 596 Tracer.addSpan(tr:endSpan()) 597 return ballPos 598 end 599 600 ---Initializes the ring with a monitor peripheral 601 ---@param monitor string The name of the monitor peripheral 602 function ring.init(monitor) 603 local tr = Tracer.new() 604 tr:setName("ring.init") 605 tr:addTag("monitor", monitor) 606 607 mon = peripheral.wrap(monitor) 608 609 -- Monitor validation 610 if mon == nil then 611 tr:addAnnotation("Monitor not found") 612 Tracer.addSpan(tr:endSpan()) 613 error("Monitor not found", 0) 614 return 615 end 616 if not mon.isColour() then 617 tr:addAnnotation("Monitor is not color") 618 Tracer.addSpan(tr:endSpan()) 619 error("Monitor is not color", 0) 620 return 621 end 622 623 local w, h = mon.getSize() 624 tr:addTag("monitorWidth", string.format("%d", w)) 625 tr:addTag("monitorHeight", string.format("%d", h)) 626 -- Center the ring on the monitor 627 local totalWidth = SPACING_X * 12 628 local totalHeight = SPACING_Y * 12 629 630 START_X = math.floor((w - totalWidth) / 2) 631 START_Y = math.floor((h - totalHeight) / 2) 632 633 -- Ensure minimum margins 634 START_X = math.max(START_X, 2) 635 START_Y = math.max(START_Y, 2) 636 tr:addTag("startX", string.format("%d", START_X)) 637 tr:addTag("startY", string.format("%d", START_Y)) 638 639 mon.setTextScale(0.5) 640 641 drawRing(tr.traceId) 642 drawBall(ballPos, tr.traceId) 643 tr:addAnnotation("Initialization complete") 644 Tracer.addSpan(tr:endSpan()) 645 return ring 646 end 647 648 -- Return the module 649 return ring