Compare commits

..

58 Commits

Author SHA1 Message Date
Sage Vaillancourt 4b9a94c2c2 Implement grounders and fly-outs.
Add a slight delay on npc fielder actions.
Speed up intro when userTeam == nil
2025-03-01 12:59:12 -05:00
Sage Vaillancourt decd1f7080 Fix inverted dance animation
Switch XyPair and Point3d to @class, over @alias
Seems to work better with autocomplete
2025-03-01 09:54:05 -05:00
Sage Vaillancourt 04d25127fc Extract batting.lua 2025-02-27 19:31:00 -05:00
Sage Vaillancourt 8b66e2e826 Drop within(). Just use distance between 2025-02-26 14:51:54 -05:00
Sage Vaillancourt 876f828117 Extract draw/ball.lua
Tweak pointIsSquarelyAboveLine() -> pointIsAboveLine()
2025-02-26 14:20:36 -05:00
Sage Vaillancourt ce9a2d335e Fix to re-enable user backwards baserunning 2025-02-26 13:14:19 -05:00
Sage Vaillancourt 80015dbe62 Extract draw/characters.lua
Pulls a bunch of draw logic out of main.lua; handles z-ordering.
Expand on save/load - though it's certainly not complete yet.
2025-02-26 13:04:38 -05:00
Sage Vaillancourt 176a7e6d5e Add saveToFile()
Not *likely* to work yet, but start scoping out good times to make a save.
Also, correct the Pitch type.
2025-02-25 17:14:47 -05:00
Sage Vaillancourt 55a3a7b0ee More type hints.
Also, move pointDirectlyUnderLine() to XyPair-based.
2025-02-24 23:55:03 -05:00
Sage Vaillancourt ddfdc8947a Bail early on pitch() if one was not actually requested 2025-02-24 22:37:15 -05:00
Sage Vaillancourt e035c0ca72 Missed a spot on the font-building 2025-02-24 20:18:44 -05:00
Sage Vaillancourt 668fa9ffd4 Add fonts to assets.lua2p
Include until-now missing Roobert 11 font
Rename font-full-circle to fix casing
2025-02-24 20:16:18 -05:00
Sage Vaillancourt b4ac028cd9 Consolidate assets into src/assets/ 2025-02-24 19:58:47 -05:00
sage 30aa5bd6c6 Merge pull request 'extract-input-controllers' (#4) from extract-input-controllers into main
Reviewed-on: #4
2025-02-24 19:50:23 -05:00
Sage Vaillancourt 09e48b65b4 Fix pitching 2025-02-24 19:49:32 -05:00
Sage Vaillancourt 9bbd68c302 Some other small main.lua refactoring 2025-02-24 17:01:51 -05:00
Sage Vaillancourt 7c7b5ff762 Extract Game:updatePitching()
Also, pull more from updateGameState() into updateBatting()
2025-02-24 16:24:21 -05:00
Sage Vaillancourt b928ee3658 Tuck bat away if `batter` is `nil` 2025-02-24 15:55:59 -05:00
Sage Vaillancourt 3a465cb02d Extract UserInput as an InputHandler.
Homogenous with Npc, which now also implements InputHandler.
2025-02-24 15:37:05 -05:00
Sage Vaillancourt b44756ff57 Add testBall.lua, testMain.lua, and testStatistics.lua
testMain.lua is really just a does-this-big-harness-work check right now, but it does work!
Extract statistics.lua for testing
Consolidate BoxScore ALL into draw/box-score.lua
2025-02-24 13:33:34 -05:00
Sage Vaillancourt 48a9854653 Remove unused param 2025-02-24 11:36:07 -05:00
Sage Vaillancourt 51c80fa427 Add testActionQueue 2025-02-24 11:34:28 -05:00
Sage Vaillancourt 19ddae6273 Remove or update some outdated TODOs 2025-02-23 18:23:19 -05:00
Sage Vaillancourt 687bf74979 Check for a game over after each score.
In case the home team just came from behind in the bottom of the final inning.
2025-02-23 18:17:09 -05:00
Sage Vaillancourt aa72d2a19f Remove the TODO for the previous runner fix. 2025-02-23 18:11:39 -05:00
Sage Vaillancourt f42ef06ff6 Only one runner can be safe on one base at a time.
Test this new change.
Add custom printTable() for use in test code.
2025-02-23 18:10:56 -05:00
Sage Vaillancourt aceefeb25c Add a screen for showing the game's controls
Tweak MainMenu appearance to show this new option.
Simple new drawButton() graphics function.
Set a max value for transition delta, to keep from leaving gaps in the mask.
2025-02-23 13:10:09 -05:00
Sage Vaillancourt d82ab06534 Use `-v` in `make test` 2025-02-23 11:11:04 -05:00
Sage Vaillancourt 3715361718 Move fielder targeting to array-based system
Allows for *light* path-finding, but is currently liable to totally lose track of weirdly-hit balls.
BUT this may be more of an issue of not correctly parsing the ball's state (home run, foul ball, etc.
Bat is now white with a black outline.
Some linting.
Run tests with `-v`
playdate.timer.new in mocks.lua
Add test for ball-catchability.
2025-02-23 11:10:40 -05:00
Sage Vaillancourt 7525daccb6 Add testGraphics.lua
Fix bug on exactly-zero/exactly-400 ballX
2025-02-23 11:03:10 -05:00
Sage Vaillancourt 9dc8b10f15 Better spacing in assets file 2025-02-22 14:01:37 -05:00
Sage Vaillancourt cea10a7706 Sort assets alphabetically 2025-02-22 13:43:34 -05:00
Sage Vaillancourt 7deadbe316 Remove some accidentally-committed testing code
Use blipper for scoredRunners
2025-02-21 15:18:11 -05:00
Sage Vaillancourt 7b49603760 Fix flickering on return-to-pitcher.
Some linting.
Prevent runners "sticking" to bases during walk/homeRun sequences.
Tweak logo to remove some trailing pixels.
2025-02-21 15:05:14 -05:00
Sage Vaillancourt 786f80b0df Add dark-skin player sprites.
SpriteCollection -> PlayerImageBundle
SpriteCollection is now PlayerImageBundle[]
2025-02-20 20:33:46 -05:00
Sage Vaillancourt 384a14fe5f Draw fans in the stands 2025-02-20 15:21:20 -05:00
Sage Vaillancourt 56c0c27d75 Add perfect-power indicator to throwMeter
Some other tightening-up in there.
E.g. clears the lastReadThrow when on a new fielder.
Add type annotations to assets files.
2025-02-20 13:56:57 -05:00
Sage Vaillancourt 35c7754207 Merge branch 'main' of https://git.sagev.space/sage/BatterUp 2025-02-20 13:52:22 -05:00
Sage Vaillancourt 92985da58f Add some sprites for fans 2025-02-20 13:51:44 -05:00
Sage Vaillancourt 17a30e9822 Extract 'Fielders' type and drop 'ignore 631' 2025-02-20 01:03:04 -05:00
Sage Vaillancourt 2d6f83a23f Add some slight linger time to draw/throwMeter 2025-02-20 00:28:14 -05:00
Sage Vaillancourt e45231dadd Lower MinCharge and idealPower
Makes it easier to throw at top speed without having to flick the crank.
Added a bit of a multiplier on the returned powerRatio, to compensate.
2025-02-20 00:16:32 -05:00
Sage Vaillancourt 08a3189780 Start supporting less accurate pitches
Fix secondsSinceRunnerLastMove nil bug
2025-02-20 00:06:43 -05:00
Sage Vaillancourt d77675b0cb Fix initial throw to pitcher being counted as a pitch. 2025-02-19 23:48:19 -05:00
Sage Vaillancourt 56a5e197cd Linting, and pitcherIsReady() timing tweak 2025-02-19 23:38:51 -05:00
Sage Vaillancourt 699dab8c7d Fielder.catchEligible -> Ball.catchable
Much simpler
2025-02-19 23:06:22 -05:00
Sage Vaillancourt b003c148a4 Extract more into pitching.
More consistent (and visible!) throw meter.
It's still imperfect (by a lot!) but it feels much more controlled.
Throws are a little too soft right now, but it's in a halfway decent state.
2025-02-19 22:32:10 -05:00
Sage Vaillancourt 52434fe891 Correct and further consolidate returnToPitcher() 2025-02-19 17:26:05 -05:00
Sage Vaillancourt ad82035ccc Pan back from home runs
Move secondsSinceLastRunnerMove into Baserunning
Tweak draw offset logic - a little jumpy, but better at following the long ball
Taller GrassBackground
Move secondsSinceLastPitch into pitchTracker
Extract pitchTracker and throwMeter
Slightly more truthful utils.moveAtSpeed()
2025-02-19 17:13:06 -05:00
Sage Vaillancourt aebbc35bac Add some quick MenuMusic
And extract a bit more pitchTracker logic
2025-02-18 15:50:22 -05:00
Sage Vaillancourt 2d812f2046 Add basic home-run handling
Still needs to pan the camera back from the home run while the runners circle the bases.
Also add a wrapping-pattern.png, though I'm not sure if it's actually used?
2025-02-18 13:58:44 -05:00
Sage Vaillancourt 1bdcc62347 Runners smile instead of frown after scoring. 2025-02-17 20:55:35 -05:00
Sage Vaillancourt c3a9122580 integer -> number 2025-02-17 20:42:44 -05:00
Sage Vaillancourt e20ad0d3ad Add swing-and-a-miss strikes 2025-02-17 20:37:16 -05:00
Sage Vaillancourt 4c9fbcdee7 More advanced statistics and displays. 2025-02-17 20:17:26 -05:00
Sage Vaillancourt 1ccf8765ee Remove some outdated TODOs and comments 2025-02-17 13:26:51 -05:00
Sage Vaillancourt 5c45b7bba0 Add box score and transitions
Add constants defining the top of the outfield wall (not used yet)
Take scores out of mutable global state (that might be just about all of it sewn up)
Finish switching batttingTeam to a TeamId value
2025-02-17 13:21:28 -05:00
Sage Vaillancourt 6007ac971f Add .luacheckrc, remove selene.toml 2025-02-16 18:43:09 -05:00
101 changed files with 3358 additions and 1135 deletions

4
.luacheckrc Normal file
View File

@ -0,0 +1,4 @@
std = "lua54+playdate"
stds.project = {
read_globals = {"playdate"}
}

View File

@ -9,7 +9,7 @@ check: assets
luacheck -d --codes src/ --exclude-files src/test/ luacheck -d --codes src/ --exclude-files src/test/
test: check test: check
(cd src; find ./test -name '*lua' | xargs -L1 lua) (cd src; find ./test -name '*lua' | xargs -L1 -I %% lua %% -v)
lint: lint:
stylua --indent-type Spaces src/ stylua --indent-type Spaces src/

View File

@ -2,8 +2,7 @@
-- These warning-allieviators could also be injected directly into __types.lua -- These warning-allieviators could also be injected directly into __types.lua
-- Base __types.lua can be found at https://github.com/balpha/playdate-types -- Base __types.lua can be found at https://github.com/balpha/playdate-types
-- selene: allow(unused_variable) ---@type pd_playdate_lib
-- selene: allow(unscoped_variables)
playdate = playdate playdate = playdate
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)

View File

@ -1 +0,0 @@
std = "lua53"

View File

@ -1,5 +1,6 @@
---@class ActionQueue
---@field queue table<any, { coroutine: thread, expireTimeMs: number }>
actionQueue = { actionQueue = {
---@type table<any, { coroutine: thread, expireTimeMs: number }>
queue = {}, queue = {},
} }
@ -25,8 +26,23 @@ function actionQueue:upsert(id, maxTimeMs, action)
} }
end end
--- The new action will not be added if an entry with the current id already exists in the queue.
---@param id any
---@param maxTimeMs number
---@param action Action
function actionQueue:newOnly(id, maxTimeMs, action)
if self.queue[id] then
return
end
self.queue[id] = {
coroutine = coroutine.create(action),
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
}
end
--- Must be called on every playdate.update() to check for (and run) any waiting tasks. --- Must be called on every playdate.update() to check for (and run) any waiting tasks.
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired. --- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
---@param deltaSeconds number
function actionQueue:runWaiting(deltaSeconds) function actionQueue:runWaiting(deltaSeconds)
local currentTimeMs = playdate.getCurrentTimeMilliseconds() local currentTimeMs = playdate.getCurrentTimeMilliseconds()
@ -42,3 +58,8 @@ function actionQueue:runWaiting(deltaSeconds)
end end
end end
end end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return actionQueue
end

View File

@ -1,6 +1,6 @@
local gfx = playdate.graphics local gfx = playdate.graphics
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft") local AnnouncementFont <const> = Roobert20Medium
local AnnouncementTransitionMs <const> = 300 local AnnouncementTransitionMs <const> = 300
local AnnouncerMarginX <const> = 26 local AnnouncerMarginX <const> = 26
@ -44,6 +44,7 @@ function Announcer:popIn()
end) end)
end end
---@param text string
function Announcer:say(text) function Announcer:say(text)
self.textQueue[#self.textQueue + 1] = text self.textQueue[#self.textQueue + 1] = text
if #self.textQueue == 1 then if #self.textQueue == 1 then
@ -51,6 +52,8 @@ function Announcer:say(text)
end end
end end
---@param x number
---@param y number
function Announcer:draw(x, y) function Announcer:draw(x, y)
if #self.textQueue == 0 then if #self.textQueue == 0 then
return return

View File

@ -2,60 +2,191 @@
-- Instead, edit the source file directly: assets.lua2p. -- Instead, edit the source file directly: assets.lua2p.
-- luacheck: ignore -- luacheck: ignore
DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png") ---@type pd_image
-- luacheck: ignore BallBackground = playdate.graphics.image.new("assets/images/game/BallBackground.png")
Glove = playdate.graphics.image.new("images/game/Glove.png")
-- luacheck: ignore
PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
-- luacheck: ignore
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
-- luacheck: ignore
GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")
-- luacheck: ignore
Hat = playdate.graphics.image.new("images/game/Hat.png")
-- luacheck: ignore
DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png")
-- luacheck: ignore
MenuImage = playdate.graphics.image.new("images/game/MenuImage.png")
-- luacheck: ignore
PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png")
-- luacheck: ignore
Minimap = playdate.graphics.image.new("images/game/Minimap.png")
-- luacheck: ignore
GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png")
-- luacheck: ignore
LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png")
-- luacheck: ignore
LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png")
-- luacheck: ignore -- luacheck: ignore
BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav") ---@type pd_image
BigBat = playdate.graphics.image.new("assets/images/game/BigBat.png")
-- luacheck: ignore -- luacheck: ignore
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav") ---@type pd_image
DarkPlayerAwayBack = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBack.png")
-- luacheck: ignore -- luacheck: ignore
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav") ---@type pd_image
DarkPlayerAwayBase = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBase.png")
-- luacheck: ignore -- luacheck: ignore
TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav") ---@type pd_image
DarkPlayerFrown = playdate.graphics.image.new("assets/images/game/DarkPlayerFrown.png")
-- luacheck: ignore
---@type pd_image
DarkPlayerHomeBack = playdate.graphics.image.new("assets/images/game/DarkPlayerHomeBack.png")
-- luacheck: ignore
---@type pd_image
DarkPlayerHomeBase = playdate.graphics.image.new("assets/images/game/DarkPlayerHomeBase.png")
-- luacheck: ignore
---@type pd_image
DarkPlayerSmile = playdate.graphics.image.new("assets/images/game/DarkPlayerSmile.png")
-- luacheck: ignore
---@type pd_image
DarkSkinFan = playdate.graphics.image.new("assets/images/game/DarkSkinFan.png")
-- luacheck: ignore
---@type pd_image
GameLogo = playdate.graphics.image.new("assets/images/game/GameLogo.png")
-- luacheck: ignore
---@type pd_image
GloveHoldingBall = playdate.graphics.image.new("assets/images/game/GloveHoldingBall.png")
-- luacheck: ignore
---@type pd_image
Glove = playdate.graphics.image.new("assets/images/game/Glove.png")
-- luacheck: ignore
---@type pd_image
GrassBackground = playdate.graphics.image.new("assets/images/game/GrassBackground.png")
-- luacheck: ignore
---@type pd_image
GrassBackgroundSmall = playdate.graphics.image.new("assets/images/game/GrassBackgroundSmall.png")
-- luacheck: ignore
---@type pd_image
Hat = playdate.graphics.image.new("assets/images/game/Hat.png")
-- luacheck: ignore
---@type pd_image
LightPlayerAwayBack = playdate.graphics.image.new("assets/images/game/LightPlayerAwayBack.png")
-- luacheck: ignore
---@type pd_image
LightPlayerAwayBase = playdate.graphics.image.new("assets/images/game/LightPlayerAwayBase.png")
-- luacheck: ignore
---@type pd_image
LightPlayerFrown = playdate.graphics.image.new("assets/images/game/LightPlayerFrown.png")
-- luacheck: ignore
---@type pd_image
LightPlayerHomeBack = playdate.graphics.image.new("assets/images/game/LightPlayerHomeBack.png")
-- luacheck: ignore
---@type pd_image
LightPlayerHomeBase = playdate.graphics.image.new("assets/images/game/LightPlayerHomeBase.png")
-- luacheck: ignore
---@type pd_image
LightPlayerSmile = playdate.graphics.image.new("assets/images/game/LightPlayerSmile.png")
-- luacheck: ignore
---@type pd_image
LightSkinFan = playdate.graphics.image.new("assets/images/game/LightSkinFan.png")
-- luacheck: ignore
---@type pd_image
MenuImage = playdate.graphics.image.new("assets/images/game/MenuImage.png")
-- luacheck: ignore
---@type pd_image
Minimap = playdate.graphics.image.new("assets/images/game/Minimap.png")
-- luacheck: ignore
---@type pd_image
PerfectPowerBg = playdate.graphics.image.new("assets/images/game/PerfectPowerBg.png")
-- luacheck: ignore
---@type pd_image
PerfectPowerFlickerLeft = playdate.graphics.image.new("assets/images/game/PerfectPowerFlickerLeft.png")
-- luacheck: ignore
---@type pd_image
PerfectPowerFlickerRight = playdate.graphics.image.new("assets/images/game/PerfectPowerFlickerRight.png")
-- luacheck: ignore
---@type pd_sampleplayer
BatCrackReverb = playdate.sound.sampleplayer.new("assets/sounds/BatCrackReverb.wav")
-- luacheck: ignore
---@type pd_sampleplayer
BootTuneOrgany = playdate.sound.sampleplayer.new("assets/music/BootTuneOrgany.wav")
-- luacheck: ignore
---@type pd_sampleplayer
BootTune = playdate.sound.sampleplayer.new("assets/music/BootTune.wav")
-- luacheck: ignore
---@type pd_sampleplayer
MenuMusic = playdate.sound.sampleplayer.new("assets/music/MenuMusic.wav")
-- luacheck: ignore
---@type pd_sampleplayer
TinnyBackground = playdate.sound.sampleplayer.new("assets/music/TinnyBackground.wav")
-- luacheck: ignore
---@type pd_font
AshevilleSans14Bold = playdate.graphics.font.new("assets/fonts/Asheville-Sans-14-Bold.pft")
-- luacheck: ignore
---@type pd_font
FontFullCircle = playdate.graphics.font.new("assets/fonts/Font-Full-Circle.pft")
-- luacheck: ignore
---@type pd_font
NanoSans = playdate.graphics.font.new("assets/fonts/Nano Sans.pft")
-- luacheck: ignore
---@type pd_font
Roobert11Medium = playdate.graphics.font.new("assets/fonts/Roobert-11-Medium.pft")
-- luacheck: ignore
---@type pd_font
Roobert20Medium = playdate.graphics.font.new("assets/fonts/Roobert-20-Medium.pft")
Logos = { Logos = {
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") }, { name = "Base", image = playdate.graphics.image.new("assets/images/game/logos/Base.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") }, ---@type pd_image
{ name = "Arrows", image = playdate.graphics.image.new("assets/images/game/logos/Arrows.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") }, ---@type pd_image
{ name = "Cats", image = playdate.graphics.image.new("assets/images/game/logos/Cats.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") }, ---@type pd_image
{ name = "Checkmarks", image = playdate.graphics.image.new("assets/images/game/logos/Checkmarks.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") }, ---@type pd_image
{ name = "FingerGuns", image = playdate.graphics.image.new("assets/images/game/logos/FingerGuns.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") }, ---@type pd_image
{ name = "Frown", image = playdate.graphics.image.new("assets/images/game/logos/Frown.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") }, ---@type pd_image
{ name = "Hearts", image = playdate.graphics.image.new("assets/images/game/logos/Hearts.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") }, ---@type pd_image
{ name = "Smiles", image = playdate.graphics.image.new("assets/images/game/logos/Smiles.png") },
-- luacheck: ignore -- luacheck: ignore
{ name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") }, ---@type pd_image
{ name = "Turds", image = playdate.graphics.image.new("assets/images/game/logos/Turds.png") },
} }

View File

@ -1,10 +1,11 @@
!(function dirLookup(dir, extension, newFunc, sep, handle) !(function dirLookup(dir, extension, newFunc, type, sep, indent, handle)
sep = sep or "\n" indent = indent or ""
sep = sep or "\n\n"
handle = handle ~= nil and handle or function(varName, value) handle = handle ~= nil and handle or function(varName, value)
return varName .. ' = ' .. value return varName .. ' = ' .. value
end end
--Open directory look for files, save data in p. By giving '-type f' as parameter, it returns all files.
local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f') local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f | sort -h')
local assetCode = "" local assetCode = ""
--Loop through all files --Loop through all files
@ -12,8 +13,9 @@
if file:find(extension) then if file:find(extension) then
local varName = file:gsub(".*/(.*)." .. extension, "%1") local varName = file:gsub(".*/(.*)." .. extension, "%1")
file = file:gsub("src/", "") file = file:gsub("src/", "")
assetCode = assetCode .. '-- luacheck: ignore\n' assetCode = assetCode .. indent .. '-- luacheck: ignore\n'
assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep assetCode = assetCode .. indent .. '---@type ' .. type ..'\n'
assetCode = assetCode .. indent .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
end end
end end
return assetCode return assetCode
@ -23,13 +25,16 @@ function generatedFileWarning()
return "-- GENERATED FILE - DO NOT EDIT\n-- Instead, edit the source file directly: assets.lua2p." return "-- GENERATED FILE - DO NOT EDIT\n-- Instead, edit the source file directly: assets.lua2p."
end)!!(generatedFileWarning()) end)!!(generatedFileWarning())
!!(dirLookup('images/game', 'png', 'playdate.graphics.image.new')) !!(dirLookup('assets/images/game', 'png', 'playdate.graphics.image.new', 'pd_image'))
!!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new')) !!(dirLookup('assets/sounds', 'wav', 'playdate.sound.sampleplayer.new', 'pd_sampleplayer'))
!!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new')) !!(dirLookup('assets/music', 'wav', 'playdate.sound.sampleplayer.new', 'pd_sampleplayer'))
!!(dirLookup('assets/fonts', 'fnt', 'playdate.graphics.font.new', 'pd_font', nil, nil, function(varName, value)
return varName:gsub("[- ]", "") .. " = " .. value:gsub("fnt", "pft")
end))
Logos = { Logos = {
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") }, { name = "Base", image = playdate.graphics.image.new("assets/images/game/logos/Base.png") },
!!(dirLookup('images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', ",\n", function(varName, value) !!(dirLookup('assets/images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', 'pd_image', ",\n\n", " ", function(varName, value)
return '{ name = "' .. varName .. '", image = ' .. value .. ' }' return '{ name = "' .. varName .. '", image = ' .. value .. ' }'
end)) end))
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,295 @@
tracking=1
space 3
! 2
" 5
# 9
$ 8
% 12
& 11
' 3
( 5
) 5
* 8
+ 8
, 3
- 6
. 2
/ 6
0 9
1 4
2 9
3 9
4 9
5 9
6 9
7 9
8 10
9 9
: 2
; 2
< 7
= 7
> 7
? 9
@ 11
A 10
B 9
C 9
D 9
E 8
F 8
G 9
H 9
I 2
J 8
K 10
L 9
M 12
N 9
O 9
P 9
Q 9
R 9
S 9
T 10
U 9
V 10
W 14
X 8
Y 8
Z 8
[ 3
\ 6
] 3
^ 6
_ 8
` 3
a 8
b 8
c 8
d 8
e 8
f 6
g 8
h 8
i 2
j 4
k 8
l 2
m 12
n 8
o 8
p 8
q 8
r 6
s 8
t 6
u 8
v 8
w 12
x 9
y 8
z 8
{ 6
| 2
} 6
~ 10
… 8
¥ 8
‼ 5
™ 8
© 11
® 11
。 16
、 16
ぁ 16
あ 16
ぃ 16
い 16
ぅ 16
う 16
ぇ 16
え 16
ぉ 16
お 16
か 16
が 16
き 16
ぎ 16
く 16
ぐ 16
け 16
げ 16
こ 16
ご 16
さ 16
ざ 16
し 16
じ 16
す 16
ず 16
せ 16
ぜ 16
そ 16
ぞ 16
た 16
だ 16
ち 16
ぢ 16
っ 16
つ 16
づ 16
て 16
で 16
と 16
ど 16
な 16
に 16
ぬ 16
ね 16
の 16
は 16
ば 16
ぱ 16
ひ 16
び 16
ぴ 16
ふ 16
ぶ 16
ぷ 16
へ 16
べ 16
ぺ 16
ほ 16
ぼ 16
ぽ 16
ま 16
み 16
む 16
め 16
も 16
ゃ 16
や 16
ゅ 16
ゆ 16
ょ 16
よ 16
ら 16
り 16
る 16
れ 16
ろ 16
ゎ 16
わ 16
ゐ 16
ゑ 16
を 16
ん 16
ゔ 16
ゕ 16
ゖ 16
゛ 1
゜ 0
ゝ 16
ゞ 16
ゟ 16
16
ァ 16
ア 16
ィ 16
イ 16
ゥ 16
ウ 16
ェ 16
エ 16
ォ 16
オ 16
カ 16
ガ 16
キ 16
ギ 16
ク 16
グ 16
ケ 16
ゲ 16
コ 16
ゴ 16
サ 16
ザ 16
シ 16
ジ 16
ス 16
ズ 16
セ 16
ゼ 16
ソ 16
ゾ 16
タ 16
ダ 16
チ 16
ヂ 16
ッ 16
ツ 16
ヅ 16
テ 16
デ 16
ト 16
ド 16
ナ 16
ニ 16
ヌ 16
ネ 16
16
ハ 16
バ 16
パ 16
ヒ 16
ビ 16
ピ 16
フ 16
ブ 16
プ 16
ヘ 16
ベ 16
ペ 16
ホ 16
ボ 16
ポ 16
マ 16
ミ 16
ム 16
メ 16
モ 16
ャ 16
ヤ 16
ュ 16
ユ 16
ョ 16
ヨ 16
ラ 16
リ 16
ル 16
レ 16
ロ 16
ヮ 16
ワ 16
ヰ 16
ヱ 16
ヲ 16
ン 16
ヴ 16
ヵ 16
ヶ 16
ヷ 16
ヸ 16
ヹ 16
ヺ 16
・ 16
ー 16
ヽ 16
ヾ 16
ヿ 16
「 16
」 16
円 16
<EFBFBD> 13

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,104 @@
tracking=1
space 2
A 3
B 3
T 3
a 3
b 3
c 3
d 3
e 3
f 3
g 3
h 3
i 1
l 2
q 3
r 3
s 3
w 5
z 3
j 1
n 3
o 3
p 3
m 5
k 3
t 3
u 3
v 3
y 3
x 3
. 1
C 3
D 3
E 3
F 3
G 3
H 3
I 3
0 3
1 3
8 3
9 3
7 3
6 3
5 3
4 3
3 3
2 3
: 1
; 1
! 1
" 3
{ 3
} 3
| 1
J 3
K 3
L 3
M 5
N 4
O 3
W 5
U 3
V 3
X 3
Y 3
Z 3
Q 3
S 3
R 3
P 3
[ 2
] 2
^ 3
< 3
= 3
> 3
? 3
@ 4
\ 3
_ 3
` 2
~ 5
¥ 3
… 5
™ 5
‼ 3
© 5
® 5
<EFBFBD> 5
# 5
/ 3
- 3
+ 3
, 1
* 3
) 2
( 2
' 1
$ 3
% 3
& 4

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,242 @@
--metrics={"baseline":0,"xHeight":0,"capHeight":0,"pairs":{"ac":[0,0],"ad":[0,0],"ae":[0,0],"af":[-1,0,0,0],"ag":[0,0],"ap":[0,0],"ar":[1,0,0,0],"at":[-1,0,0,0],"au":[0,0],"av":[-1,0,0,0],"aw":[-1,0,0,0],"ay":[-1,0,0,0],"b,":[-1,0,0,0],"b.":[-1,0,0,0],"bl":[0,0],"br":[0,0],"bu":[0,0],"by":[-1,0,0,0],"ca":[0,0],"ch":[0,0],"ck":[0,0],"d,":[-1,0,0,0],"d.":[0,0],"da":[0,0],"dc":[0,0],"de":[0,0],"dg":[0,0],"do":[0,0],"dt":[0,0],"du":[0,0],"dv":[0,0],"dw":[0,0],"dy":[0,0],"e,":[-1,0,0,0],"e.":[-1,0,0,0],"ea":[0,0],"ei":[0,0],"el":[0,0],"em":[0,0],"en":[0,0],"ep":[0,0],"er":[0,0],"et":[-1,0,0,0],"eu":[0,0],"ev":[-1,0,0,0],"ew":[-1,0,0,0],"ey":[-1,0,0,0],"f,":[-2,0,0,0],"f.":[-2,0,0,0],"fa":[-1,0,0,0],"fe":[-1,0,0,0],"ff":[-2,0,0,0],"fi":[0,0],"fl":[-1,0,0,0],"fo":[-2,0,0,0],"g,":[0,0],"g.":[0,0],"ga":[0,0],"ge":[0,0],"gg":[0,0],"gh":[0,0],"gl":[0,0],"go":[0,0],"hc":[0,0],"hd":[0,0],"he":[0,0],"hg":[0,0],"ho":[0,0],"hp":[0,0],"ht":[-1,0,0,0],"hu":[0,0],"hv":[-1,0,0,0],"hw":[-1,0,0,0],"hy":[-1,0,0,0],"ic":[-1,0,0,0],"id":[-1,0,0,0],"ie":[-1,0,0,0],"ig":[-1,0,0,0],"io":[-1,0,0,0],"ip":[-1,0,0,0],"it":[-2,0,0,0],"iu":[-1,0,0,0],"iv":[-1,0,0,0],"j,":[0,0],"j.":[0,0],"ja":[0,0],"je":[0,0],"jo":[0,0],"ju":[0,0],"ka":[-2,0,0,0],"kc":[-2,0,0,0],"kd":[-2,0,0,0],"ke":[-2,0,0,0],"kg":[-2,0,0,0],"ko":[-2,0,0,0],"la":[0,0],"lc":[0,0],"ld":[0,0],"le":[0,0],"lf":[0,0],"lg":[0,0],"lo":[0,0],"Lo":[-1,0,0,0],"lp":[0,0],"lq":[0,0],"lu":[0,0],"lv":[0,0],"lw":[0,0],"ly":[0,0],"ma":[0,0],"mc":[0,0],"md":[0,0],"me":[0,0],"mg":[0,0],"mn":[0,0],"mo":[0,0],"mp":[0,0],"mt":[-1,0,0,0],"mu":[0,0],"mv":[-1,0,0,0],"my":[-1,0,0,0],"nc":[0,0],"nd":[0,0],"ne":[0,0],"ng":[0,0],"no":[0,0],"np":[0,0],"nt":[-1,0,0,0],"nu":[0,0],"nv":[-1,0,0,0],"nw":[-1,0,0,0],"ny":[-1,0,0,0],"o,":[-2,0,0,0],"o.":[-1,0,0,0],"ob":[0,0],"of":[-2,0,0,0],"oh":[0,0],"oj":[-2,0,0,0],"ok":[0,0],"ol":[0,0],"om":[0,0],"on":[0,0],"op":[0,0],"or":[0,0],"ou":[0,0],"ov":[-1,0,0,0],"ow":[-1,0,0,0],"ox":[-1,0,0,0],"oy":[-1,0,0,0],"p,":[-1,0,0,0],"p.":[-1,0,0,0],"pa":[0,0],"ph":[0,0],"pi":[0,0],"pl":[0,0],"pp":[0,0],"pu":[0,0],"qu":[0,0],"r,":[-3,0,0,0],"r.":[-2,0,0,0],"ra":[-1,0,0,0],"rd":[-1,0,0,0],"re":[-1,0,0,0],"rg":[-1,0,0,0],"rk":[0,0],"rl":[0,0],"rm":[0,0],"rn":[0,0],"ro":[-2,0,0,0],"rq":[-1,0,0,0],"rr":[0,0],"rt":[-1,0,0,0],"rv":[0,0],"ry":[0,0],"s,":[-1,0,0,0],"s.":[-1,0,0,0],"sh":[0,0],"st":[-1,0,0,0],"su":[0,0],"t,":[0,0],"t.":[1,0,0,0],"ta":[1,0,0,0],"td":[0,0],"te":[0,0],"th":[0,0],"ti":[1,0,0,0],"tl":[1,0,0,0],"to":[0,0],"ua":[0,0],"uc":[0,0],"ud":[0,0],"ue":[0,0],"ug":[0,0],"uo":[0,0],"up":[1,0,0,0],"uq":[0,0],"ur":[1,0,0,0],"ut":[0,0],"uv":[0,0],"uw":[0,0],"uy":[0,0],"v,":[-2,0,0,0],"v.":[-2,0,0,0],"va":[0,0],"vb":[0,0],"vc":[-1,0,0,0],"vd":[-1,0,0,0],"ve":[-1,0,0,0],"vg":[-1,0,0,0],"vo":[-1,0,0,0],"vv":[0,0],"vy":[-1,0,0,0],"w,":[-2,0,0,0],"w.":[-1,0,0,0],"wa":[-1,0,0,0],"wd":[-1,0,0,0],"we":[-1,0,0,0],"wg":[-1,0,0,0],"wh":[0,0],"wo":[-1,0,0,0],"wx":[-1,0,0,0],"xa":[-1,0,0,0],"xe":[-1,0,0,0],"xo":[-1,0,0,0],"y,":[-3,0,0,0],"y.":[-2,0,0,0],"ya":[-1,0,0,0],"yc":[-1,0,0,0],"yd":[-1,0,0,0],"ye":[-1,0,0,0],"Yo":[-2,0,0,0],"yo":[-1,0,0,0],"LO":[-2,0,0,0],"AT":[-3,0,0,0],"AY":[-3,0,0,0],"//":[-4,0,0,0],"/d":[-2,0,0,0],"/p":[-1,0,0,0],"tp":[1,0,0,0],"t:":[1,0,0,0],"/w":[-1,0,0,0],"ot":[-1,0,0,0],"Wo":[-2,0,0,0],"Fo":[-2,0,0,0],"Fu":[-2,0,0,0],"Vu":[-1,0,0,0],"Tu":[-2,0,0,0],"To":[-3,0,0,0],"Vo":[-2,0,0,0],"Yu":[-1,0,0,0],"Zo":[-1,0,0,0],"ty":[-1,0,0,0],"is":[-1,0,0,0]},"left":[],"right":[]}
tracking=1
0 12
1 5
2 11
3 12
4 12
5 11
6 12
7 11
8 11
9 12
space 3
! 2
" 6
# 14
$ 11
% 15
& 13
' 2
( 5
) 5
* 8
+ 10
, 3
- 8
. 2
/ 9
: 2
; 4
< 9
= 11
> 9
? 9
@ 18
A 13
B 11
C 14
D 12
E 10
F 10
G 14
H 12
I 2
J 5
K 12
L 9
M 15
N 11
O 15
P 10
Q 15
R 10
S 11
T 12
U 12
V 12
W 18
X 11
Y 10
Z 11
[ 5
\ 9
] 5
^ 7
_ 11
` 3
a 9
b 10
c 10
d 10
e 10
f 7
g 10
h 9
i 3
j 4
k 10
l 2
m 16
n 9
o 11
p 10
q 10
r 6
s 8
t 7
u 9
v 8
w 14
x 9
y 10
z 9
{ 6
| 2
} 6
~ 10
¥ 10
… 12
™ 16
‼ 6
© 15
® 15
<EFBFBD> 15
Ⓐ 18
Ⓑ 18
🌐 18
14
▸ 12
⊙ 18
3
3
“ 6
” 6
af -1
ar 1
at -1
av -1
aw -1
ay -1
b, -1
b. -1
by -1
d, -1
e, -1
e. -1
et -1
ev -1
ew -1
ey -1
f, -2
f. -2
fa -1
fe -1
ff -2
fl -1
fo -2
ht -1
hv -1
hw -1
hy -1
ic -1
id -1
ie -1
ig -1
io -1
ip -1
it -2
iu -1
iv -1
ka -2
kc -2
kd -2
ke -2
kg -2
ko -2
Lo -1
mt -1
mv -1
my -1
nt -1
nv -1
nw -1
ny -1
o, -2
o. -1
of -2
oj -2
ov -1
ow -1
ox -1
oy -1
p, -1
p. -1
r, -3
r. -2
ra -1
rd -1
re -1
rg -1
ro -2
rq -1
rt -1
s, -1
s. -1
st -1
t. 1
ta 1
ti 1
tl 1
up 1
ur 1
v, -2
v. -2
vc -1
vd -1
ve -1
vg -1
vo -1
vy -1
w, -2
w. -1
wa -1
wd -1
we -1
wg -1
wo -1
wx -1
xa -1
xe -1
xo -1
y, -3
y. -2
ya -1
yc -1
yd -1
ye -1
Yo -2
yo -1
LO -2
AT -3
AY -3
// -4
/d -2
/p -1
tp 1
t: 1
/w -1
ot -1
Wo -2
Fo -2
Fu -2
Vu -1
Tu -2
To -3
Vo -2
Yu -1
Zo -1
ty -1
is -1

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 592 B

After

Width:  |  Height:  |  Size: 592 B

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 738 B

View File

Before

Width:  |  Height:  |  Size: 601 B

After

Width:  |  Height:  |  Size: 601 B

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

View File

Before

Width:  |  Height:  |  Size: 592 B

After

Width:  |  Height:  |  Size: 592 B

View File

Before

Width:  |  Height:  |  Size: 589 B

After

Width:  |  Height:  |  Size: 589 B

View File

Before

Width:  |  Height:  |  Size: 600 B

After

Width:  |  Height:  |  Size: 600 B

View File

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 579 B

View File

Before

Width:  |  Height:  |  Size: 589 B

After

Width:  |  Height:  |  Size: 589 B

View File

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 593 B

View File

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 596 B

View File

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 587 B

View File

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 598 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

View File

@ -1,30 +1,31 @@
---@class Ball ---@class Ball
---@field x number ---@field x number
---@field y number ---@field y number
---@field xVelocity number
---@field yVelocity number
---@field z number ---@field z number
---@field flyBall boolean
---@field size number ---@field size number
---@field heldBy Fielder | nil ---@field heldBy Fielder | nil
---@field catchable boolean
---@field isFlyBall boolean
---@field xAnimator SimpleAnimator ---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator ---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator
---@field zAnimator SimpleAnimator ---@field floatAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib ---@field private animatorLib pd_animator_lib
---@field private bounce thread Requires deltaSeconds on resume, returns ball height.
---@field private flyTimeMs number
Ball = {} Ball = {}
local function defaultFloatAnimator(animatorLib)
return animatorLib.new(2000, -60, 0, utils.easingHill)
end
---@param animatorLib pd_animator_lib ---@param animatorLib pd_animator_lib
---@return Ball ---@return Ball
function Ball.new(animatorLib) function Ball.new(animatorLib)
return setmetatable({ return setmetatable({
animatorLib = animatorLib, animatorLib = animatorLib,
x = 400 --[[@as number]], x = C.Center.x --[[@as number]],
y = 300 --[[@as number]], y = C.Center.y --[[@as number]],
z = 0, z = 0,
flyBall = false, catchable = true,
size = C.SmallestBallRadius, size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]], heldBy = nil --[[@type Runner | nil]],
@ -34,184 +35,69 @@ function Ball.new(animatorLib)
-- TODO? Replace these with a ballAnimatorZ? -- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk -- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius), sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
zAnimator = animatorLib.new(2000, -60, 0, utils.easingHill), floatAnimator = defaultFloatAnimator(animatorLib),
}, { __index = Ball }) }, { __index = Ball })
end end
---@param fielder Fielder
function Ball:caughtBy(fielder)
self.heldBy = fielder
-- if self.flyBall then
-- TODO: Signal an out if the ball hasn't touched the ground.
-- end
end
function timeToFirstBounce(v)
local h0 = 0.1 -- m/s
-- local v = 10 -- m/s, current velocity
local g = 10 -- m/s/s
local t = 0 -- starting time
local rho = 0.75 -- coefficient of restitution
local tau = 0.10 -- contact time for bounce
local hmax = h0 -- keep track of the maximum height
local h = h0
local hstop = 0.01 -- stop when bounce is less than 1 cm
local freefall = true -- state: freefall or in contact
local vmax = math.sqrt(2 * hmax * g)
local dt = 0.1
local ret = 0
while hmax > hstop do
if freefall then
local hnew = h + v * dt - 0.5 * g * dt * dt
if hnew < 0 then
return ret
else
t = t + dt
v = v - g * dt
h = hnew
end
else
t = t + tau
vmax = vmax * rho
v = vmax
freefall = true
h = 0
end
hmax = 0.5 * vmax * vmax / g
ret = ret + dt
end
return 0
end
function bouncer(v, ball)
printTable(ball)
local startMs = playdate.getCurrentTimeMilliseconds()
return function()
local h0 = 0.1 -- m/s
-- local v = 10 -- m/s, current velocity
local g = 10 -- m/s/s
local t = 0 -- starting time
local rho = 0.60 -- coefficient of restitution
local tau = 0.10 -- contact time for bounce
local hmax = h0 -- keep track of the maximum height
local h = h0
local hstop = 0.01 -- stop when bounce is less than 1 cm
local freefall = true -- state: freefall or in contact
local t_last = -math.sqrt(2 * h0 / g) -- time we would have launched to get to h0 at t=0
local vmax = math.sqrt(v * g)
while hmax > hstop do
local dt = coroutine.yield(h, not freefall)
if freefall then
local hnew = h + v * dt - 0.5 * g * dt * dt
if hnew < 0 then
-- Bounced!
t = t_last + 2 * math.sqrt(2 * hmax / g)
freefall = false
t_last = t + tau
h = 0
else
t = t + dt
v = v - g * dt
h = hnew
end
else
t = t + tau
vmax = vmax * rho
v = vmax
freefall = true
h = 0
end
hmax = 0.5 * vmax * vmax / g
end
return 0
end
end
---@param deltaSeconds number ---@param deltaSeconds number
function Ball:updatePosition(deltaSeconds) function Ball:updatePosition(deltaSeconds)
-- printTable({ x = self.x, y = self.y, z = self.z })
if self.heldBy then if self.heldBy then
utils.moveAtSpeedZ(self, deltaSeconds * 10, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ }) utils.moveAtSpeedZ(self, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ })
-- self.x = self.heldBy.x self.size = C.SmallestBallRadius
-- self.y = self.heldBy.y
-- self.z = C.GloveZ
-- self.size = C.SmallestBallRadius
else else
-- self.x = self.x + self.xVelocity self.x = self.xAnimator:currentValue()
-- self.x = self.xAnimator:currentValue() local z = self.floatAnimator:currentValue()
-- self.y = self.yAnimator:currentValue() -- TODO: This `+ z` is more graphics logic than physics logic
-- self.z = self.zAnimator:currentValue() self.y = self.yAnimator:currentValue() + z
if self.bounce then self.z = z
local alive, z, justBounced = coroutine.resume(self.bounce, deltaSeconds) if self.z < 2 and self.isFlyBall then
if alive then print("Ball hit the ground!")
local lostVelMult = justBounced and 0.8 or 0.99 self.isFlyBall = false
self.xVelocity = self.xVelocity * (1 - (deltaSeconds * lostVelMult))
self.yVelocity = self.yVelocity * (1 - (deltaSeconds * lostVelMult))
self.x = self.x + (self.xVelocity * deltaSeconds)
self.y = self.y + (self.yVelocity * deltaSeconds)
self.z = z * 30
else
self.bounce = nil
end end
end self.size = C.SmallestBallRadius + math.max(0, (self.floatAnimator:currentValue() - C.GloveZ) / 2)
self.size = (math.max(0, 0.04 * (self.z - C.GloveZ))) + C.SmallestBallRadius
-- print(self.size)
end
if self.z < 1 then
self.flyBall = false
end end
end end
---@alias DestinationAndFlightTime { destX: number, destY: number, flyTimeMs: number } function Ball:markUncatchable()
self.catchable = false
---@alias LaunchControls DestinationAndFlightTime playdate.timer.new(200, function()
self.catchable = true
end)
end
--- Launches the ball from its current position to the given destination. --- Launches the ball from its current position to the given destination.
---@param destX number ---@param destX number
---@param destY number ---@param destY number
---@param easingFunc EasingFunc ---@param easingFunc EasingFunc
---@param freshHit boolean | nil ---@param flyTimeMs number | nil
---@param flyTimeMs number | nil The angle away from parallel to the ground. ---@param floaty boolean | nil
--- 0 is straight forward, 90 is straight up, 180 is straight behind. ---@param customFloater pd_animator | nil
function Ball:launch(destX, destY, _, freshHit, _, power) ---@param isHit boolean
if freshHit then function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customFloater, isHit)
self.flyBall = true
end
throwMeter:reset()
self.heldBy = nil self.heldBy = nil
self.isFlyBall = isHit
local flightDistance, x, y = utils.distanceBetween(self.x, self.y, destX, destY) -- Prevent silly insta-catches
local timeToBounce = timeToFirstBounce(10) self:markUncatchable()
-- if not flyTimeMs then if not flyTimeMs then
-- flyTimeMs = flightDistance * C.DefaultLaunchPower flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
-- end end
-- TODO? set a maxThrowDistance to limit throws by, instead if customFloater then
power = power or 5 self.floatAnimator = customFloater
self.xVelocity = -1.1 * x -- (x / -flightDistance) * power else
self.yVelocity = -1.1 * y -- (y / -flightDistance) * power self.sizeAnimator = self.animatorLib.new(flyTimeMs, C.SmallestBallRadius, 9, utils.easingHill)
printTable({ x = x, y = y }) self.floatAnimator = defaultFloatAnimator(self.animatorLib)
printTable({ end
destX = destX, self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
destY = destY, self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
x = x, if floaty then
y = y, self.floatAnimator:reset(flyTimeMs)
-- xVelocity = self.xVelocity, end
-- yVelocity = self.yVelocity, end
-- timeToBounce = timeToBounce,
}) -- luacheck: ignore
-- -- TODO? Scale based on distance? if not playdate or playdate.TEST_MODE then
-- self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) return Ball
-- self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
-- self.zAnimator = self.animatorLib.new(flyTimeMs, flightDistance, C.GloveZ, utils.easingHill)
self.bounce = coroutine.create(bouncer(10))
end end

View File

@ -1,10 +1,10 @@
--- @alias Runner { --- @class Runner
--- x: number, --- @field x number
--- y: number, --- @field y number
--- nextBase: Base, --- @field nextBase Base
--- prevBase: Base | nil, --- @field prevBase Base | nil
--- forcedTo: Base | nil, --- @field forcedTo Base | nil
--- } --- @field spriteIndex number
---@class Baserunning ---@class Baserunning
---@field runners Runner[] ---@field runners Runner[]
@ -12,15 +12,18 @@
---@field scoredRunners Runner[] ---@field scoredRunners Runner[]
---@field batter Runner | nil ---@field batter Runner | nil
---@field outs number ---@field outs number
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
---@field secondsSinceLastRunnerMove number
---@field announcer Announcer ---@field announcer Announcer
---@field onThirdOut fun() ---@field onThirdOut fun()
Baserunning = {} Baserunning = {}
-- TODO: Implement slides. Would require making fielders' gloves "real objects" whose state is tracked. -- TODO: Implement slides? Would require making fielders' gloves "real objects" whose state is tracked.
---@param announcer Announcer ---@param announcer Announcer
---@param onThirdOutCallback fun()
---@return Baserunning ---@return Baserunning
function Baserunning.new(announcer, onThirdOut) function Baserunning.new(announcer, onThirdOutCallback)
local o = setmetatable({ local o = setmetatable({
runners = {}, runners = {},
outRunners = {}, outRunners = {},
@ -30,7 +33,7 @@ function Baserunning.new(announcer, onThirdOut)
--- it seems sensible to store the value here. --- it seems sensible to store the value here.
outs = 0, outs = 0,
announcer = announcer, announcer = announcer,
onThirdOut = onThirdOut, onThirdOut = onThirdOutCallback,
}, { __index = Baserunning }) }, { __index = Baserunning })
o:pushNewBatter() o:pushNewBatter()
@ -38,7 +41,7 @@ function Baserunning.new(announcer, onThirdOut)
return o return o
end end
---@param runner integer | Runner ---@param runner number | Runner
---@param message string | nil ---@param message string | nil
---@return boolean wasThirdOut ---@return boolean wasThirdOut
function Baserunning:outRunner(runner, message) function Baserunning:outRunner(runner, message)
@ -50,9 +53,8 @@ function Baserunning:outRunner(runner, message)
end end
end end
end end
if type(runner) ~= "number" then local runnerType = type(runner)
error("Expected runner to have type 'number', but was: " .. type(runner)) assert(runnerType == "number", "Expected runner to have type 'number', but was: " .. runnerType)
end
self.outRunners[#self.outRunners + 1] = self.runners[runner] self.outRunners[#self.outRunners + 1] = self.runners[runner]
table.remove(self.runners, runner) table.remove(self.runners, runner)
@ -65,7 +67,6 @@ function Baserunning:outRunner(runner, message)
self.onThirdOut() self.onThirdOut()
self.outs = 0 self.outs = 0
-- TODO: outRunners/scoredRunners split
while #self.runners > 0 do while #self.runners > 0 do
self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners) self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
end end
@ -79,9 +80,17 @@ function Baserunning:outEligibleRunners(fielder)
local touchedBase = utils.isTouchingBase(fielder.x, fielder.y) local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
local didOutRunner = false local didOutRunner = false
local runnerBaseBiMap = {}
for _, runner in pairs(self.runners) do
local theTouchedBase = utils.isTouchingBase(runner.x, runner.y)
if theTouchedBase ~= nil and runnerBaseBiMap[theTouchedBase] == nil then
runnerBaseBiMap[runner] = theTouchedBase
runnerBaseBiMap[theTouchedBase] = runner
end
end
for i, runner in pairs(self.runners) do for i, runner in pairs(self.runners) do
local runnerOnBase = utils.isTouchingBase(runner.x, runner.y) local runnerOnBase = runnerBaseBiMap[runner]
-- TODO: Tag-outs when two baserunners are on the same base.
if -- Force out if -- Force out
touchedBase touchedBase
and runner.prevBase -- Make sure the runner is not standing at home and runner.prevBase -- Make sure the runner is not standing at home
@ -126,18 +135,32 @@ function Baserunning:convertBatterToRunner()
end end
---@param deltaSeconds number ---@param deltaSeconds number
function Baserunning:walkAwayOutRunners(deltaSeconds) ---@param runner Runner
for i, runner in ipairs(self.outRunners) do ---@return boolean isStillWalking
local function walkWayOutRunner(deltaSeconds, runner)
if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
runner.x = runner.x + (deltaSeconds * 25) runner.x = runner.x + (deltaSeconds * 25)
runner.y = runner.y + (deltaSeconds * 25) runner.y = runner.y + (deltaSeconds * 25)
else return true
end
return false
end
---@param deltaSeconds number
function Baserunning:walkAwayOutRunners(deltaSeconds)
for i, runner in ipairs(self.outRunners) do
if not walkWayOutRunner(deltaSeconds, runner) then
table.remove(self.outRunners, i) table.remove(self.outRunners, i)
end end
end end
for i, runner in ipairs(self.scoredRunners) do
if not walkWayOutRunner(deltaSeconds, runner) then
table.remove(self.scoredRunners, i)
end
end
end end
---@return Runner ---@return Runner theBatterPushed
function Baserunning:pushNewBatter() function Baserunning:pushNewBatter()
local new = { local new = {
-- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol. -- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol.
@ -146,27 +169,31 @@ function Baserunning:pushNewBatter()
nextBase = C.RightHandedBattersBox, nextBase = C.RightHandedBattersBox,
prevBase = nil, prevBase = nil,
forcedTo = C.Bases[C.First], forcedTo = C.Bases[C.First],
spriteIndex = math.random(#HomeTeamSpriteGroup),
} }
self.runners[#self.runners + 1] = new self.runners[#self.runners + 1] = new
self.batter = new self.batter = new
return new return new
end end
---@param self table function Baserunning:getNewestRunner()
---@param runnerIndex integer return self.runners[#self.runners]
end
---@param runnerIndex number
function Baserunning:runnerScored(runnerIndex) function Baserunning:runnerScored(runnerIndex)
-- TODO: outRunners/scoredRunners split self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex]
self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex]
table.remove(self.runners, runnerIndex) table.remove(self.runners, runnerIndex)
end end
--- Returns true only if the given runner moved during this update. --- Returns true only if the given runner moved during this update.
---@param runner Runner | nil ---@param runner Runner | nil
---@param runnerIndex integer | nil May only be nil if runner == batter ---@param runnerIndex number | nil May only be nil if runner == batter
---@param appliedSpeed number ---@param appliedSpeed number
---@param isAutoRun boolean
---@param deltaSeconds number ---@param deltaSeconds number
---@return boolean runnerMoved, boolean runnerScored ---@return boolean runnerMoved, boolean runnerScored
function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSeconds) function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun, deltaSeconds)
local autoRunSpeed = 20 * deltaSeconds local autoRunSpeed = 20 * deltaSeconds
if not runner or not runner.nextBase then if not runner or not runner.nextBase then
@ -178,7 +205,7 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
if if
nearestBaseDistance < 5 nearestBaseDistance < 5
and runnerIndex ~= nil and runnerIndex ~= nil
and runner ~= self.batter --runner.prevBase and runner ~= self.batter
and runner.nextBase == C.Bases[C.Home] and runner.nextBase == C.Bases[C.Home]
and nearestBase == C.Bases[C.Home] and nearestBase == C.Bases[C.Home]
then then
@ -213,9 +240,12 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
-- TODO: Make this less "sticky" for the user. -- TODO: Make this less "sticky" for the user.
-- Currently it can be a little hard to run *past* a base. -- Currently it can be a little hard to run *past* a base.
local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed local autoRun = 0
if not isAutoRun then
autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
or nearestBaseDistance < 5 and 0 or nearestBaseDistance < 5 and 0
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed) or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
end
mult = autoRun + (appliedSpeed / 20) mult = autoRun + (appliedSpeed / 20)
runner.x = runner.x - (x * mult) runner.x = runner.x - (x * mult)
@ -227,9 +257,12 @@ end
--- Update non-batter runners. --- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update --- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved, number runnersScored ---@param forcedOnly boolean If true, only move forced runners (e.g. for a walk)
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) ---@param isAutoRun boolean If true, does not attempt to hug the bases
local someRunnerMoved = false ---@param deltaSeconds number
---@return boolean runnersStillMoving, number runnersScored, number secondsSinceLastMove
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun, deltaSeconds)
local runnersStillMoving = false
local runnersScored = 0 local runnersScored = 0
local speedIsFunction = type(appliedSpeed) == "function" local speedIsFunction = type(appliedSpeed) == "function"
@ -240,19 +273,23 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
if speedIsFunction then if speedIsFunction then
speed = appliedSpeed(runner) speed = appliedSpeed(runner)
end end
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds) local thisRunnerMoved, thisRunnerScored =
someRunnerMoved = someRunnerMoved or thisRunnerMoved self:updateRunner(runner, runnerIndex, speed, isAutoRun, deltaSeconds)
runnersStillMoving = runnersStillMoving or thisRunnerMoved
if thisRunnerScored then if thisRunnerScored then
runnersScored = runnersScored + 1 runnersScored = runnersScored + 1
end end
end end
end end
if someRunnerMoved then if runnersStillMoving then
self.secondsSinceLastRunnerMove = 0
self:updateForcedRunners() self:updateForcedRunners()
else
self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds
end end
return someRunnerMoved, runnersScored return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
end end
-- luacheck: ignore -- luacheck: ignore

68
src/batting.lua Normal file
View File

@ -0,0 +1,68 @@
---@class BatRenderState
---@field batBase XyPair
---@field batTip XyPair
---@field batAngleDeg number
---@field batSpeed number
---@class Batting
---@field private Baserunning
---@field state BatRenderState Is updated by checkForHit()
Batting = {}
local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170
local OffscreenPos <const> = utils.xy(-999, -999)
---@param baserunning Baserunning
function Batting.new(baserunning)
return setmetatable({
baserunning = baserunning,
state = {
batAngleDeg = 0,
batSpeed = 0,
batTip = OffscreenPos,
batBase = OffscreenPos,
},
}, { __index = Batting })
end
-- TODO? Make the bat angle work more like the throw meter.
-- Would instead constantly drift toward a default value, giving us a little more control,
-- and letting the user find a crank position and direction that works for them
--- Assumes the bat is being held by self.baserunning.batter
--- Mutates self.state for later rendering.
---@param batDeg number
---@param batSpeed number
---@param ball Point3d
---@return XyPair | nil, boolean, number | nil Ball destination or nil if no hit, true only if batter swung, power mult
function Batting:checkForHit(batDeg, batSpeed, ball)
local batter = self.baserunning.batter
local isSwinging = batDeg > SwingBackDeg and batDeg < SwingForwardDeg
local batRadians = math.rad(batDeg)
local base = batter and utils.xy(batter.x + C.BatterHandPos.x, batter.y + C.BatterHandPos.y) or OffscreenPos
local tip = utils.xy(base.x + (C.BatLength * math.sin(batRadians)), base.y + (C.BatLength * math.cos(batRadians)))
self.state.batSpeed = batSpeed
self.state.batAngleDeg = batDeg
self.state.batTip = tip
self.state.batBase = base
local ballWasHit = batSpeed > 0 and ball.y < 232 and utils.pointOnOrUnderLine(ball, base, tip, C.Screen.H)
if not ballWasHit then
return nil, isSwinging
end
local ballAngle = batRadians + math.rad(90)
local mult = math.abs(batSpeed / 15)
local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
return utils.xy(ball.x + ballVelX, ball.y + ballVelY), isSwinging, mult
end

View File

@ -67,39 +67,39 @@ C.ScoreboardDelayMs = 2000
--- generally as a check for whether or not it's in play. --- generally as a check for whether or not it's in play.
C.BallOffscreen = 999 C.BallOffscreen = 999
C.PitchAfterSeconds = 8 C.PitchAfterSeconds = 6
C.ReturnToPitcherAfterSeconds = 2.4 C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050 C.PitchFlyMs = 1050
C.PitchStartX = 195 C.PitchStart = utils.xy(195, 105)
C.PitchStartY, C.PitchEndY = 105, 240 C.PitchEndY = 240
C.DefaultLaunchPower = 4 C.DefaultLaunchPower = 4
--- The max distance at which a fielder can tag out a runner. --- The max distance at which a fielder can tag out a runner.
C.TagDistance = 15 C.TagDistance = 15
C.BallCatchHitbox = 15 C.BallCatchHitbox = 3
--- The max distance at which a runner can be considered on base. --- The max distance at which a runner can be considered on base.
C.BaseHitbox = 10 C.BaseHitbox = 10
C.BattingPower = 25 C.BattingPower = 25
C.BatterHandPos = utils.xy(25, 15) C.BatterHandPos = utils.xy(25, 15)
C.GloveZ = 10 C.GloveZ = 0 -- 10
C.SmallestBallRadius = 6 C.SmallestBallRadius = 6
C.BatLength = 35 C.BatLength = 35
-- TODO: enums implemented this way are probably going to be difficult to serialize! ---@alias OffenseState "batting" | "running" | "walking" | "homeRun"
---@alias OffenseState "batting" | "running" | "walking"
--- An enum for what state the offense is in --- An enum for what state the offense is in
---@type table<string, OffenseState> ---@type table<string, OffenseState>
C.Offense = { C.Offense = {
batting = "batting", batting = "batting",
running = "running", running = "running",
walking = "walking", walking = "walking",
homeRun = "homeRun",
fliedOut = "running",
} }
---@alias Side "offense" | "defense" ---@alias Side "offense" | "defense"
@ -109,15 +109,14 @@ C.PitcherStartPos = {
y = C.Screen.H * 0.40, y = C.Screen.H * 0.40,
} }
C.ThrowMeterMax = 10
C.ThrowMeterDrainPerSec = 150
--- Controls how hard the ball can be hit, and --- Controls how hard the ball can be hit, and
--- how fast the ball can be thrown. --- how fast the ball can be thrown.
C.CrankPower = 10 C.CrankPower = 10
C.FielderRunMult = 1.3 C.FielderRunMult = 1.3
C.PlayerHeightOffset = 20
C.UserThrowPower = 0.3 C.UserThrowPower = 0.3
--- How fast baserunners move after a walk --- How fast baserunners move after a walk
@ -125,6 +124,24 @@ C.WalkedRunnerSpeed = 10
C.ResetFieldersAfterSeconds = 2.5 C.ResetFieldersAfterSeconds = 2.5
C.OutfieldWall = {
{ x = -400, y = -103 },
{ x = -167, y = -208 },
{ x = 50, y = -211 },
{ x = 150, y = -181 },
{ x = 339, y = -176 },
{ x = 450, y = -221 },
{ x = 700, y = -209 },
{ x = 785, y = -59 },
{ x = 801, y = -16 },
}
C.BottomOfOutfieldWall = {}
for i, v in ipairs(C.OutfieldWall) do
C.BottomOfOutfieldWall[i] = utils.xy(v.x, v.y + 40)
end
if not playdate then if not playdate then
return C return C
end end

100
src/control-screen.lua Normal file
View File

@ -0,0 +1,100 @@
local gfx = playdate.graphics
local HeaderFont <const> = Roobert11Medium
local DetailFont <const> = FontFullCircle
---@alias TextObject { text: string, font: pd_font }
---@param texts TextObject[]
local function drawTexts(texts)
local xOffset = 10
local initialOffset <const> = -(HeaderFont:getHeight()) / 2
local yOffset = initialOffset
--- The text height plus a margin scaled to that height
function getOffsetOffset(textObject)
return (-4 + math.floor(textObject.font:getHeight() * 1.6)) / 2
end
-- Inverted buffer around text to separate it from the background
for _, textObject in ipairs(texts) do
local offsetOffset = getOffsetOffset(textObject)
yOffset = yOffset + offsetOffset
gfx.setImageDrawMode(gfx.kDrawModeInverted)
for x = xOffset - 6, xOffset + 6 do
for y = yOffset - 6, yOffset + 6 do
textObject.font:drawText(textObject.text, x, y)
end
end
yOffset = yOffset + offsetOffset
end
-- Drawing the actual text afterward (instead of inline) keeps the inverted buffer from drawing over it.
yOffset = initialOffset
gfx.setImageDrawMode(gfx.kDrawModeCopy)
for _, textObject in ipairs(texts) do
local offsetOffset = getOffsetOffset(textObject)
yOffset = yOffset + offsetOffset
textObject.font:drawText(textObject.text, xOffset, yOffset)
yOffset = yOffset + offsetOffset
end
end
---@param text string
---@return TextObject
local function header(text)
return { text = text, font = HeaderFont }
end
---@param text string
---@return TextObject
local function detail(text)
return { text = text, font = DetailFont }
end
---@class ControlScreen
---@field sceneToReturnTo Scene
---@field private renderedImage pd_image Static image doesn't need to be constantly re-rendered.
ControlScreen = {}
---@return pd_image
local function draw()
local image = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(image)
BallBackground:draw(0, 0)
drawTexts({
header("Batting:"),
detail("Swing the crank to swing your bat"),
detail("But watch out! Some pitches are tricky!"),
header("Pitching:"),
detail("Swing the crank to pitch the ball"),
detail("But be careful! Throw too hard and it might go wild!"),
detail("(shh: try holding A or B while you pitch)"),
header("Fielding:"),
detail("To throw, hold a direction button and crank!"),
detail("Right throws to 1st, Up goes to 2nd, etc."),
})
gfx.popContext()
return image
end
---@param sceneToReturnTo Scene
---@return ControlScreen
function ControlScreen.new(sceneToReturnTo)
return setmetatable({
sceneToReturnTo = sceneToReturnTo,
renderedImage = draw(),
}, { __index = ControlScreen })
end
function ControlScreen:update()
gfx.animation.blinker.updateAll()
gfx.clear()
self.renderedImage:draw(0, 0)
drawButton("B", 370, 210)
if playdate.buttonJustPressed(playdate.kButtonA) or playdate.buttonJustPressed(playdate.kButtonB) then
transitionBetween(self, self.sceneToReturnTo)
end
end

View File

@ -16,7 +16,8 @@ function dbg.label(value, name)
return value return value
end end
-- Only works if called with the bases empty (i.e. the only runner should be the batter. --- Only works if called with the bases empty (i.e. the only runner should be the batter.
---@param br Baserunning
function dbg.loadTheBases(br) function dbg.loadTheBases(br)
br:pushNewBatter() br:pushNewBatter()
br:pushNewBatter() br:pushNewBatter()
@ -35,6 +36,68 @@ function dbg.loadTheBases(br)
br.runners[4].nextBase = C.Bases[C.Home] br.runners[4].nextBase = C.Bases[C.Home]
end end
local hitSamples = {
away = {
{
utils.xy(7.88733, -16.3434),
utils.xy(378.3376, 30.49521),
utils.xy(367.1036, 21.55336),
},
{
utils.xy(379.8051, -40.82794),
utils.xy(-444.5791, -30.30901),
utils.xy(-30.43079, -30.50307),
},
{
utils.xy(227.8881, -14.56854),
utils.xy(293.5208, 39.38919),
utils.xy(154.4738, -26.55899),
},
},
home = {
{
utils.xy(146.2505, -89.12155),
utils.xy(429.5428, 59.62944),
utils.xy(272.4666, -78.578),
},
{
utils.xy(485.0516, 112.8341),
utils.xy(290.9232, -4.946442),
utils.xy(263.4262, -6.482407),
},
{
utils.xy(260.6927, -63.63049),
utils.xy(392.1548, -44.22421),
utils.xy(482.5545, 105.3476),
utils.xy(125.5928, 18.53091),
},
},
}
---@param inningCount number Number of innings to mock
---@return Statistics
function dbg.mockStatistics(inningCount)
inningCount = inningCount or 9
local stats = Statistics.new()
for i = 1, inningCount - 1 do
stats.innings[i].home.score = math.floor(math.random() * 5)
stats.innings[i].away.score = math.floor(math.random() * 5)
if hitSamples.home[i] ~= nil then
stats.innings[i].home.hits = hitSamples.home[i]
end
if hitSamples.away[i] ~= nil then
stats.innings[i].away.hits = hitSamples.away[i]
end
stats:pushInning()
end
local homeScore, awayScore = utils.totalScores(stats)
if homeScore == awayScore then
stats.innings[#stats.innings].home.score = 1 + stats.innings[#stats.innings].home.score
end
return stats
end
---@param points XyPair[] ---@param points XyPair[]
function dbg.drawLine(points) function dbg.drawLine(points)
for i = 2, #points do for i = 2, #points do

11
src/draw/ball.lua Normal file
View File

@ -0,0 +1,11 @@
local gfx <const> = playdate.graphics
function Ball:draw()
gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(self.x, self.y, self.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(self.x, self.y, self.size)
end

203
src/draw/box-score.lua Normal file
View File

@ -0,0 +1,203 @@
local MarginY <const> = 70
local SmallFont <const> = FontFullCircle
local ScoreFont <const> = AshevilleSans14Bold
local NumWidth <const> = ScoreFont:getTextWidth("0")
local NumHeight <const> = ScoreFont:getHeight()
local AwayWidth <const> = ScoreFont:getTextWidth("AWAY")
local InningMargin = 4
local InningDrawWidth <const> = (InningMargin * 2) + (NumWidth * 2)
local ScoreDrawHeight = NumHeight * 2
-- luacheck: ignore 143
---@type pd_graphics_lib
local gfx = playdate.graphics
local function formatScore(n)
if n <= 9 then
return " " .. n
elseif n <= 19 then
return " " .. n
else
return tostring(n)
end
end
local HomeY <const> = -4 + (NumHeight * 2) + MarginY
local AwayY <const> = -4 + (NumHeight * 3) + MarginY
local function drawInning(x, inningNumber, homeScore, awayScore)
gfx.setColor(gfx.kColorBlack)
gfx.setColor(gfx.kColorWhite)
gfx.setLineWidth(1)
gfx.drawRect(x, 34 + MarginY, InningDrawWidth, ScoreDrawHeight)
inningNumber = " " .. inningNumber
homeScore = formatScore(homeScore)
awayScore = formatScore(awayScore)
x = x - 8 + (InningDrawWidth / 2)
ScoreFont:drawTextAligned(inningNumber, x, -4 + NumHeight + MarginY, gfx.kAlignRight)
ScoreFont:drawTextAligned(awayScore, x, HomeY, gfx.kAlignRight)
ScoreFont:drawTextAligned(homeScore, x, AwayY, gfx.kAlignRight)
end
---@class BoxScore
---@field stats Statistics
---@field private targetY number
BoxScore = {}
---@param stats Statistics
---@return BoxScore
function BoxScore.new(stats)
return setmetatable({
stats = stats,
targetY = 0,
}, { __index = BoxScore })
end
function BoxScore:drawBoxScore()
local inningStart = 4 + (AwayWidth * 1.5)
local widthAndMarg = InningDrawWidth + 4
ScoreFont:drawTextAligned(" HOME", 10, HomeY, gfx.kAlignRight)
ScoreFont:drawTextAligned("AWAY", 10, AwayY, gfx.kAlignRight)
for i = 1, #self.stats.innings do
local inningStats = self.stats.innings[i]
drawInning(inningStart + ((i - 1) * widthAndMarg), i, inningStats.home.score, inningStats.away.score)
end
local homeScore, awayScore = utils.totalScores(self.stats)
drawInning(4 + inningStart + (widthAndMarg * #self.stats.innings), "F", homeScore, awayScore)
ScoreFont:drawTextAligned("v", C.Center.x, C.Screen.H - 40, gfx.kAlignCenter)
end
local GraphM = 10
local GraphW = C.Screen.W - (GraphM * 2)
local GraphH = C.Screen.H - (GraphM * 2)
function BoxScore:drawScoreGraph(y)
-- TODO: Actually draw score legend
-- Offset by 2 to support a) the zero-index b) the score legend
local segmentWidth = GraphW / (#self.stats.innings + 2)
local legendX = segmentWidth * (#self.stats.innings + 2) - GraphM
gfx.drawLine(GraphM / 2, y + GraphM + GraphH, legendX, y + GraphM + GraphH)
gfx.drawLine(legendX, y + GraphM, legendX, y + GraphH + GraphM)
gfx.setLineWidth(3)
local homeScore, awayScore = utils.totalScores(self.stats)
local highestScore = math.max(homeScore, awayScore)
local heightPerPoint = (GraphH - 6) / highestScore
function point(inning, score)
return utils.xy(GraphM + (inning * segmentWidth), y + GraphM + GraphH + (score * -heightPerPoint))
end
function drawLine(teamId)
local linePoints = { point(0, 0) }
local scoreTotal = 0
for i, inning in ipairs(self.stats.innings) do
scoreTotal = scoreTotal + inning[teamId].score
linePoints[#linePoints + 1] = point(i, scoreTotal)
end
dbg.drawLine(linePoints)
local finalPoint = linePoints[#linePoints]
SmallFont:drawTextAligned(string.upper(teamId), finalPoint.x + 3, finalPoint.y - 7, gfx.kAlignRight)
end
drawLine("home")
gfx.setDitherPattern(0.5)
drawLine("away")
gfx.setDitherPattern(0)
end
---@param realHit XyPair
---@return XyPair
function convertHitToMini(realHit)
-- Convert to all-positive y
local y = realHit.y + C.Screen.H
y = y / 2
local x = realHit.x + C.Screen.W
x = x / 3
return utils.xy(x, y)
end
function BoxScore:drawHitChart(y)
local leftMargin = 8
GrassBackgroundSmall:drawCentered(C.Center.x, y + C.Center.y + 54)
gfx.setLineWidth(1)
ScoreFont:drawTextAligned("AWAY", leftMargin, y + C.Screen.H - NumHeight, gfx.kAlignRight)
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer2x2)
gfx.fillRect(leftMargin, y + C.Screen.H - NumHeight, ScoreFont:getTextWidth("AWAY"), NumHeight)
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0.5)
for _, inning in ipairs(self.stats.innings) do
for _, hit in ipairs(inning.away.hits) do
local miniHitPos = convertHitToMini(hit)
gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4)
end
end
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0)
ScoreFont:drawTextAligned(" HOME", leftMargin, y + C.Screen.H - (NumHeight * 2), gfx.kAlignRight)
for _, inning in ipairs(self.stats.innings) do
for _, hit in ipairs(inning.home.hits) do
local miniHitPos = convertHitToMini(hit)
gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4)
end
end
end
local screens = {
BoxScore.drawBoxScore,
BoxScore.drawScoreGraph,
BoxScore.drawHitChart,
}
function BoxScore:render()
local originalDrawMode = gfx.getImageDrawMode()
gfx.clear(gfx.kColorBlack)
gfx.setImageDrawMode(gfx.kDrawModeInverted)
gfx.setColor(gfx.kColorBlack)
for i, screen in ipairs(screens) do
screen(self, (i - 1) * C.Screen.H)
end
gfx.setImageDrawMode(originalDrawMode)
end
local renderedImage
function BoxScore:update()
if not renderedImage then
renderedImage = gfx.image.new(C.Screen.W, C.Screen.H * #screens)
gfx.pushContext(renderedImage)
self:render()
gfx.popContext()
end
local deltaSeconds = playdate.getElapsedTime()
playdate.resetElapsedTime()
gfx.setDrawOffset(0, self.targetY)
renderedImage:draw(0, 0)
local crankChange = playdate.getCrankChange()
if crankChange ~= 0 then
self.targetY = self.targetY - (crankChange * 0.8)
else
local closestScreen = math.floor(0.5 + (self.targetY / C.Screen.H)) * C.Screen.H
if math.abs(self.targetY - closestScreen) > 3 then
local needsIncrease = self.targetY < closestScreen
local change = needsIncrease and 200 * deltaSeconds or -200 * deltaSeconds
self.targetY = self.targetY + change
end
end
self.targetY = math.max(math.min(self.targetY, 0), -C.Screen.H * (#screens - 1))
end

View File

@ -0,0 +1,77 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics
---@alias PlayerImageBundle { smiling: pd_image, lowHat: pd_image, frowning: pd_image, back: pd_image }
---@alias SpriteCollection PlayerImageBundle[]
---@param image pd_image
---@param drawInverted boolean
function maybeDrawInverted(image, x, y, drawInverted)
local drawMode = gfx.getImageDrawMode()
if drawInverted then
gfx.setImageDrawMode(gfx.kDrawModeInverted)
end
image:draw(x, y)
gfx.setImageDrawMode(drawMode)
end
--- TODO: Custom names on jerseys?
---@return PlayerImageBundle
---@param base pd_image
---@param isInverted boolean
function buildPlayerBundle(base, back, smile, frown, logo, isInverted)
local smiling = gfx.image.new(base:getSize())
gfx.lockFocus(smiling)
base:draw(0, 0)
Hat:draw(6, 0)
smile:draw(5, 9)
maybeDrawInverted(logo, 3, 25, isInverted)
local lowHat = gfx.image.new(base:getSize())
gfx.lockFocus(lowHat)
base:draw(0, 0)
Hat:draw(6, 2)
smile:draw(5, 9)
maybeDrawInverted(logo, 3, 25, isInverted)
local frowning = gfx.image.new(base:getSize())
gfx.lockFocus(frowning)
base:draw(0, 0)
maybeDrawInverted(logo, 3, 25, isInverted)
Hat:draw(6, 0)
frown:draw(5, 9)
gfx.unlockFocus()
return {
smiling = smiling,
lowHat = lowHat,
frowning = frowning,
back = back,
}
end
---@type SpriteCollection
AwayTeamSpriteGroup = nil
---@type SpriteCollection
HomeTeamSpriteGroup = nil
function replaceAwayLogo(logo)
AwayTeamSpriteGroup = {
buildPlayerBundle(DarkPlayerAwayBase, DarkPlayerAwayBack, DarkPlayerSmile, DarkPlayerFrown, logo, true),
buildPlayerBundle(LightPlayerAwayBase, LightPlayerAwayBack, LightPlayerSmile, LightPlayerFrown, logo, true),
}
end
function replaceHomeLogo(logo)
HomeTeamSpriteGroup = {
buildPlayerBundle(DarkPlayerHomeBase, DarkPlayerHomeBack, DarkPlayerSmile, DarkPlayerFrown, logo, true),
buildPlayerBundle(LightPlayerHomeBase, LightPlayerHomeBack, LightPlayerSmile, LightPlayerFrown, logo, true),
}
end
replaceAwayLogo(Logos[1].image)
replaceHomeLogo(Logos[2].image)

151
src/draw/characters.lua Normal file
View File

@ -0,0 +1,151 @@
---@class Characters
---@field homeSprites SpriteCollection
---@field awaySprites SpriteCollection
---@field homeBlipper table
---@field awayBlipper table
Characters = {}
local gfx <const> = playdate.graphics
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
---@param homeSprites SpriteCollection
---@param awaySprites SpriteCollection
function Characters.new(homeSprites, awaySprites)
return setmetatable({
homeSprites = homeSprites,
awaySprites = awaySprites,
homeBlipper = blipper.new(100, homeSprites),
awayBlipper = blipper.new(100, awaySprites),
}, { __index = Characters })
end
---@param ball Point3d
---@param fielderX number
---@param fielderY number
---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY, flip)
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
if distanceFromBall > 20 then
Glove:draw(shoulderX, shoulderY, flip)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
return true
end
end
---@param fieldingTeamSprites SpriteCollection
---@param fielder Fielder
---@param ball Point3d
---@param flip boolean | nil
---@return boolean isHoldingBall
function drawFielder(fieldingTeamSprites, fielder, ball, flip)
local danceOffset = FielderDanceAnimator:currentValue()
local x = fielder.x
local y = fielder.y - danceOffset
fieldingTeamSprites[fielder.spriteIndex].smiling:draw(fielder.x, y - 20, flip)
return drawFielderGlove(ball, x, y)
end
---@param batState BatRenderState
local function drawBat(batState)
gfx.setLineWidth(7)
gfx.drawLine(batState.batBase.x, batState.batBase.y, batState.batTip.x, batState.batTip.y)
gfx.setColor(gfx.kColorWhite)
gfx.setLineCapStyle(gfx.kLineCapStyleRound)
gfx.setLineWidth(3)
gfx.drawLine(batState.batBase.x, batState.batBase.y, batState.batTip.x, batState.batTip.y)
gfx.setColor(gfx.kColorBlack)
end
---@param battingTeamSprites SpriteCollection
---@param batter Runner
---@param batState BatRenderState
local function drawBatter(battingTeamSprites, batter, batState)
local spriteCollection = battingTeamSprites[batter.spriteIndex]
if batState.batAngleDeg > 50 and batState.batAngleDeg < 200 then
drawBat(batState)
spriteCollection.back:draw(batter.x, batter.y - C.PlayerHeightOffset)
else
spriteCollection.smiling:draw(batter.x, batter.y - C.PlayerHeightOffset)
drawBat(batState)
end
end
---@param battingTeam TeamId
---@return SpriteCollection battingTeam, SpriteCollection fieldingTeam, table runnerBlipper
function Characters:getSpriteCollections(battingTeam)
if battingTeam == "home" then
return self.homeSprites, self.awaySprites, self.homeBlipper
end
return self.awaySprites, self.homeSprites, self.awayBlipper
end
---@param fielding Fielding
---@param baserunning Baserunning
---@param batState BatRenderState
---@param battingTeam TeamId
---@param ball Point3d
---@return Fielder | nil ballHeldBy
function Characters:drawAll(fielding, baserunning, batState, battingTeam, ball)
---@type { y: number, drawAction: fun() }[]
local characterDraws = {}
function addDraw(y, drawAction)
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
end
local battingTeamSprites, fieldingTeamSprites, runnerBlipper = self:getSpriteCollections(battingTeam)
---@type Fielder | nil
local ballHeldBy
for _, fielder in pairs(fielding.fielders) do
addDraw(fielder.y, function()
local ballHeldByThisFielder = drawFielder(fieldingTeamSprites, fielder, ball)
if ballHeldByThisFielder then
ballHeldBy = fielder
end
end)
end
for _, runner in pairs(baserunning.runners) do
addDraw(runner.y, function()
local currentBatter = baserunning.batter
if runner == currentBatter then
drawBatter(battingTeamSprites, currentBatter, batState)
else
-- TODO? Change blip speed depending on runner speed?
runnerBlipper:draw(false, runner.x, runner.y - C.PlayerHeightOffset, runner)
end
end)
end
for _, runner in pairs(baserunning.outRunners) do
addDraw(runner.y, function()
battingTeamSprites[runner.spriteIndex].frowning:draw(runner.x, runner.y - C.PlayerHeightOffset)
end)
end
for _, runner in pairs(baserunning.scoredRunners) do
addDraw(runner.y, function()
runnerBlipper:draw(false, runner.x, runner.y - C.PlayerHeightOffset, runner)
end)
end
table.sort(characterDraws, function(a, b)
return a.y < b.y
end)
for _, character in pairs(characterDraws) do
character.drawAction()
end
return ballHeldBy
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return Characters
end

36
src/draw/fans.lua Normal file
View File

@ -0,0 +1,36 @@
local gfx <const> = playdate.graphics
fans = {}
local FanImages <const> = { DarkSkinFan, LightSkinFan }
local FanWidth <const>, FanHeight <const> = FanImages[1]:getSize()
local BgWidth <const>, BgHeight <const> = GrassBackground:getSize()
local AudienceImage1 <const> = gfx.image.new(BgWidth, BgHeight)
local AudienceImage2 <const> = gfx.image.new(BgWidth, BgHeight)
local height = 0
while height < BgHeight do
local width = 0
while width < BgWidth do
gfx.pushContext(AudienceImage1)
local image = FanImages[math.random(#FanImages)]
local jiggle = math.random(5)
image:draw(width + jiggle, height)
gfx.popContext()
gfx.pushContext(AudienceImage2)
image:draw(width + jiggle + math.random(0, 2), height)
gfx.popContext()
width = width + FanWidth
end
height = height + FanHeight - 10
end
local AudienceMovement = gfx.animation.blinker.new(200, 200, true)
AudienceMovement:start()
function fans.draw()
local currentImage = AudienceMovement.on and AudienceImage1 or AudienceImage2
currentImage:draw(-400, -720)
end

View File

@ -1,28 +0,0 @@
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
---@param ball Point3d
---@param fielderX number
---@param fielderY number
---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY, flip)
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
if distanceFromBall > 20 then
Glove:draw(shoulderX, shoulderY, flip)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
return true
end
end
---@param playerSprites SpriteCollection
---@param ball Point3d
---@param x number
---@param y number
---@return boolean isHoldingBall
function drawFielder(playerSprites, ball, x, y, flip)
playerSprites.smiling:draw(x, y - 20, flip)
return drawFielderGlove(ball, x, y)
end

View File

@ -1,6 +1,7 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics local gfx = playdate.graphics
local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft") local ScoreFont <const> = FontFullCircle
local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize() local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize()
local MinimapPosX, MinimapPosY = C.Screen.W - MinimapSizeX, C.Screen.H - MinimapSizeY local MinimapPosX, MinimapPosY = C.Screen.W - MinimapSizeX, C.Screen.H - MinimapSizeY
@ -82,11 +83,10 @@ local ScoreboardHeight <const> = 55
local Indicator = "> " local Indicator = "> "
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator) local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
---@param teams any
---@param battingTeam any ---@param battingTeam any
---@return string, number, string, number ---@return string, number, string, number
function getIndicators(teams, battingTeam) function getIndicators(battingTeam)
if teams.home == battingTeam then if battingTeam == "home" then
return Indicator, 0, "", IndicatorWidth return Indicator, 0, "", IndicatorWidth
end end
return "", IndicatorWidth, Indicator, 0 return "", IndicatorWidth, Indicator, 0
@ -100,11 +100,11 @@ local stats = {
battingTeam = nil, battingTeam = nil,
} }
function drawScoreboardImpl(x, y, teams) function drawScoreboardImpl(x, y)
local homeScore = stats.homeScore local homeScore = stats.homeScore
local awayScore = stats.awayScore local awayScore = stats.awayScore
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, stats.battingTeam) local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(stats.battingTeam)
local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore) local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore)
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore) local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
@ -145,17 +145,18 @@ end
local newStats = stats local newStats = stats
function drawScoreboard(x, y, teams, outs, battingTeam, inning) function drawScoreboard(x, y, statistics, outs, battingTeam, inning)
local homeScore, awayScore = utils.totalScores(statistics)
if if
newStats.homeScore ~= teams.home.score newStats.homeScore ~= homeScore
or newStats.awayScore ~= teams.away.score or newStats.awayScore ~= awayScore
or newStats.outs ~= outs or newStats.outs ~= outs
or newStats.inning ~= inning or newStats.inning ~= inning
or newStats.battingTeam ~= battingTeam or newStats.battingTeam ~= battingTeam
then then
newStats = { newStats = {
homeScore = teams.home.score, homeScore = homeScore,
awayScore = teams.away.score, awayScore = awayScore,
outs = outs, outs = outs,
inning = inning, inning = inning,
battingTeam = battingTeam, battingTeam = battingTeam,
@ -164,5 +165,5 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning)
stats = newStats stats = newStats
end) end)
end end
drawScoreboardImpl(x, y, teams) drawScoreboardImpl(x, y)
end end

52
src/draw/panner.lua Normal file
View File

@ -0,0 +1,52 @@
---@class Panner
Panner = {}
local function panCoroutine(ball)
local offset = utils.xy(getDrawOffset(ball.x, ball.y))
while true do
local target, deltaSeconds = coroutine.yield(offset.x, offset.y)
if target == nil then
offset = utils.xy(getDrawOffset(ball.x, ball.y))
else
while utils.moveAtSpeed(offset, 200 * deltaSeconds, target, 20) do
target, deltaSeconds = coroutine.yield(offset.x, offset.y)
end
-- -- Pan back to ball
-- while utils.moveAtSpeed(offset, 200 * deltaSeconds, ball, 20) do
-- target, deltaSeconds = coroutine.yield(offset.x, offset.y)
-- end
end
end
end
---@param ball XyPair
function Panner.new(ball)
return setmetatable({
coroutine = coroutine.create(function()
panCoroutine(ball)
end),
panTarget = nil,
}, { __index = Panner })
end
---@param deltaSeconds number
---@return number offsetX, number offsetY
function Panner:get(deltaSeconds)
if self.holdUntil and self.holdUntil() then
self:reset()
end
local _, offsetX, offsetY = coroutine.resume(self.coroutine, self.panTarget, deltaSeconds)
return offsetX, offsetY
end
---@param panTarget XyPair
---@param holdUntil fun(): boolean
function Panner:panTo(panTarget, holdUntil)
self.panTarget = panTarget
self.holdUntil = holdUntil
end
function Panner:reset()
self.holdUntil = nil
self.panTarget = nil
end

View File

@ -1,69 +0,0 @@
local gfx = playdate.graphics
---@alias SpriteCollection { smiling: pd_image, lowHat: pd_image, frowning: pd_image, back: pd_image }
---@param image pd_image
---@param drawInverted boolean
function maybeDrawInverted(image, x, y, drawInverted)
-- TODO: Bring logo up a pixel on the dark player base?
local drawMode = gfx.getImageDrawMode()
if drawInverted then
gfx.setImageDrawMode(gfx.kDrawModeInverted)
end
image:draw(x, y)
gfx.setImageDrawMode(drawMode)
end
--- TODO: Custom names on jerseys?
---@return SpriteCollection
---@param base pd_image
---@param isDark boolean
function buildCollection(base, back, logo, isDark)
local smiling = gfx.image.new(base:getSize())
gfx.lockFocus(smiling)
base:draw(0, 0)
Hat:draw(6, 0)
PlayerSmile:draw(5, 9)
maybeDrawInverted(logo, 3, 25, isDark)
local lowHat = gfx.image.new(base:getSize())
gfx.lockFocus(lowHat)
base:draw(0, 0)
Hat:draw(6, 2)
PlayerSmile:draw(5, 9)
maybeDrawInverted(logo, 3, 25, isDark)
local frowning = gfx.image.new(base:getSize())
gfx.lockFocus(frowning)
base:draw(0, 0)
maybeDrawInverted(logo, 3, 25, isDark)
Hat:draw(6, 0)
PlayerFrown:draw(5, 9)
gfx.unlockFocus()
return {
smiling = smiling,
lowHat = lowHat,
frowning = frowning,
back = back,
}
end
---@type SpriteCollection
AwayTeamSprites = nil
---@type SpriteCollection
HomeTeamSprites = nil
function replaceAwayLogo(logo)
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, logo, true)
end
function replaceHomeLogo(logo)
HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, logo, false)
end
replaceAwayLogo(Logos[1].image)
replaceHomeLogo(Logos[2].image)

53
src/draw/throw-meter.lua Normal file
View File

@ -0,0 +1,53 @@
---@type pd_graphics_lib
local gfx <const> = playdate.graphics
local ThrowMeterHeight <const> = 50
local ThrowMeterLingerSec <const> = 1.5
local flickerTimer = gfx.animation.blinker.new(50, 50, true)
flickerTimer:start()
---@param x number
---@param y number
function throwMeter:draw(x, y)
gfx.setLineWidth(1)
gfx.drawRect(x, y, 14, ThrowMeterHeight)
if self.lastReadThrow then
local ratio = 1
if not self.wasPerfect then
ratio = (self.lastReadThrow - throwMeter.MinCharge) / (self.idealPower - throwMeter.MinCharge)
end
local height = ThrowMeterHeight * ratio
gfx.fillRect(x + 2, y + ThrowMeterHeight - height, 10, height)
-- TODO: Dither or bend if the user throws too hard
-- Or maybe dither if it's too soft - bend if it's too hard
if self.wasPerfect then
PerfectPowerBg:draw(x - 11, y - 9)
if flickerTimer.on then
PerfectPowerFlickerLeft:draw(x - 11, y - 9)
else
PerfectPowerFlickerRight:draw(x - 11, y - 9)
end
end
end
end
function throwMeter:drawNearFielder(fielder)
if not fielder and not self.lastThrower then
return
end
if fielder then
if fielder ~= self.lastThrower then
self.lastReadThrow = nil
end
self.lastThrower = fielder
actionQueue:upsert("throwMeterLinger", 200 + ThrowMeterLingerSec * 1000, function()
local dt = 0
while dt < ThrowMeterLingerSec do
dt = dt + coroutine.yield()
end
self.lastThrower = nil
end)
end
self:draw(self.lastThrower.x - 25, self.lastThrower.y - 10)
end

99
src/draw/transitions.lua Normal file
View File

@ -0,0 +1,99 @@
Transitions = {
---@type Scene | nil
nextScene = nil,
---@type Scene | nil
previousScene = nil,
}
local gfx = playdate.graphics
local previousSceneImage
local previousSceneMask
local nextSceneImage
local batImageTable = {}
local batOffset = 80
local degStep = 3
function loadBatImageTable()
for deg = 90 - (degStep * 3), 270 + (degStep * 3), degStep do
local img = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(img)
BigBat:drawRotated(C.Center.x, C.Screen.H + batOffset, 90 + deg)
gfx.popContext()
batImageTable[deg] = img
end
end
loadBatImageTable()
local function update()
local lastAngle
local seamAngle = math.rad(270)
while seamAngle > math.rad(90) do
local deltaSeconds = playdate.getElapsedTime()
playdate.resetElapsedTime()
-- Setting a max value keeps from leaving unmasked areas
seamAngle = seamAngle - math.min(0.1, deltaSeconds * 3)
local seamAngleDeg = math.floor(math.deg(seamAngle))
seamAngleDeg = seamAngleDeg - (seamAngleDeg % degStep)
-- Skip re-drawing if no change
if lastAngle ~= seamAngleDeg then
lastAngle = seamAngleDeg
nextSceneImage:draw(0, 0)
gfx.pushContext(previousSceneMask)
gfx.setImageDrawMode(gfx.kDrawModeFillBlack)
batImageTable[seamAngleDeg]:draw(0, 0)
gfx.popContext()
previousSceneImage:setMaskImage(previousSceneMask)
previousSceneImage:draw(0, 0)
batImageTable[seamAngleDeg]:draw(0, 0)
end
coroutine.yield()
end
playdate.update = function()
Transitions.nextScene:update()
end
end
---@param nextScene fun() The next playdate.update function
function transitionTo(nextScene)
if not Transitions.nextScene then
error("Expected Transitions to already have nextScene defined! E.g. by calling transitionBetween")
end
local previousScene = Transitions.nextScene
transitionBetween(previousScene, nextScene)
end
---@param scene Scene
---@return pd_image
local function getSceneRender(scene)
local image = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(image)
scene:update()
gfx.popContext()
return image
end
---@param previousScene Scene Has the current playdate.update function
---@param nextScene Scene Has the next playdate.update function
function transitionBetween(previousScene, nextScene)
playdate.wait(2) -- TODO: There's some sort of timing wack here.
playdate.update = update
previousSceneImage = getSceneRender(previousScene)
nextSceneImage = getSceneRender(nextScene)
previousSceneMask = gfx.image.new(C.Screen.W, C.Screen.H, gfx.kColorWhite)
previousSceneImage:setMaskImage(previousSceneMask)
Transitions.nextScene = nextScene
Transitions.previousScene = previousScene
-- Prevents bad transition calculations due to a long "delta"
playdate.resetElapsedTime()
end

View File

@ -1,22 +1,25 @@
--- @class Glove --- @class Fielder {
--- @field z number
--- @class Fielder
--- @field glove Glove
--- @field catchEligible boolean
--- @field name string --- @field name string
--- @field x number --- @field x number
--- @field y number --- @field y number
--- @field target XyPair | nil --- @field targets XyPair[]
--- @field speed number --- @field speed number
--- @field armStrength number --- @field spriteIndex number
-- TODO: Run down baserunners in a pickle. ---@class Fielders
---@field first Fielder
---@field second Fielder
---@field shortstop Fielder
---@field third Fielder
---@field pitcher Fielder
---@field catcher Fielder
---@field left Fielder
---@field center Fielder
---@field right Fielder
---@class Fielding ---@class Fielding
---@field fielders table<string, Fielder> ---@field fielders Fielders
---@field fielderHoldingBall Fielder | nil ---@field fielderHoldingBall Fielder | nil
---@field private onFlyOut fun()
Fielding = {} Fielding = {}
FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
@ -29,15 +32,11 @@ local function newFielder(name, speed)
return { return {
name = name, name = name,
speed = speed * C.FielderRunMult, speed = speed * C.FielderRunMult,
catchEligible = true, spriteIndex = math.random(#HomeTeamSpriteGroup),
armStrength = 10,
glove = {
z = C.GloveZ
},
} }
end end
function Fielding.new(onFlyOut) function Fielding.new()
return setmetatable({ return setmetatable({
fielders = { fielders = {
first = newFielder("First", 40), first = newFielder("First", 40),
@ -50,7 +49,6 @@ function Fielding.new(onFlyOut)
center = newFielder("Center", 50), center = newFielder("Center", 50),
right = newFielder("Right", 50), right = newFielder("Right", 50),
}, },
onFlyOut = onFlyOut,
---@type Fielder | nil ---@type Fielder | nil
fielderHoldingBall = nil, fielderHoldingBall = nil,
}, { __index = Fielding }) }, { __index = Fielding })
@ -59,17 +57,18 @@ end
--- Actually only benches the infield, because outfielders are far away! --- Actually only benches the infield, because outfielders are far away!
---@param position XyPair ---@param position XyPair
function Fielding:benchTo(position) function Fielding:benchTo(position)
self.fielders.first.target = position self.fielders.first.targets = { position }
self.fielders.second.target = position self.fielders.second.targets = { position }
self.fielders.shortstop.target = position self.fielders.shortstop.targets = { position }
self.fielders.third.target = position self.fielders.third.targets = { position }
self.fielders.pitcher.target = position self.fielders.pitcher.targets = { position }
self.fielders.catcher.target = position self.fielders.catcher.targets = { position }
end end
--- Resets the target positions of all fielders to their defaults (at their field positions). --- Resets the target positions of all fielders to their defaults (at their field positions).
---@param fromOffTheField XyPair | nil If provided, also sets all runners' current position to one centralized location. ---@param fromOffTheField XyPair | nil If provided, also sets all runners' current position to one centralized location.
function Fielding:resetFielderPositions(fromOffTheField) ---@param immediate boolean | nil
function Fielding:resetFielderPositions(fromOffTheField, immediate)
if fromOffTheField then if fromOffTheField then
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
fielder.x = fromOffTheField.x fielder.x = fromOffTheField.x
@ -77,123 +76,103 @@ function Fielding:resetFielderPositions(fromOffTheField)
end end
end end
self.fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) self.fielders.first.targets = { utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) }
self.fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) self.fielders.second.targets = { utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) }
self.fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) self.fielders.shortstop.targets = { utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) }
self.fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) self.fielders.third.targets = { utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) }
self.fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) self.fielders.pitcher.targets = { utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) }
self.fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) self.fielders.catcher.targets = { utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) }
self.fielders.left.target = utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1) self.fielders.left.targets = { utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1) }
self.fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4) self.fielders.center.targets = { utils.xy(C.Center.x, C.Screen.H * -0.4) }
self.fielders.right.target = utils.xy(C.Screen.W * 1.6, self.fielders.left.target.y) self.fielders.right.targets = { utils.xy(C.Screen.W * 1.6, self.fielders.left.targets[1].y) }
if immediate then
for _, fielder in pairs(self.fielders) do
fielder.x = fielder.targets[1].x
fielder.y = fielder.targets[1].y
end
end
end end
---@param deltaSeconds number ---@param deltaSeconds number
---@param fielder Fielder ---@param fielder Fielder
---@param ballPos Point3d ---@param ball Ball
---@return boolean inCatchingRange ---@return boolean canCatch
local function updateFielderPosition(deltaSeconds, fielder, ballPos) local function updateFielderPosition(deltaSeconds, fielder, ball)
if fielder.target ~= nil then if #fielder.targets > 0 then
-- if fielder.name == "Left" then local nextFielderPos = utils.xy(fielder.x, fielder.y)
-- printTable({ target = fielder.target }) local currentTarget = fielder.targets[#fielder.targets]
-- end local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget)
if fielder.target.z then
if not utils.moveAtSpeedZ(fielder, fielder.speed * deltaSeconds, fielder.target) then if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall, 40) then
if fielder.name == "Left" then local targetCount = #fielder.targets
print("CLEAR LEFT'S 3D TARGET") -- Back up a little
end fielder.targets[targetCount + 2] = utils.xy(fielder.x, fielder.y + 5)
fielder.target = nil -- Try to come at it from below
end fielder.targets[targetCount + 1] = utils.xy(currentTarget.x, fielder.y + 10)
else
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
if fielder.name == "Left" then
print("CLEAR LEFT'S 2D TARGET")
end
fielder.target = nil
end
end
end end
--local distance = utils.distanceBetweenZ(fielder.x, fielder.y, C.GloveZ, ballPos.x, ballPos.y, ballPos.z) if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.targets[#fielder.targets]) then
if ballPos.z > C.GloveZ * 2 then table.remove(fielder.targets, #fielder.targets)
return false
end end
local distance = utils.distanceBetween(fielder.x, fielder.y, ballPos.x, ballPos.y)
return distance < C.BallCatchHitbox
end end
-- TODO: Clean this up, like, a lot.
-- I'd love to avoid any "real" pathfinding implementation, but these huge target queues are liable to be an issue.
-- The worst case came when a ball was hit far, but not in a way that the game classed as a home run.
-- Maybe this queueing would be fine if that issue was resolved
if #fielder.targets >= 10 then
fielder.targets = { utils.xy(fielder.x, fielder.y + 100) }
end
assert(#fielder.targets < 10, "Fielder " .. fielder.name .. " is accruing too many target positions!")
return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
end
-- TODO: Prevent multiple fielders covering the same base.
-- At least in a how-about-everybody-stand-right-here way.
--- Selects the nearest fielder to move toward the given coordinates. --- Selects the nearest fielder to move toward the given coordinates.
--- Other fielders should attempt to cover their bases --- Other fielders should attempt to cover their bases
---@param ballDestX number ---@param ball Point3d
---@param ballDestY number ---@param ballDest XyPair
---@param ball Ball function Fielding:haveSomeoneChase(ball, ballDest)
function Fielding:haveSomeoneChase(ballDestX, ballDestY, ball) local chasingFielder = utils.getNearestOf(self.fielders, ballDest.x, ballDest.y)
local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) -- Start moving toward the ball directly after reaching ballDest
chasingFielder.target = ball chasingFielder.targets = { ball, ballDest }
-- local timer = playdate.timer.new(1000)
-- timer.updateCallback = function()
-- printTable(chasingFielder.target)
-- end
print("chasingFielder: " .. chasingFielder.name)
printTable(ball)
for _, base in ipairs(C.Bases) do for _, base in ipairs(C.Bases) do
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
-- For now, skipping the pitcher because they're considered closer to 2B than second or shortstop -- Skip the pitcher for 2B - they're considered closer than second or shortstop.
return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then
return false
end
return fielder ~= chasingFielder
end) end)
nearest.target = base nearest.targets = { base }
end end
end end
--- **Also updates `ball.heldby`**
---@param ball Ball ---@param ball Ball
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball ---@return Fielder | nil, boolean fielderHoldingBall nil if no fielder is currently touching the ball, true if caught a fly ball
function Fielding:updateFielderPositions(ball, deltaSeconds) function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall local fielderHoldingBall
local caughtAFlyBall = false
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball)
-- if inCatchingRange then
-- printTable({
-- inCatchingRange = inCatchingRange,
-- catchEligible = fielder.catchEligible,
-- fielderName = fielder.name,
-- })
-- end
if inCatchingRange and fielder.catchEligible then
-- TODO: Base this catch on fielder skill? -- TODO: Base this catch on fielder skill?
local canCatch = updateFielderPosition(deltaSeconds, fielder, ball)
if canCatch then
fielderHoldingBall = fielder fielderHoldingBall = fielder
if ball.flyBall then ball.heldBy = fielder -- How much havoc will this wreak?
self.onFlyOut() if ball.isFlyBall then
ball.flyBall = false ball.isFlyBall = false
end caughtAFlyBall = true
ball:caughtBy(fielder) end
end end
end end
-- TODO: The need is growing for a distinction between touching the ball and holding the ball.
-- Or, at least, fielders need to start *stopping* the ball when they make contact with it.
-- Right now, a line-drive *through* first will be counted as an out.
self.fielderHoldingBall = fielderHoldingBall self.fielderHoldingBall = fielderHoldingBall
return fielderHoldingBall return fielderHoldingBall, caughtAFlyBall
end
---@param fielder Fielder
function Fielding.markIneligible(fielder)
fielder.catchEligible = false
playdate.timer.new(500, function()
fielder.catchEligible = true
end)
end
function Fielding:markAllIneligible()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = false
end
playdate.timer.new(750, function()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = true
end
end)
end end
-- TODO? Start moving target fielders close sooner? -- TODO? Start moving target fielders close sooner?
@ -202,17 +181,20 @@ end
---@param ball { launch: LaunchBall } ---@param ball { launch: LaunchBall }
---@param throwFlyMs number ---@param throwFlyMs number
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs) local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
while field.fielderHoldingBall == nil do while true do
if field.fielderHoldingBall == nil then
coroutine.yield() coroutine.yield()
end else
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder) local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end) end)
closestFielder.target = targetBase closestFielder.targets = { targetBase }
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, false, throwFlyMs) ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return
end
end
end end
--- Buffer in a fielder throw action. --- Buffer in a fielder throw action.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,15 +1,22 @@
--- Assumes that background image is of size local gfx <const> = playdate.graphics
local ButtonFont <const> = FontFullCircle
--- Assumes that background image is of size:
---
--- XXX --- XXX
--- XOX --- XOX
---
--- Where each character is the size of the screen, and 'O' is the default view. --- Where each character is the size of the screen, and 'O' is the default view.
function getDrawOffset(ballX, ballY) function getDrawOffset(ballX, ballY)
local offsetX, offsetY local offsetX, offsetY
if ballY > C.Screen.H or ballX >= C.BallOffscreen then if ballY > C.Screen.H or ballX >= C.BallOffscreen then
return 0, 0 return 0, 0
end end
offsetY = math.max(0, -1.4 * ballY) -- Keep the ball approximately in the center, once it's past C.Center.y - 30
offsetY = math.max(0, (-1 * ballY) + C.Center.y - 30)
if ballX > 0 and ballX < C.Screen.W then if ballX >= 0 and ballX <= C.Screen.W then
offsetX = 0 offsetX = 0
elseif ballX < 0 then elseif ballX < 0 then
offsetX = math.max(-1 * C.Screen.W, ballX * -1) offsetX = math.max(-1 * C.Screen.W, ballX * -1)
@ -17,7 +24,22 @@ function getDrawOffset(ballX, ballY)
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W) offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
end end
return offsetX * 1.7, offsetY return offsetX * 1.3, offsetY
end
local buttonBlinker = gfx.animation.blinker.new(750, 500, true)
buttonBlinker:start()
--- Requires calling `playdate.graphics.animation.blinker.updateAll()` during `update()` to blink correctly.
function drawButton(buttonLabel, x, y)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(x + 4, y + 7, 12)
gfx.setColor(gfx.kColorBlack)
if buttonBlinker.on then
gfx.setLineWidth(1)
gfx.drawCircleAtPoint(x + 4, y + 7, 10)
end
ButtonFont:drawText(buttonLabel, x, y)
end end
---@class Blipper ---@class Blipper
@ -26,16 +48,15 @@ blipper = {}
--- Build an object that simply "blips" between the given images at the given interval. --- Build an object that simply "blips" between the given images at the given interval.
--- Expects `playdate.graphics.animation.blinker.updateAll()` to be called on every update. --- Expects `playdate.graphics.animation.blinker.updateAll()` to be called on every update.
function blipper.new(msInterval, smiling, lowHat) function blipper.new(msInterval, spriteCollection)
local blinker = playdate.graphics.animation.blinker.new(msInterval, msInterval, true) local blinker = gfx.animation.blinker.new(msInterval, msInterval, true)
blinker:start() blinker:start()
return { return {
blinker = blinker, blinker = blinker,
smiling = smiling, draw = function(self, disableBlipping, x, y, hasSpriteIndex)
lowHat = lowHat, local spriteBundle = spriteCollection[hasSpriteIndex.spriteIndex]
draw = function(self, disableBlipping, x, y) local currentImage = (disableBlipping or self.blinker.on) and spriteBundle.lowHat or spriteBundle.smiling
local currentImage = (disableBlipping or self.blinker.on) and self.lowHat or self.smiling local offsetY = currentImage == spriteBundle.lowHat and -1 or 0
local offsetY = currentImage == lowHat and -1 or 0
currentImage:draw(x, y + offsetY) currentImage:draw(x, y + offsetY)
end, end,
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 603 B

View File

@ -1,3 +1,7 @@
-- stylua: ignore start
import "control-screen.lua"
-- stylua: ignore end
---@class MainMenu ---@class MainMenu
MainMenu = { MainMenu = {
---@type { new: fun(settings: Settings): { update: fun(self) } } ---@type { new: fun(settings: Settings): { update: fun(self) } }
@ -5,30 +9,40 @@ MainMenu = {
} }
local gfx = playdate.graphics local gfx = playdate.graphics
local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft") local ScoreFont <const> = FontFullCircle
local TinyFont <const> = NanoSans
--- Take control of playdate.update --- Take control of playdate.update
--- Will replace playdate.update when the menu is done. --- Will replace playdate.update when the menu is done.
---@param next { new: fun(settings: Settings): { update: fun(self) } } ---@param next { new: fun(settings: Settings): { update: fun(self) } }
function MainMenu.start(next) function MainMenu.start(next)
MenuMusic:play(0)
MainMenu.next = next MainMenu.next = next
playdate.update = MainMenu.update playdate.update = function()
MainMenu:update()
end
end end
local inningCountSelection = 3 local inningCountSelection = 3
function MainMenu:showControls()
local next = ControlScreen.new(self)
transitionBetween(MainMenu, next)
end
local function startGame() local function startGame()
local next = MainMenu.next.new({ local next = MainMenu.next.new({
finalInning = inningCountSelection, finalInning = inningCountSelection,
homeTeamSprites = HomeTeamSprites, homeTeamSpriteGroup = HomeTeamSpriteGroup,
awayTeamSprites = AwayTeamSprites, awayTeamSpriteGroup = AwayTeamSpriteGroup,
}) })
playdate.resetElapsedTime() playdate.resetElapsedTime()
playdate.update = function() transitionBetween(MainMenu, next)
next:update() MenuMusic:setPaused(true)
end
end end
---@param baseEaser EasingFunc
---@return EasingFunc
local function pausingEaser(baseEaser) local function pausingEaser(baseEaser)
--- t: elapsedTime --- t: elapsedTime
--- d: duration --- d: duration
@ -53,7 +67,8 @@ local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill)
animatorY.repeatCount = -1 animatorY.repeatCount = -1
animatorY.reverses = true animatorY.reverses = true
local crankStartPos = nil ---@type number
local crankStartPos
---@generic T ---@generic T
---@param array T[] ---@param array T[]
@ -64,9 +79,21 @@ local function arrayElementFromCrank(array, crankPosition)
return array[i] return array[i]
end end
local currentLogo = nil ---@type pd_image
local currentLogo
--luacheck: ignore
function MainMenu:update()
if playdate.buttonJustPressed(playdate.kButtonA) then
startGame()
return
end
if playdate.buttonJustPressed(playdate.kButtonB) then
self:showControls()
return
end
function MainMenu.update()
playdate.timer.updateTimers() playdate.timer.updateTimers()
crankStartPos = crankStartPos or playdate.getCrankPosition() crankStartPos = crankStartPos or playdate.getCrankPosition()
@ -82,21 +109,25 @@ function MainMenu.update()
currentLogo:drawScaled(20, C.Center.y + 40, 3) currentLogo:drawScaled(20, C.Center.y + 40, 3)
end end
if playdate.buttonJustPressed(playdate.kButtonA) then if playdate.buttonJustPressed(playdate.kButtonUp) or playdate.buttonJustPressed(playdate.kButtonRight) then
startGame() inningCountSelection = math.min(99, inningCountSelection + 1)
end end
startGame() if playdate.buttonJustPressed(playdate.kButtonDown) or playdate.buttonJustPressed(playdate.kButtonLeft) then
if playdate.buttonJustPressed(playdate.kButtonUp) then inningCountSelection = math.max(1, inningCountSelection - 1)
inningCountSelection = inningCountSelection + 1
end
if playdate.buttonJustPressed(playdate.kButtonDown) then
inningCountSelection = inningCountSelection - 1
end end
GameLogo:drawCentered(C.Center.x, 50) local logoCenter = 90
GameLogo:drawCentered(C.Center.x, logoCenter)
TinyFont:drawTextAligned("a game by Sage", C.Center.x, logoCenter + 35, kTextAlignment.center)
StartFont:drawTextAligned("Press A to start!", C.Center.x, 140, kTextAlignment.center) local promptOffsetX = 120
gfx.drawTextAligned("with " .. inningCountSelection .. " innings", C.Center.x, 190, kTextAlignment.center) ScoreFont:drawTextAligned(
"Press A to start with <" .. inningCountSelection .. "> innings",
C.Center.x - promptOffsetX,
180,
kTextAlignment.left
)
ScoreFont:drawTextAligned("Press B for controls", C.Center.x - promptOffsetX, 198, kTextAlignment.left)
local ball = { local ball = {
x = animatorX:currentValue(), x = animatorX:currentValue(),
@ -105,10 +136,12 @@ function MainMenu.update()
size = 6, size = 6,
} }
local ballIsHeld = drawFielder(AwayTeamSprites, ball, 30, 200) local fielder1 = { x = 30, y = 200, spriteIndex = 1 }
ballIsHeld = drawFielder(HomeTeamSprites, ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld local ballIsHeld = drawFielder(AwayTeamSpriteGroup, fielder1, ball)
local fielder2 = { x = 350, y = 200, spriteIndex = 2 }
ballIsHeld = drawFielder(HomeTeamSpriteGroup, fielder2, ball, playdate.graphics.kImageFlippedX) or ballIsHeld
-- drawFielder(AwayTeamSprites, { x = 0, y = 0, z = 0 }, ball.x, ball.y)
if not ballIsHeld then if not ballIsHeld then
gfx.setLineWidth(2) gfx.setLineWidth(2)

View File

@ -6,52 +6,75 @@ import 'CoreLibs/graphics.lua'
import 'CoreLibs/object.lua' import 'CoreLibs/object.lua'
import 'CoreLibs/timer.lua' import 'CoreLibs/timer.lua'
import 'CoreLibs/ui.lua' import 'CoreLibs/ui.lua'
import 'CoreLibs/utilities/where.lua'
-- stylua: ignore end -- stylua: ignore end
--- @alias Scene { update: fun(self: self) }
--- @alias EasingFunc fun(number, number, number, number): number --- @alias EasingFunc fun(number, number, number, number): number
---@class InputHandler
---@field update fun(self, deltaSeconds: number)
---@field updateBatAngle fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number)
---@field runningSpeed fun(self, runner: Runner, ball: Ball)
---@field pitch fun(self)
---@field fielderAction fun(self, fielderHoldingBall: Fielder | nil, outedSomeRunner: boolean, ball: Ball)
--- @alias LaunchBall fun( --- @alias LaunchBall fun(
--- self: self, --- self: self,
--- destX: number, --- destX: number,
--- destY: number, --- destY: number,
--- easingFunc: EasingFunc, --- easingFunc: EasingFunc,
--- freshHit: boolean | nil,
--- flyTimeMs: number | nil, --- flyTimeMs: number | nil,
--- floaty: boolean | nil,
--- customBallScaler: pd_animator | nil,
--- ) --- )
-- stylua: ignore start -- stylua: ignore start
import 'utils.lua' import 'utils.lua'
import 'constants.lua' import 'constants.lua'
import 'assets.lua' import 'assets.lua'
import 'draw/player.lua'
import 'draw/overlay.lua'
import 'draw/fielder.lua'
import 'main-menu.lua' import 'main-menu.lua'
import 'action-queue.lua' import 'action-queue.lua'
import 'announcer.lua' import 'announcer.lua'
import 'ball.lua' import 'ball.lua'
import 'baserunning.lua' import 'baserunning.lua'
import 'batting.lua'
import 'dbg.lua' import 'dbg.lua'
import 'fielding.lua' import 'fielding.lua'
import 'graphics.lua' import 'graphics.lua'
import 'npc.lua' import 'npc.lua'
import 'pitching.lua'
import 'statistics.lua'
import 'user-input.lua'
import 'draw/ball.lua'
import 'draw/box-score.lua'
import 'draw/fans.lua'
import 'draw/characters.lua'
import 'draw/overlay.lua'
import 'draw/panner.lua'
import 'draw/character-sprites.lua'
import 'draw/characters.lua'
import 'draw/throw-meter.lua'
import 'draw/transitions.lua'
-- stylua: ignore end -- stylua: ignore end
-- TODO: Customizable field structure. E.g. stands and ads etc. -- TODO: Customizable field structure. E.g. stands and ads etc.
local gfx <const>, C <const> = playdate.graphics, C ---@type pd_graphics_lib
local gfx <const> = playdate.graphics
---@alias Team { score: number, benchPosition: XyPair } local C <const> = C
---@alias Team { benchPosition: XyPair }
---@type table<TeamId, Team> ---@type table<TeamId, Team>
local teams <const> = { local teams <const> = {
home = { home = {
score = 0, -- TODO: Extract this last bit of global mutable state.
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y), benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
}, },
away = { away = {
score = 0,
benchPosition = utils.xy(-10, C.Center.y), benchPosition = utils.xy(-10, C.Center.y),
}, },
} }
@ -62,36 +85,33 @@ local teams <const> = {
---@class Settings ---@class Settings
---@field finalInning number ---@field finalInning number
---@field userTeam TeamId | nil ---@field userTeam TeamId | nil
---@field awayTeamSprites SpriteCollection ---@field awayTeamSpriteGroup SpriteCollection
---@field homeTeamSprites SpriteCollection ---@field homeTeamSpriteGroup SpriteCollection
---@class MutableState ---@class MutableState
---@field deltaSeconds number ---@field deltaSeconds number
---@field ball Ball ---@field ball Ball
---@field battingTeam TeamId ---@field battingTeam TeamId
---@field catcherThrownBall boolean ---@field pitchIsOver boolean
---@field didSwing boolean
---@field offenseState OffenseState ---@field offenseState OffenseState
---@field inning number ---@field inning number
---@field batBase XyPair ---@field stats Statistics
---@field batTip XyPair
---@field batAngleDeg number --- Ephemeral data ONLY used during rendering
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0 ---@class RenderState
---@field secondsSinceLastRunnerMove number ---@field bat BatRenderState
---@field secondsSincePitchAllowed number
-- These are only sort-of global state. They are purely graphical,
-- but they need to be kept in sync with the rest of the globals.
---@field runnerBlipper Blipper
---@field battingTeamSprites SpriteCollection
---@field fieldingTeamSprites SpriteCollection
---@class Game ---@class Game
---@field private settings Settings ---@field private settings Settings
---@field private announcer Announcer ---@field private announcer Announcer
---@field private fielding Fielding ---@field private fielding Fielding
---@field private baserunning Baserunning ---@field private baserunning Baserunning
---@field private npc Npc ---@field private batting Batting
---@field private homeTeamBlipper Blipper ---@field private characters Characters
---@field private awayTeamBlipper Blipper ---@field private npc InputHandler
---@field private userInput InputHandler
---@field private panner Panner
---@field private state MutableState ---@field private state MutableState
Game = {} Game = {}
@ -99,57 +119,47 @@ Game = {}
---@param announcer Announcer | nil ---@param announcer Announcer | nil
---@param fielding Fielding | nil ---@param fielding Fielding | nil
---@param baserunning Baserunning | nil ---@param baserunning Baserunning | nil
---@param npc Npc | nil ---@param npc InputHandler | nil
---@param state MutableState | nil ---@param state MutableState | nil
---@return Game ---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state) function Game.new(settings, announcer, fielding, baserunning, npc, state)
teams.away.score = 0
teams.home.score = 0
announcer = announcer or Announcer.new() announcer = announcer or Announcer.new()
settings.userTeam = "home" -- "away" fielding = fielding or Fielding.new()
settings.userTeam = "away"
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat)
local battingTeam = "away" local battingTeam = "away"
local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper
local ball = Ball.new(gfx.animator) local ball = Ball.new(gfx.animator)
local o = setmetatable({ local o = setmetatable({
settings = settings, settings = settings,
announcer = announcer, announcer = announcer,
homeTeamBlipper = homeTeamBlipper, fielding = fielding,
awayTeamBlipper = awayTeamBlipper, panner = Panner.new(ball),
state = state or { state = state or {
batBase = utils.xy(C.Center.x - 34, 215),
batTip = utils.xy(0, 0),
batAngleDeg = C.CrankOffsetDeg,
deltaSeconds = 0, deltaSeconds = 0,
ball = ball, ball = ball,
battingTeam = battingTeam, battingTeam = battingTeam,
offenseState = C.Offense.batting, offenseState = C.Offense.batting,
inning = 1, inning = 1,
catcherThrownBall = true, pitchIsOver = true,
secondsSinceLastRunnerMove = 0, didSwing = false,
secondsSincePitchAllowed = 0, stats = Statistics.new(),
battingTeamSprites = settings.awayTeamSprites,
fieldingTeamSprites = settings.homeTeamSprites,
runnerBlipper = runnerBlipper,
}, },
}, { __index = Game }) }, { __index = Game })
o.fielding = fielding or Fielding.new(function()
print("Fly out!")
end)
o.baserunning = baserunning or Baserunning.new(announcer, function() o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning() o:nextHalfInning()
end) end)
o.batting = Batting.new(o.baserunning)
o.userInput = UserInput.new(function(throwFly, forbidThrowHome)
return o:buttonControlledThrow(throwFly, forbidThrowHome)
end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders) o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition) o.fielding:resetFielderPositions(teams.home.benchPosition, settings.userTeam == nil)
playdate.timer.new(3500, function() playdate.timer.new(settings.userTeam == nil and 10 or 2000, function()
print("Start pitcher with ball") o:returnToPitcher()
ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, nil, 6)
end) end)
o.characters = Characters.new(settings.homeTeamSpriteGroup, settings.awayTeamSpriteGroup)
BootTune:play() BootTune:play()
BootTune:setFinishCallback(function() BootTune:setFinishCallback(function()
@ -159,46 +169,6 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
return o return o
end end
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
---@type Pitch[]
local Pitches <const> = {
-- Fastball
function()
return {
x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear),
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Curve ball
function()
return {
x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX + 20, C.PitchStartX, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Slider
function()
return {
x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX - 20, C.PitchStartX, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Wobbbleball
function(ball)
return {
x = {
currentValue = function()
return C.PitchStartX + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStartY) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
}
---@param teamId TeamId ---@param teamId TeamId
---@return TeamId ---@return TeamId
local function getOppositeTeamId(teamId) local function getOppositeTeamId(teamId)
@ -211,10 +181,6 @@ local function getOppositeTeamId(teamId)
end end
end end
function Game:getBattingTeam()
return teams[self.state.battingTeam] or error("Unknown battingTeam: " .. (self.state.battingTeam or "nil"))
end
function Game:getFieldingTeam() function Game:getFieldingTeam()
return teams[getOppositeTeamId(self.state.battingTeam)] return teams[getOppositeTeamId(self.state.battingTeam)]
end end
@ -235,73 +201,114 @@ function Game:userIsOn(side)
return ret, not ret return ret, not ret
end end
---@return InputHandler offense, InputHandler defense
function Game:currentInputHandlers()
local userOnOffense, userOnDefense = self:userIsOn("offense")
local offenseInput = userOnOffense and self.userInput or self.npc
local defenseInput = userOnDefense and self.userInput or self.npc
offenseInput:update(self.state.deltaSeconds)
defenseInput:update(self.state.deltaSeconds)
return offenseInput, defenseInput
end
---@param pitchFlyTimeMs number | nil ---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil ---@param pitchTypeIndex number | nil
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex) ---@param accuracy number The closer to 1.0, the better
Fielding.markIneligible(self.fielding.fielders.pitcher) function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
self.state.ball:launch(C.PitchStartX, C.PitchEndY, nil, false, nil, 2000 / pitchFlyTimeMs) if pitchTypeIndex == nil then
return -- No throw!
end
self.state.ball:markUncatchable()
self.state.ball.heldBy = nil self.state.ball.heldBy = nil
self.state.catcherThrownBall = false self.state.pitchIsOver = false
self.state.offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
-- local current = Pitches[pitchTypeIndex](self.state.ball) local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
-- self.state.ball.xAnimator = current.x self.state.ball.xAnimator = current.x
-- self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
-- -- TODO: This would need to be sanely replaced in ball:launch() etc. -- TODO: This would need to be sanely replaced in ball:launch() etc.
-- -- if current.z then -- if current.z then
-- -- ball.zAnimator = current.z -- ball.floatAnimator = current.z
-- -- ball.zAnimator:reset() -- ball.floatAnimator:reset()
-- -- end
-- if pitchFlyTimeMs then
-- self.state.ball.xAnimator:reset(pitchFlyTimeMs)
-- self.state.ball.yAnimator:reset(pitchFlyTimeMs)
-- else
-- self.state.ball.xAnimator:reset()
-- self.state.ball.yAnimator:reset()
-- end -- end
self.state.secondsSincePitchAllowed = 0 if pitchFlyTimeMs then
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else
self.state.ball.xAnimator:reset()
self.state.ball.yAnimator:reset()
end
pitchTracker.secondsSinceLastPitch = 0
end
function Game:pitcherIsOnTheMound()
return utils.distanceBetweenPoints(self.fielding.fielders.pitcher, C.PitcherStartPos) < C.BaseHitbox
end
function Game:pitcherIsReady()
local pitcher = self.fielding.fielders.pitcher
return self:pitcherIsOnTheMound()
and (
self.state.ball.heldBy == pitcher
or utils.distanceBetweenPoints(pitcher, self.state.ball) < C.BallCatchHitbox
or utils.distanceBetweenPoints(self.state.ball, C.PitchStart) < 2
)
end
function Game:checkForGameOver()
local state = self.state
if state.stats:gameIsOver(state.inning, self.settings.finalInning, state.battingTeam) then
self.announcer:say("THAT'S THE BALL GAME!")
playdate.timer.new(3000, function()
transitionTo(BoxScore.new(self.state.stats))
end)
return true
end
return false
end end
function Game:nextHalfInning() function Game:nextHalfInning()
pitchTracker:reset() pitchTracker:reset()
local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
if not gameOver then
Fielding.celebrate() Fielding.celebrate()
self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(self:getFieldingTeam().benchPosition) if self:checkForGameOver() then
self.announcer:say("SWITCHING SIDES...") return
end end
if gameOver then self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("AND THAT'S THE BALL GAME!") self.announcer:say("SWITCHING SIDES...")
else
self.fielding:resetFielderPositions() self.fielding:resetFielderPositions()
if self.state.battingTeam == teams.home then if self.state.battingTeam == "home" then
self.state.inning = self.state.inning + 1 self.state.inning = self.state.inning + 1
self.state.stats:pushInning()
end end
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
if self.state.battingTeam == teams.home then self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
self.state.battingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.homeTeamBlipper
self.state.fieldingTeamSprites = self.settings.awayTeamSprites
else
self.state.battingTeamSprites = self.settings.awayTeamSprites
self.state.fieldingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.awayTeamBlipper
end
end) end)
end end
---@return TeamInningData
function Game:battingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][self.state.battingTeam]
end
---@return TeamInningData
function Game:fieldingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][getOppositeTeamId(self.state.battingTeam)]
end end
---@param scoredRunCount number ---@param scoredRunCount number
function Game:score(scoredRunCount) function Game:score(scoredRunCount)
local batting = self:getBattingTeam() local battingTeamStats = self:battingTeamCurrentInning()
batting.score = batting.score + scoredRunCount battingTeamStats.score = battingTeamStats.score + scoredRunCount
self.announcer:say("SCORE!") self.announcer:say("SCORE!")
self:checkForGameOver()
end end
---@param throwFlyMs number ---@param throwFlyMs number
@ -309,26 +316,26 @@ end
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome) function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third] targetBase = C.Third
elseif playdate.buttonIsPressed(playdate.kButtonUp) then elseif playdate.buttonIsPressed(playdate.kButtonUp) then
targetBase = C.Bases[C.Second] targetBase = C.Second
elseif playdate.buttonIsPressed(playdate.kButtonRight) then elseif playdate.buttonIsPressed(playdate.kButtonRight) then
targetBase = C.Bases[C.First] targetBase = C.First
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
targetBase = C.Bases[C.Home] targetBase = C.Home
else else
return false return false
end end
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs) self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs)
self.state.secondsSinceLastRunnerMove = 0 self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running self.state.offenseState = C.Offense.running
return true return true
end end
function Game:nextBatter() function Game:nextBatter()
self.state.secondsSincePitchAllowed = -3 pitchTracker.secondsSinceLastPitch = -3
self.baserunning.batter = nil self.baserunning.batter = nil
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
pitchTracker:reset() pitchTracker:reset()
@ -352,200 +359,210 @@ end
function Game:strikeOut() function Game:strikeOut()
local outBatter = self.baserunning.batter local outBatter = self.baserunning.batter
self.baserunning.batter = nil self.baserunning.batter = nil
self.baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!") self.baserunning:outRunner(outBatter, "Strike out!")
self:nextBatter() self:nextBatter()
end end
---@param batDeg number function Game:saveToFile()
function Game:updateBatting(batDeg, batSpeed) playdate.datastore.write({ currentGame = self.state }, "data", true)
local batAngle = math.rad(batDeg) end
-- TODO: animate bat-flip or something
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0 function Game.load()
self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0 local loaded = playdate.datastore.read("data")
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle)) ---@type Game
self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle)) local loadedGame = loaded.currentGame
loadedGame.state.ball = Ball.new(gfx.animator)
local settings = {
homeTeamSpriteGroup = HomeTeamSpriteGroup,
awayTeamSpriteGroup = AwayTeamSpriteGroup,
finalInning = loadedGame.settings.finalInning,
}
local ret = Game.new(settings, nil, loadedGame.fielding, nil, nil, loadedGame.state)
ret.baserunning.outs = loadedGame.outs
return ret
end
---@param offenseHandler InputHandler
function Game:updateBatting(offenseHandler)
local ball = self.state.ball
local batDeg, batSpeed = offenseHandler:updateBatAngle(ball, self.state.pitchIsOver, self.state.deltaSeconds)
local ballDest, isSwinging, mult = self.batting:checkForHit(batDeg, batSpeed, ball)
self.state.didSwing = self.state.didSwing or (isSwinging and not self.state.pitchIsOver)
if not ballDest then
return
end
if
batSpeed > 0
and utils.pointDirectlyUnderLine(
self.state.ball.x,
self.state.ball.y,
self.state.batBase.x,
self.state.batBase.y,
self.state.batTip.x,
self.state.batTip.y,
C.Screen.H
)
and self.state.ball.y < 232
then
-- Hit! -- Hit!
-- TODO: animate bat-flip or something
local isFlyBall = math.random() > 0.5
self:saveToFile()
BatCrackReverb:play() BatCrackReverb:play()
self.state.offenseState = C.Offense.running self.state.offenseState = C.Offense.running
-- local ballAngle = batAngle + math.rad(90)
-- local mult = math.abs(batSpeed / 15)
-- local ballVelX = mult * 10 * math.sin(ballAngle)
-- local ballVelY = mult * 5 * math.cos(ballAngle)
-- if ballVelY > 0 then
-- ballVelX = ballVelX * -1
-- ballVelY = ballVelY * -1
-- end
local ballDestX = self.fielding.fielders.left.x -- self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = self.fielding.fielders.left.y -- self.state.ball.y + (ballVelY * C.BattingPower)
pitchTracker:reset() pitchTracker:reset()
print("Hit ball!") local flyTimeMs = 8000
self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, true, 3000, batSpeed / 3)
-- TODO? A dramatic eye-level view on a home-run could be sick. -- TODO? A dramatic eye-level view on a home-run could be sick.
local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest
if utils.isFoulBall(ballDestX, ballDestY) then if utils.isFoulBall(ballDest) then
self.announcer:say("Foul ball!") self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out -- TODO: Have a fielder chase for the fly-out
return return
end end
self.baserunning:convertBatterToRunner() local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall)
self.fielding:haveSomeoneChase(ballDestX, ballDestY, self.state.ball) if isPastOutfieldWall then
if not isFlyBall then
-- Grounder at the wall!
ballDest.y = nearbyPointAbove.y - 8
else
-- Home run!
playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted
if utils.distanceBetweenPoints(ball, ballDest) < 2 then
self.announcer:say("HOME RUN!")
self.state.offenseState = C.Offense.homeRun
-- Linger on the home-run ball for a moment, before panning to the bases.
playdate.timer.new(1000, function()
self.panner:panTo(utils.xy(0, 0), function()
return self:pitcherIsReady()
end)
end)
end end
end)
end
end
local ballHeightAnimator = isFlyBall
and gfx.animator.new(flyTimeMs, C.GloveZ, 10 + (2 * mult * mult * 0.5), utils.hitEasingHill)
or gfx.animator.new(flyTimeMs, 2 * (mult * mult), 0, utils.createBouncer(4))
ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, ballHeightAnimator, true)
self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ball, ballDest)
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved ---@param forcedOnly boolean
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly) ---@param isAutoRun boolean
local runnerMoved, runnersScored = ---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds) function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun)
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun, self.state.deltaSeconds)
if runnersScored ~= 0 then if runnersScored ~= 0 then
self:score(runnersScored) self:score(runnersScored)
end end
return runnerMoved return runnersStillMoving, secondsSinceLastRunnerMove
end end
---@param throwFly number function Game:returnToPitcher()
function Game:userPitch(throwFly) self.fielding:resetFielderPositions()
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
local bPressed = playdate.buttonIsPressed(playdate.kButtonB) if self:pitcherIsReady() then
if not aPressed and not bPressed then return -- Don't then!
self:pitch(throwFly, 1)
elseif aPressed and not bPressed then
self:pitch(throwFly, 2)
elseif not aPressed and bPressed then
self:pitch(throwFly, 3)
elseif aPressed and bPressed then
self:pitch(throwFly, 4)
end
end end
function Game:updateGameState() actionQueue:newOnly("returnToPitcher", 60 * 1000, function()
self.state.deltaSeconds = playdate.getElapsedTime() or 0 while not self:pitcherIsOnTheMound() do
playdate.resetElapsedTime() coroutine.yield()
local crankChange = playdate.getCrankChange() --[[@as number]] end
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower) if not self.baserunning.batter then
if crankChange < 0 then self.baserunning:pushNewBatter()
crankLimited = crankLimited * -1 end
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
end)
end end
self.state.ball:updatePosition(self.state.deltaSeconds) ---@param defenseHandler InputHandler
function Game:updatePitching(defenseHandler)
pitchTracker:recordIfPassed(self.state.ball)
local userOnOffense, userOnDefense = self:userIsOn("offense") if self:pitcherIsOnTheMound() then
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
if userOnDefense then
throwMeter:applyCharge(self.state.deltaSeconds, crankLimited)
end end
if self.state.offenseState == C.Offense.batting then if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
if self.state.ball.y < C.StrikeZoneStartY then self:saveToFile()
pitchTracker.recordedPitchX = nil local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = self.state.ball.x
end
local pitcher = self.fielding.fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
self.state.secondsSincePitchAllowed = self.state.secondsSincePitchAllowed + self.state.deltaSeconds
end
if self.state.secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not self.state.catcherThrownBall then
local outcome = pitchTracker:updatePitchCounts()
if outcome == PitchOutcomes.StrikeOut then if outcome == PitchOutcomes.StrikeOut then
self:strikeOut() self:strikeOut()
elseif outcome == PitchOutcomes.Walk then elseif outcome == PitchOutcomes.Walk then
self:walk() self:walk()
end end
-- Catcher has the ball. Throw it back to the pitcher self:returnToPitcher()
print("Catcher return ball to pitcher") self.state.pitchIsOver = true
self.state.ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, 20) self.state.didSwing = false
self.fielding:markAllIneligible()
self.state.catcherThrownBall = true
end end
local batSpeed if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
if userOnOffense then self:pitch(defenseHandler:pitch())
self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 end
batSpeed = crankLimited
else
self.state.batAngleDeg =
self.npc:updateBatAngle(self.state.ball, self.state.catcherThrownBall, self.state.deltaSeconds)
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
end end
self:updateBatting(self.state.batAngleDeg, batSpeed) function Game:updateGameState()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
self.state.ball:updatePosition()
local fielderHoldingBall, caughtAFlyBall =
self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if caughtAFlyBall then
local fliedOut = self.baserunning:getNewestRunner()
self.baserunning:outRunner(fliedOut, "Fly out!")
self.state.offenseState = C.Offense.fliedOut
self.baserunning:pushNewBatter()
pitchTracker.secondsSinceLastPitch = -1
end
local offenseHandler, defenseHandler = self:currentInputHandlers()
if self.state.offenseState == C.Offense.batting then
self:updatePitching(defenseHandler)
self:updateBatting(offenseHandler)
-- Walk batter to the plate -- Walk batter to the plate
-- TODO: Ensure batter can't be nil, here self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds)
self.baserunning:updateRunner(self.baserunning.batter, nil, crankLimited, self.state.deltaSeconds)
if self.state.secondsSincePitchAllowed > C.PitchAfterSeconds then
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly and not self:buttonControlledThrow(throwFly, true) then
self:userPitch(throwFly)
end
else
self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches))
end
end
elseif self.state.offenseState == C.Offense.running then elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited local appliedSpeed = function(runner)
or function(runner) return offenseHandler:runningSpeed(runner, self.state.ball)
return self.npc:runningSpeed(runner, self.state.ball)
end end
if self:updateNonBatterRunners(appliedSpeed) then
self.state.secondsSinceLastRunnerMove = 0 local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false)
else if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds
if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher -- End of play. Throw the ball back to the pitcher
print("Return ball to pitcher")
self.state.ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, 20)
-- This is ugly. Maybe Fielding should handle the return throw directly.
self.fielding:markAllIneligible()
self.fielding:resetFielderPositions()
self.state.offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter() self:returnToPitcher()
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
end
end
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
self.state.offenseState = C.Offense.batting
end
end end
local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) local outedSomeRunner = false
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly then
self:buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then if fielderHoldingBall then
local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall) outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense and self.state.offenseState == C.Offense.running then end
self.npc:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball) defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then
self.state.offenseState = C.Offense.batting
end
elseif self.state.offenseState == C.Offense.homeRun then
self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false, true)
if #self.baserunning.runners == 0 then
-- Give the player a moment to enjoy their home run.
playdate.timer.new(1500, function()
self:returnToPitcher()
actionQueue:upsert("waitForPitcherToHaveBall", 10000, function()
while not self:pitcherIsReady() do
coroutine.yield()
end
self.state.offenseState = C.Offense.batting
end)
end)
end end
end end
@ -553,90 +570,36 @@ function Game:updateGameState()
actionQueue:runWaiting(self.state.deltaSeconds) actionQueue:runWaiting(self.state.deltaSeconds)
end end
-- TODO: Swappable update() for main menu, etc.
function Game:update() function Game:update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
self:updateGameState() self:updateGameState()
local ball = self.state.ball
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
local ballY = ball.y - (ball.z * 0.2) local state = self.state
local offsetX, offsetY = getDrawOffset(ball.x, ballY) local offsetX, offsetY = self.panner:get(state.deltaSeconds)
gfx.setDrawOffset(offsetX, offsetY) gfx.setDrawOffset(offsetX, offsetY)
GrassBackground:draw(-400, -240) fans.draw()
GrassBackground:draw(-400, -720)
---@type { y: number, drawAction: fun() }[] local ballHeldBy =
local characterDraws = {} self.characters:drawAll(self.fielding, self.baserunning, self.batting.state, state.battingTeam, state.ball)
function addDraw(y, drawAction)
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction } if self:userIsOn("defense") then
throwMeter:drawNearFielder(ballHeldBy)
end end
local danceOffset = FielderDanceAnimator:currentValue() if not ballHeldBy then
local ballIsHeld = false state.ball:draw()
for _, fielder in pairs(self.fielding.fielders) do
addDraw(fielder.y + danceOffset, function()
ballIsHeld = drawFielder(
self.state.fieldingTeamSprites,
self.state.ball,
fielder.x,
fielder.y + danceOffset
) or ballIsHeld
end)
end
local playerHeightOffset = 20
-- TODO? Scale sprites down as y increases
for _, runner in pairs(self.baserunning.runners) do
addDraw(runner.y, function()
if runner == self.baserunning.batter then
if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then
self.state.battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
else
self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
end
else
-- TODO? Change blip speed depending on runner speed?
self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
end
end)
end
table.sort(characterDraws, function(a, b)
return a.y < b.y
end)
for _, character in pairs(characterDraws) do
character.drawAction()
end
if self.state.offenseState == C.Offense.batting then
gfx.setLineWidth(5)
gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y)
end
for _, runner in pairs(self.baserunning.outRunners) do
self.state.battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
end
if not ballIsHeld then
gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(ball.x, ballY, ball.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(ball.x, ballY, ball.size)
end end
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 20 then
drawMinimap(self.baserunning.runners, self.fielding.fielders) drawMinimap(self.baserunning.runners, self.fielding.fielders)
end end
drawScoreboard(0, C.Screen.H * 0.77, teams, self.baserunning.outs, self:getBattingTeam(), self.state.inning)
drawScoreboard(0, C.Screen.H * 0.77, state.stats, self.baserunning.outs, state.battingTeam, state.inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
self.announcer:draw(C.Center.x, 10) self.announcer:draw(C.Center.x, 10)
@ -646,25 +609,14 @@ function Game:update()
end end
end end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return Game
end
playdate.display.setRefreshRate(50) playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite) gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
MainMenu.start(Game) MainMenu.start(Game)
-- local b = coroutine.create(bouncer(10))
-- local x = 0
--
-- function playdate.update()
-- -- print(playdate.getFPS())
-- local deltaSeconds = playdate.getElapsedTime() or 0
-- playdate.resetElapsedTime()
-- local alive, z = coroutine.resume(b, deltaSeconds)
-- if alive then
-- z = z * 10
-- x = x + (deltaSeconds * 40)
-- gfx.setColor(gfx.kColorBlack)
-- gfx.drawCircleAtPoint(x, 240 - z, 5)
-- end
-- end

View File

@ -1,10 +1,11 @@
local npcBatDeg = 0 local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1500 local BaseNpcBatSpeed <const> = 1000
local npcBatSpeed = 1500 local npcBatSpeed = BaseNpcBatSpeed
---@class Npc ---@class Npc: InputHandler
---@field runners Runner[] ---@field runners Runner[]
---@field fielders Fielder[] ---@field fielders Fielder[]
-- selene: allow(unscoped_variables)
Npc = {} Npc = {}
---@param runners Runner[] ---@param runners Runner[]
@ -17,27 +18,42 @@ function Npc.new(runners, fielders)
}, { __index = Npc }) }, { __index = Npc })
end end
function Npc.update() end
-- TODO: FAR more nuanced NPC batting.
-- luacheck: no unused
---@param ball XyPair ---@param ball XyPair
---@param catcherThrownBall boolean ---@param pitchIsOver boolean
---@param deltaSec number ---@param deltaSec number
---@return number ---@return number batAngleDeg, number batSpeed
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec) -- luacheck: no unused args function Npc:updateBatAngle(ball, pitchIsOver, deltaSec)
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then if
not pitchIsOver
and ball.y > 200
and ball.y < 230
and (ball.x < C.Center.x + 15)
and (ball.x > C.Center.x - 12)
then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = 230 npcBatDeg = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360)
end end
return npcBatDeg return npcBatDeg, (self:batSpeed() * deltaSec)
end end
function Npc:batSpeed() -- luacheck: no unused args ---@return number
return npcBatSpeed / 1.5 function Npc:batSpeed()
return npcBatSpeed * 1.25
end
---@return number flyTimeMs, number pitchId, number accuracy
function Npc:pitch()
return C.PitchFlyMs / self:pitchSpeed(), math.random(#Pitches), 0.9
end end
local baseRunningSpeed = 25 local baseRunningSpeed = 25
--- TODO: Individual runner control.
---@param runner Runner ---@param runner Runner
---@param ball Point3d ---@param ball Point3d
---@return number ---@return number
@ -121,21 +137,16 @@ end
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall } ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball) local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners) local targetX, targetY = getNextOutTarget(runners)
printTable({ targetX = targetX, targetY = targetY }) if targetX == nil or targetY == nil then
if targetX ~= nil and targetY ~= nil then return
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate) end
return grabCandidate.catchEligible
end) local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
nearestFielder.target = utils.xy(targetX, targetY) nearestFielder.targets = { utils.xy(targetX, targetY) }
if nearestFielder == fielder then if nearestFielder == fielder then
ball.heldBy = fielder ball.heldBy = fielder
else else
playdate.timer.new(500, function() ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
print("Try to make a play")
ball:launch(targetX, targetY, playdate.easingFunctions.linear, false, nearestFielder.armStrength)
Fielding.markIneligible(nearestFielder)
end)
end
end end
end end
@ -143,18 +154,21 @@ end
---@param outedSomeRunner boolean ---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall } ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
function Npc:fielderAction(fielder, outedSomeRunner, ball) function Npc:fielderAction(fielder, outedSomeRunner, ball)
if outedSomeRunner then if not fielder then
-- Delay a little before the next play return
playdate.timer.new(750, function() end
local playDelay = outedSomeRunner and 0.5 or 0.1
actionQueue:newOnly("npcFielderAction", 2000, function()
local dt = 0
while dt < playDelay do
dt = dt + coroutine.yield()
end
tryToMakeAPlay(self.fielders, fielder, self.runners, ball) tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end) end)
else
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end
end end
---@return number ---@return number
function Npc:pitchSpeed() -- luacheck: no unused args function Npc:pitchSpeed()
return 2 return 2
end end

View File

@ -2,6 +2,6 @@ name=Batter Up!
author=Sage Vaillancourt author=Sage Vaillancourt
description=Crush dingers and hustle around the bases! description=Crush dingers and hustle around the bases!
bundleID=space.sagev.batterup bundleID=space.sagev.batterup
imagePath=images/launcher imagePath=assets/images/launcher
version=0.1 version=0.1
buildNumber=1 buildNumber=1

222
src/pitching.lua Normal file
View File

@ -0,0 +1,222 @@
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch fun(accuracy: number, ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
---@type pd_graphics_lib
local gfx <const> = playdate.graphics
local StrikeZoneWidth <const> = C.StrikeZoneEndX - C.StrikeZoneStartX
-- TODO? Also degrade speed
---@param accuracy number
---@return number xValueToMissBy
function getPitchMissBy(accuracy)
accuracy = accuracy or 1.0
local missBy = (1 - accuracy) * StrikeZoneWidth * 3
if math.random() > 0.5 then
missBy = missBy * -1
end
return missBy
end
---@type Pitch[]
Pitches = {
-- Fastball
function(accuracy)
return {
x = gfx.animator.new(
0,
C.PitchStart.x,
getPitchMissBy(accuracy) + C.PitchStart.x,
playdate.easingFunctions.linear
),
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Curve ball
function(accuracy)
return {
x = gfx.animator.new(
C.PitchFlyMs,
getPitchMissBy(accuracy) + C.PitchStart.x + 20,
C.PitchStart.x,
utils.easingHill
),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Slider
function(accuracy)
return {
x = gfx.animator.new(
C.PitchFlyMs,
getPitchMissBy(accuracy) + C.PitchStart.x - 20,
C.PitchStart.x,
utils.easingHill
),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Wobbleball
function(accuracy, ball)
local missBy = getPitchMissBy(accuracy)
return {
x = {
currentValue = function()
return missBy
+ C.PitchStart.x
+ (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
}
---@alias PitchOutcome "StrikeOut" | "Walk"
---@type table<string, PitchOutcome>
PitchOutcomes = {
StrikeOut = "StrikeOut",
Walk = "Walk",
}
pitchTracker = {
--- Position of the pitch, or nil, if one has not been recorded.
---@type number | nil
recordedPitchX = nil,
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
secondsSinceLastPitch = 0,
strikes = 0,
balls = 0,
}
function pitchTracker:reset()
self.strikes = 0
self.balls = 0
end
---@param ball XyPair
function pitchTracker:recordIfPassed(ball)
if ball.y < C.StrikeZoneStartY then
self.recordedPitchX = nil
elseif not self.recordedPitchX then
self.recordedPitchX = ball.x
end
end
---@param didSwing boolean
---@param fieldingTeamInningData TeamInningData
---@return PitchOutcome | nil
function pitchTracker:updatePitchCounts(didSwing, fieldingTeamInningData)
if not self.recordedPitchX then
return
end
local currentPitchingStats = fieldingTeamInningData.pitching
if didSwing or self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
self.strikes = self.strikes + 1
currentPitchingStats.strikes = currentPitchingStats.strikes + 1
if self.strikes >= 3 then
self:reset()
return PitchOutcomes.StrikeOut
end
else
self.balls = self.balls + 1
currentPitchingStats.balls = currentPitchingStats.balls + 1
if self.balls >= 4 then
self:reset()
return PitchOutcomes.Walk
end
end
end
-----------------
-- Throw Meter --
-----------------
throwMeter = {
MinCharge = 25,
idealPower = 50,
--- Used at draw-time only.
---@type number
lastReadThrow = nil,
--- Used at draw-time only.
---@type Fielder | nil
lastThrower = nil,
--- Used at draw-time only.
---@type boolean
wasPerfect = false,
}
local MaxPowerRatio <const> = 1.5
--- Returns nil when a throw is NOT requested.
---@param chargeAmount number
---@return number | nil powerRatio, number | nil accuracy, boolean isPerfect
function throwMeter:readThrow(chargeAmount)
local power = self:readCharge(chargeAmount)
if not power then
return nil, nil, false
end
local ratio = math.min(power / self.idealPower, MaxPowerRatio)
self.wasPerfect = math.abs(ratio - 1) < 0.05
local accuracy = 1
-- Only throw off accuracy on slow throws
if ratio >= 1 and not self.wasPerfect then
accuracy = 1 / ratio
end
return ratio * 1.5, accuracy, self.wasPerfect
end
local CrankRecordSec <const> = 0.33
---@alias CrankQueueEntry { time: number, chargeAmount: number }
---@type CrankQueueEntry[]
local crankQueue = {}
--- If (within approx. a third of a second) the crank has moved more than 45 degrees, call that a throw.
---@param chargeAmount number
---@return number | nil
function throwMeter:readCharge(chargeAmount)
if chargeAmount == 0 then
return nil
end
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
local minTimeHasPassed = false
while #crankQueue ~= 0 and (currentTimeMs - crankQueue[1].time) > CrankRecordSec do
table.remove(crankQueue, 1)
minTimeHasPassed = true
end
crankQueue[#crankQueue + 1] = { time = currentTimeMs, chargeAmount = math.abs(chargeAmount) }
if not minTimeHasPassed then
return nil
end
local currentCharge = 0
for _, v in ipairs(crankQueue) do
currentCharge = currentCharge + v.chargeAmount
end
if currentCharge > throwMeter.MinCharge then
self.lastReadThrow = currentCharge
crankQueue = {}
return currentCharge
else
return nil
end
end

61
src/statistics.lua Normal file
View File

@ -0,0 +1,61 @@
-- TODO? Some other stats
-- * Scroll left and right through games that go into extra innings
-- * Scroll up and down through other stats.
-- + Balls and strikes
-- + Batting average
-- + Farthest hit ball
---@return TeamInningData
local function newTeamInning()
return {
score = 0,
pitching = {
balls = 0,
strikes = 0,
},
hits = {},
}
end
---@return table<TeamId, TeamInningData>
local function newInning()
return {
home = newTeamInning(),
away = newTeamInning(),
}
end
---@alias TeamInningData { score: number, pitching: { balls: number, strikes: number }, hits: XyPair[] }
--- E.g. statistics[1].home.pitching.balls
---@class Statistics
---@field innings (table<TeamId, TeamInningData>)[]
Statistics = {}
---@return Statistics
function Statistics.new()
return setmetatable({
innings = { newInning() },
}, { __index = Statistics })
end
function Statistics:pushInning()
self.innings[#self.innings + 1] = newInning()
end
---@param inning number
---@param finalInning number
---@param battingTeam TeamId
---@return boolean gameOver
function Statistics:gameIsOver(inning, finalInning, battingTeam)
local homeScore, awayScore = utils.totalScores(self)
local isFinalInning = inning >= finalInning
local gameOver = isFinalInning and battingTeam == "home" and awayScore ~= homeScore
gameOver = gameOver or battingTeam == "away" and isFinalInning and homeScore > awayScore
return gameOver
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return Statistics
end

View File

@ -1,13 +1,68 @@
utils = require("utils") utils = require("utils")
local mockPlaydate = { local currentTimeMs = 0
local mockPlaydate = {}
mockPlaydate = {
TEST_MODE = true, TEST_MODE = true,
skipMs = function(skip)
currentTimeMs = currentTimeMs + skip
end,
getCurrentTimeMilliseconds = function()
currentTimeMs = currentTimeMs + 1
return currentTimeMs
end,
easingFunctions = {},
timer = {
lastTimer = {
mockCompletion = function()
error("No lastTimer set!")
end,
},
new = function(_, callback)
local timer = {
mockCompletion = function()
callback()
end,
}
mockPlaydate.timer.lastTimer = timer
return timer
end,
},
graphics = { graphics = {
animator = { animator = {
new = function() new = function()
return utils.staticAnimator(0) return utils.staticAnimator(0)
end, end,
}, },
animation = {
blinker = {
new = function()
return { start = function() end }
end,
},
},
font = {
new = function()
return {}
end,
},
image = {
new = function()
return {}
end,
},
},
sound = {
sampleplayer = {
new = function()
return {
play = function() end,
setFinishCallback = function() end,
}
end,
},
}, },
} }

View File

@ -9,3 +9,30 @@ playdate, announcer = mocks[1], mocks[2]
local _f = require("fielding") local _f = require("fielding")
Fielding, newFielder = _f[1], _f[2] Fielding, newFielder = _f[1], _f[2]
HomeTeamSpriteGroup = {}
-- Print contents of `tbl`, with indentation.
-- `indent` sets the initial level of indentation.
function str(tbl, indent, nl)
if not indent then
indent = 1
end
nl = nl or "\n"
if type(tbl) == "table" then
local indentStr = string.rep(" ", indent)
local ret = "{" .. nl
for k, v in pairs(tbl) do
--ret = ret .. indentStr .. "[" .. str(k, -9999, "") .. "]" .. ": " .. str(v, indent + 1, nl) .. "," .. nl
ret = ret .. indentStr .. "[" .. tostring(k) .. "]" .. ": " .. tostring(v) .. "," .. nl
end
return ret .. indentStr .. nl .. "}"
else
return tostring(tbl)
end
end
function printTable(tbl)
print(str(tbl))
end

View File

@ -0,0 +1,87 @@
require("test/setup")
require("action-queue")
function testActionQueueRunsToCompletion()
actionQueue.queue = {}
local invokeTotalSec = 0
local hasYielded = false
actionQueue:upsert("testAction", 9999999999, function(delta)
while invokeTotalSec < 5 do
invokeTotalSec = invokeTotalSec + delta
hasYielded = true
coroutine.yield()
end
end)
luaunit.assertIsFalse(hasYielded, "Should not have been invoked yet.")
for _ = 1, 10 do
actionQueue:runWaiting(1)
luaunit.assertIsTrue(hasYielded, "Should have been invoked.")
end
luaunit.assertEquals(5, invokeTotalSec, "Should have run five times and stopped itself")
end
function testActionQueueExpiration()
actionQueue.queue = {}
local yieldCount = 0
actionQueue:upsert("testAction", 2000, function()
while true do
yieldCount = yieldCount + 1
coroutine.yield()
end
end)
local skipSec = 60
playdate.skipMs(60 * 1000)
actionQueue:runWaiting(skipSec)
luaunit.assertEquals(1, yieldCount, "Should always be invoked at least once")
playdate.skipMs(1000)
actionQueue:runWaiting(1)
luaunit.assertEquals(1, yieldCount, "Should not be invoked again after expiry")
end
function testDuplicateUpsertsShouldOnlyRunOnce()
actionQueue.queue = {}
local yieldCount = 0
local yieldId
local action = function(id)
return function()
while true do
yieldCount = yieldCount + 1
yieldId = id
coroutine.yield()
end
end
end
for i = 1, 10 do
actionQueue:upsert("testAction", 9999999999, action(i))
end
actionQueue:runWaiting(1)
luaunit.assertEquals(1, yieldCount, "Duplicate upserts should result in only one invocation.")
luaunit.assertEquals(10, yieldId, "Most recent upsert should take precedence.")
end
function testNewOnlyActionsShouldNotReplaceExistingActions()
actionQueue.queue = {}
local yieldCount = 0
local yieldId
local action = function(id)
return function()
while true do
yieldCount = yieldCount + 1
yieldId = id
coroutine.yield()
end
end
end
for i = 1, 10 do
actionQueue:newOnly("testAction", 9999999999, action(i))
end
actionQueue:runWaiting(1)
luaunit.assertEquals(1, yieldCount, "Duplicate newOnly should result in only one invocation.")
luaunit.assertEquals(1, yieldId, "The first newOnly should take precedence.")
end
os.exit(luaunit.LuaUnit.run())

14
src/test/testBall.lua Normal file
View File

@ -0,0 +1,14 @@
require("test/setup")
local Ball = require("ball")
function testMarkUncatchable()
local ball = Ball.new(playdate.graphics.animator)
luaunit.assertIsTrue(ball.catchable, "Ball should start catchable")
ball:markUncatchable()
luaunit.assertIsFalse(ball.catchable, "Ball should not be catchable immediately after mark")
playdate.timer.lastTimer.mockCompletion()
luaunit.assertIsTrue(ball.catchable, "Ball should return to catchability after its timer expires")
end
os.exit(luaunit.LuaUnit.run())

View File

@ -11,7 +11,7 @@ function buildBaserunning()
return baserunning, thirdOutCallbackData return baserunning, thirdOutCallbackData
end end
---@alias BaseIndexOrXyPair (integer | XyPair) ---@alias BaseIndexOrXyPair (number | XyPair)
--- NOTE: in addition to the given runners, there is implicitly a batter running from first. --- NOTE: in addition to the given runners, there is implicitly a batter running from first.
---@param runnerLocations BaseIndexOrXyPair[] ---@param runnerLocations BaseIndexOrXyPair[]
@ -29,7 +29,7 @@ function buildRunnersOn(runnerLocations)
for b = 1, location do for b = 1, location do
runner.x = C.Bases[b].x runner.x = C.Bases[b].x
runner.y = C.Bases[b].y runner.y = C.Bases[b].y
baserunning:updateNonBatterRunners(0.001, false, 0.001) baserunning:updateNonBatterRunners(0.001, false, false, 0.001)
end end
else else
-- Is a raw XyPair -- Is a raw XyPair
@ -42,22 +42,21 @@ end
---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] } ---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] }
---@param expected boolean ---@param expectedOuts number
---@param fielderWithBallAt XyPair ---@param fielderWithBallAt XyPair
---@param when integer[][] ---@param when number[][]
function assertRunnerOutCondition(expected, when, fielderWithBallAt) function assertRunnerOutCondition(expectedOuts, when, fielderWithBallAt)
local msg = expected and "out" or "safe"
for _, runnersOn in ipairs(when) do for _, runnersOn in ipairs(when) do
local baserunning = buildRunnersOn(runnersOn) local baserunning = buildRunnersOn(runnersOn)
local outedSomeRunner = baserunning:outEligibleRunners(fielderWithBallAt) baserunning:outEligibleRunners(fielderWithBallAt)
luaunit.failIf(outedSomeRunner ~= expected, "Runner should have been " .. msg .. ", but was not!") luaunit.assertEquals(expectedOuts, baserunning.outs, "Incorrect number of outs.")
end end
end end
---@param condition Condition ---@param condition Condition
function assertRunnerStatuses(condition) function assertRunnerStatuses(condition)
assertRunnerOutCondition(true, condition.outWhen, condition.fielderWithBallAt) assertRunnerOutCondition(1, condition.outWhen, condition.fielderWithBallAt)
assertRunnerOutCondition(false, condition.safeWhen, condition.fielderWithBallAt) assertRunnerOutCondition(0, condition.safeWhen, condition.fielderWithBallAt)
end end
function testForceOutsAtFirst() function testForceOutsAtFirst()
@ -157,4 +156,18 @@ function testTagOutsShouldNotHappenOnBase()
}) })
end end
function testTagOutsWithMultipleRunnersOnOneBase()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {
{ 3, 3 },
},
safeWhen = {
{ 1, 1 },
{ 2, 2 },
{ 4, 4 },
},
})
end
os.exit(luaunit.LuaUnit.run()) os.exit(luaunit.LuaUnit.run())

View File

@ -1,52 +1,54 @@
require("test/setup") require("test/setup")
require("ball")
---@return Fielding, number fielderCount ---@return Fielding, Fielder someBaseman
local function fieldersAtDefaultPositions() local function fieldersAtDefaultPositions()
local fielding = Fielding.new() local fielding = Fielding.new()
fielding:resetFielderPositions() fielding:resetFielderPositions()
local fielderCount = 0 local fielderCount = 0
for _, fielder in pairs(fielding.fielders) do for _, fielder in pairs(fielding.fielders) do
fielder.x = fielder.target.x fielder.x = fielder.targets[#fielder.targets].x
fielder.y = fielder.target.y fielder.y = fielder.targets[#fielder.targets].y
fielderCount = fielderCount + 1 fielderCount = fielderCount + 1
end end
return fielding, fielderCount return fielding, fielding.fielders.second
end end
---@param x number ---@param x number
---@param y number ---@param y number
---@param z number | nil ---@param z number | nil
function fakeBall(x, y, z) local function ballAt(x, y, z)
return { local ball = Ball.new(playdate.graphics.animator)
x = x, ball.x = x
y = y, ball.y = y
z = z or 0, ball.z = z
heldBy = nil, return ball
caughtBy = function(self, fielder)
self.heldBy = fielder
end,
}
end end
function testBallPickedUpByNearbyFielders() function testBallPickedUpByNearbyFielders()
local fielding, fielderCount = fieldersAtDefaultPositions() local fielding, baseman = fieldersAtDefaultPositions()
luaunit.assertIs("table", type(fielding)) local ball = ballAt(baseman.x, baseman.y, baseman.z)
luaunit.assertIs("table", type(fielding.fielders))
luaunit.assertEquals(9, fielderCount)
local ball = fakeBall(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should not be held by a fielder yet")
local secondBaseman = fielding.fielders.second
ball.x = secondBaseman.x
ball.y = secondBaseman.y
ball.z = C.GloveZ
fielding:updateFielderPositions(ball, 0.01) fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder") luaunit.assertIs(baseman, ball.heldBy, "Ball should be held by the nearest fielder")
end
function testBallNotPickedUpByDistantFielders()
local fielding = fieldersAtDefaultPositions()
local ball = ballAt(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should be too far for any fielders to pick up")
end
function testBallNotPickedUpWhenNotCatchable()
local fielding, baseman = fieldersAtDefaultPositions()
local ball = ballAt(baseman.x, baseman.y, baseman.z)
ball:markUncatchable()
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should be held by the nearest fielder")
end end
os.exit(luaunit.LuaUnit.run()) os.exit(luaunit.LuaUnit.run())

37
src/test/testGraphics.lua Normal file
View File

@ -0,0 +1,37 @@
require("test/setup")
require("graphics")
local function assertSmallDifference(previous, current, ballValue, ballLabel)
local difference = math.abs(previous - current)
local baseError = "Expected a small difference, but received a difference of " .. difference
local fullDetails = luaunit.prettystr({ previous = previous, current = current, [ballLabel] = ballValue })
luaunit.assertIsTrue(difference < 2, baseError .. ":\n " .. luaunit.prettystr(fullDetails))
end
function testNoJumpsInYOffset()
local startY = -240 * 3
local atXValues = { -400, 0, 400 }
for _, xValue in ipairs(atXValues) do
local _, lastYOffset = getDrawOffset(xValue, startY)
for ballY = startY, 240 do
local _, currentYOffset = getDrawOffset(xValue, ballY)
assertSmallDifference(lastYOffset, currentYOffset, ballY, "ballY")
lastYOffset = currentYOffset
end
end
end
function testNoJumpsInXOffset()
local startX = -800
local atYValues = { 240, 0, -240 }
for _, yValue in ipairs(atYValues) do
local lastXOffset = getDrawOffset(startX, yValue)
for ballX = startX, 800 do
local currentXOffset = getDrawOffset(ballX, yValue)
assertSmallDifference(lastXOffset, currentXOffset, ballX, "ballX")
lastXOffset = currentXOffset
end
end
end
os.exit(luaunit.LuaUnit.run())

35
src/test/testMain.lua Normal file
View File

@ -0,0 +1,35 @@
require("test/setup")
require("draw/panner")
function string.starts(str, start)
return string.sub(str, 1, str.len(start)) == start
end
import = function(target)
if string.starts(target, "CoreLibs") or string.starts(target, "draw/") then
return
end
-- Remove .lua
require(target:sub(1, #target - 4))
end
Glove = {
getSize = function()
return 10, 10
end,
}
Characters = require("draw/characters")
local Game = require("main")
local settings = {
homeTeamSpriteGroup = {},
awayTeamSpriteGroup = {},
}
function testStandaloneInit()
-- Harness should be fleshed-out enough to init without error.
Game.new(settings, announcer)
end
os.exit(luaunit.LuaUnit.run())

View File

@ -0,0 +1,19 @@
require("test/setup")
local Statistics = require("statistics")
function testReportGameOver()
---@type Statistics
local stats = Statistics.new()
stats.innings[1].home.score = 0
stats.innings[1].away.score = 0
luaunit.assertIsFalse(stats:gameIsOver(9, 9, "home"), "Tie games should not report a game over")
stats.innings[1].home.score = 1
luaunit.assertIsTrue(stats:gameIsOver(9, 9, "home"), "Team in lead should report a game over")
stats.innings[1].home.score = 1
luaunit.assertIsFalse(stats:gameIsOver(1, 9, "home"), "Should not game over with innings left")
end
os.exit(luaunit.LuaUnit.run())

69
src/user-input.lua Normal file
View File

@ -0,0 +1,69 @@
---@class UserInput: InputHandler
---@field buttonControlledThrow: fun(throwFlyMs: number, forbidThrowHome: boolean): boolean didThrow
UserInput = {}
---@return UserInput
function UserInput.new(buttonControlledThrow)
return setmetatable({
buttonControlledThrow = buttonControlledThrow,
}, { __index = UserInput })
end
function UserInput:update()
self.crankChange = playdate.getCrankChange()
self.crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower)
if self.crankChange < 0 then
self.crankLimited = self.crankLimited * -1
end
end
---@return number batAngleDeg, number batSpeed
function UserInput:updateBatAngle()
local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
local batSpeed = math.abs(self.crankLimited)
return batAngleDeg, batSpeed
end
function UserInput:runningSpeed()
return self.crankLimited
end
---@param throwFlyMs number
---@param accuracy number | nil
---@return number | nil pitchFlyTimeMs, number | nil pitchTypeIndex, number | nil accuracy
local function userPitch(throwFlyMs, accuracy)
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
if not aPressed and not bPressed then
return throwFlyMs, 1, accuracy
elseif aPressed and not bPressed then
return throwFlyMs, 2, accuracy
elseif not aPressed and bPressed then
return throwFlyMs, 3, accuracy
elseif aPressed and bPressed then
return throwFlyMs, 4, accuracy
end
return nil, nil, nil
end
---@return number | nil pitchFlyTimeMs, number | nil pitchTypeIndex, number | nil accuracy
function UserInput:pitch()
local powerRatio, accuracy = throwMeter:readThrow(self.crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly and not self.buttonControlledThrow(throwFly, true) then
return userPitch(throwFly, accuracy)
end
end
end
function UserInput:fielderAction()
local powerRatio = throwMeter:readThrow(self.crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly then
self.buttonControlledThrow(throwFly, false)
end
end
end

Some files were not shown because too many files have changed in this diff Show More