Compare commits

..

1 Commits

Author SHA1 Message Date
Sage Vaillancourt 3e256d95c9 Committing my "realer" physics changes as they stand
Which is to say, *very, very, unplayable*
2025-02-16 18:34:45 -05:00
101 changed files with 1130 additions and 3353 deletions

View File

@ -1,4 +0,0 @@
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 -I %% lua %% -v) (cd src; find ./test -name '*lua' | xargs -L1 lua)
lint: lint:
stylua --indent-type Spaces src/ stylua --indent-type Spaces src/

View File

@ -2,7 +2,8 @@
-- 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
---@type pd_playdate_lib -- selene: allow(unused_variable)
-- selene: allow(unscoped_variables)
playdate = playdate playdate = playdate
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)

1
selene.toml Normal file
View File

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

View File

@ -1,6 +1,5 @@
---@class ActionQueue
---@field queue table<any, { coroutine: thread, expireTimeMs: number }>
actionQueue = { actionQueue = {
---@type table<any, { coroutine: thread, expireTimeMs: number }>
queue = {}, queue = {},
} }
@ -26,23 +25,8 @@ 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()
@ -58,8 +42,3 @@ 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> = Roobert20Medium local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
local AnnouncementTransitionMs <const> = 300 local AnnouncementTransitionMs <const> = 300
local AnnouncerMarginX <const> = 26 local AnnouncerMarginX <const> = 26
@ -44,7 +44,6 @@ 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
@ -52,8 +51,6 @@ 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,191 +2,60 @@
-- Instead, edit the source file directly: assets.lua2p. -- Instead, edit the source file directly: assets.lua2p.
-- luacheck: ignore -- luacheck: ignore
---@type pd_image DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png")
BallBackground = playdate.graphics.image.new("assets/images/game/BallBackground.png") -- luacheck: ignore
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
---@type pd_image BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
BigBat = playdate.graphics.image.new("assets/images/game/BigBat.png")
-- luacheck: ignore -- luacheck: ignore
---@type pd_image BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
DarkPlayerAwayBack = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBack.png")
-- luacheck: ignore -- luacheck: ignore
---@type pd_image BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
DarkPlayerAwayBase = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBase.png")
-- luacheck: ignore -- luacheck: ignore
---@type pd_image TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav")
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("assets/images/game/logos/Base.png") }, { name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") },
{ name = "Arrows", image = playdate.graphics.image.new("assets/images/game/logos/Arrows.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") },
{ name = "Cats", image = playdate.graphics.image.new("assets/images/game/logos/Cats.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") },
{ name = "Checkmarks", image = playdate.graphics.image.new("assets/images/game/logos/Checkmarks.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") },
{ name = "FingerGuns", image = playdate.graphics.image.new("assets/images/game/logos/FingerGuns.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") },
{ name = "Frown", image = playdate.graphics.image.new("assets/images/game/logos/Frown.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") },
{ name = "Hearts", image = playdate.graphics.image.new("assets/images/game/logos/Hearts.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") },
{ name = "Smiles", image = playdate.graphics.image.new("assets/images/game/logos/Smiles.png") },
-- luacheck: ignore -- luacheck: ignore
---@type pd_image { name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") },
{ name = "Turds", image = playdate.graphics.image.new("assets/images/game/logos/Turds.png") },
} }

View File

@ -1,11 +1,10 @@
!(function dirLookup(dir, extension, newFunc, type, sep, indent, handle) !(function dirLookup(dir, extension, newFunc, sep, handle)
indent = indent or "" sep = sep or "\n"
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 | sort -h') local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f')
local assetCode = "" local assetCode = ""
--Loop through all files --Loop through all files
@ -13,9 +12,8 @@
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 .. indent .. '-- luacheck: ignore\n' assetCode = assetCode .. '-- luacheck: ignore\n'
assetCode = assetCode .. indent .. '---@type ' .. type ..'\n' assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
assetCode = assetCode .. indent .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
end end
end end
return assetCode return assetCode
@ -25,16 +23,13 @@ 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('assets/images/game', 'png', 'playdate.graphics.image.new', 'pd_image')) !!(dirLookup('images/game', 'png', 'playdate.graphics.image.new'))
!!(dirLookup('assets/sounds', 'wav', 'playdate.sound.sampleplayer.new', 'pd_sampleplayer')) !!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new'))
!!(dirLookup('assets/music', 'wav', 'playdate.sound.sampleplayer.new', 'pd_sampleplayer')) !!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new'))
!!(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("assets/images/game/logos/Base.png") }, { name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
!!(dirLookup('assets/images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', 'pd_image', ",\n\n", " ", function(varName, value) !!(dirLookup('images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', ",\n", function(varName, value)
return '{ name = "' .. varName .. '", image = ' .. value .. ' }' return '{ name = "' .. varName .. '", image = ' .. value .. ' }'
end)) end))
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,295 +0,0 @@
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,104 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -1,242 +0,0 @@
--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.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

View File

@ -1,31 +1,30 @@
---@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 floatAnimator SimpleAnimator ---@field zAnimator 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 = C.Center.x --[[@as number]], x = 400 --[[@as number]],
y = C.Center.y --[[@as number]], y = 300 --[[@as number]],
z = 0, z = 0,
catchable = true, flyBall = false,
size = C.SmallestBallRadius, size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]], heldBy = nil --[[@type Runner | nil]],
@ -35,69 +34,184 @@ 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),
floatAnimator = defaultFloatAnimator(animatorLib), zAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
}, { __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, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ }) utils.moveAtSpeedZ(self, deltaSeconds * 10, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ })
self.size = C.SmallestBallRadius -- self.x = self.heldBy.x
-- self.y = self.heldBy.y
-- self.z = C.GloveZ
-- self.size = C.SmallestBallRadius
else else
self.x = self.xAnimator:currentValue() -- self.x = self.x + self.xVelocity
local z = self.floatAnimator:currentValue() -- self.x = self.xAnimator:currentValue()
-- TODO: This `+ z` is more graphics logic than physics logic -- self.y = self.yAnimator:currentValue()
self.y = self.yAnimator:currentValue() + z -- self.z = self.zAnimator:currentValue()
self.z = z if self.bounce then
if self.z < 2 and self.isFlyBall then local alive, z, justBounced = coroutine.resume(self.bounce, deltaSeconds)
print("Ball hit the ground!") if alive then
self.isFlyBall = false local lostVelMult = justBounced and 0.8 or 0.99
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
self.size = C.SmallestBallRadius + math.max(0, (self.floatAnimator:currentValue() - C.GloveZ) / 2) end
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
function Ball:markUncatchable() ---@alias DestinationAndFlightTime { destX: number, destY: number, flyTimeMs: number }
self.catchable = false
playdate.timer.new(200, function() ---@alias LaunchControls DestinationAndFlightTime
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 flyTimeMs number | nil ---@param freshHit boolean | nil
---@param floaty boolean | nil ---@param flyTimeMs number | nil The angle away from parallel to the ground.
---@param customFloater pd_animator | nil --- 0 is straight forward, 90 is straight up, 180 is straight behind.
---@param isHit boolean function Ball:launch(destX, destY, _, freshHit, _, power)
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customFloater, isHit) if freshHit then
self.flyBall = true
end
throwMeter:reset()
self.heldBy = nil self.heldBy = nil
self.isFlyBall = isHit
-- Prevent silly insta-catches local flightDistance, x, y = utils.distanceBetween(self.x, self.y, destX, destY)
self:markUncatchable() local timeToBounce = timeToFirstBounce(10)
if not flyTimeMs then -- if not flyTimeMs then
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower -- flyTimeMs = flightDistance * C.DefaultLaunchPower
end -- end
if customFloater then -- TODO? set a maxThrowDistance to limit throws by, instead
self.floatAnimator = customFloater power = power or 5
else self.xVelocity = -1.1 * x -- (x / -flightDistance) * power
self.sizeAnimator = self.animatorLib.new(flyTimeMs, C.SmallestBallRadius, 9, utils.easingHill) self.yVelocity = -1.1 * y -- (y / -flightDistance) * power
self.floatAnimator = defaultFloatAnimator(self.animatorLib) printTable({ x = x, y = y })
end printTable({
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) destX = destX,
self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc) destY = destY,
if floaty then x = x,
self.floatAnimator:reset(flyTimeMs) y = y,
end -- xVelocity = self.xVelocity,
end -- yVelocity = self.yVelocity,
-- timeToBounce = timeToBounce,
})
-- -- TODO? Scale based on distance?
-- self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
-- self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
-- self.zAnimator = self.animatorLib.new(flyTimeMs, flightDistance, C.GloveZ, utils.easingHill)
-- luacheck: ignore self.bounce = coroutine.create(bouncer(10))
if not playdate or playdate.TEST_MODE then
return Ball
end end

View File

@ -1,10 +1,10 @@
--- @class Runner --- @alias Runner {
--- @field x number --- x: number,
--- @field y number --- y: number,
--- @field nextBase Base --- nextBase: Base,
--- @field prevBase Base | nil --- prevBase: Base | nil,
--- @field forcedTo Base | nil --- forcedTo: Base | nil,
--- @field spriteIndex number --- }
---@class Baserunning ---@class Baserunning
---@field runners Runner[] ---@field runners Runner[]
@ -12,18 +12,15 @@
---@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, onThirdOutCallback) function Baserunning.new(announcer, onThirdOut)
local o = setmetatable({ local o = setmetatable({
runners = {}, runners = {},
outRunners = {}, outRunners = {},
@ -33,7 +30,7 @@ function Baserunning.new(announcer, onThirdOutCallback)
--- 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 = onThirdOutCallback, onThirdOut = onThirdOut,
}, { __index = Baserunning }) }, { __index = Baserunning })
o:pushNewBatter() o:pushNewBatter()
@ -41,7 +38,7 @@ function Baserunning.new(announcer, onThirdOutCallback)
return o return o
end end
---@param runner number | Runner ---@param runner integer | 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)
@ -53,8 +50,9 @@ function Baserunning:outRunner(runner, message)
end end
end end
end end
local runnerType = type(runner) if type(runner) ~= "number" then
assert(runnerType == "number", "Expected runner to have type 'number', but was: " .. runnerType) error("Expected runner to have type 'number', but was: " .. type(runner))
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)
@ -67,6 +65,7 @@ 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
@ -80,17 +79,9 @@ 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 = runnerBaseBiMap[runner] local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
-- 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
@ -135,32 +126,18 @@ function Baserunning:convertBatterToRunner()
end end
---@param deltaSeconds number ---@param deltaSeconds number
---@param runner Runner function Baserunning:walkAwayOutRunners(deltaSeconds)
---@return boolean isStillWalking for i, runner in ipairs(self.outRunners) do
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)
return true else
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 theBatterPushed ---@return Runner
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.
@ -169,31 +146,27 @@ 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
function Baserunning:getNewestRunner() ---@param self table
return self.runners[#self.runners] ---@param runnerIndex integer
end
---@param runnerIndex number
function Baserunning:runnerScored(runnerIndex) function Baserunning:runnerScored(runnerIndex)
self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex] -- TODO: outRunners/scoredRunners split
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 number | nil May only be nil if runner == batter ---@param runnerIndex integer | 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, isAutoRun, deltaSeconds) function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, 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
@ -205,7 +178,7 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun,
if if
nearestBaseDistance < 5 nearestBaseDistance < 5
and runnerIndex ~= nil and runnerIndex ~= nil
and runner ~= self.batter and runner ~= self.batter --runner.prevBase
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
@ -240,12 +213,9 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun,
-- 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 = 0 local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
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)
@ -257,12 +227,9 @@ 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
---@param forcedOnly boolean If true, only move forced runners (e.g. for a walk) ---@return boolean someRunnerMoved, number runnersScored
---@param isAutoRun boolean If true, does not attempt to hug the bases function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
---@param deltaSeconds number local someRunnerMoved = false
---@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"
@ -273,23 +240,19 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun,
if speedIsFunction then if speedIsFunction then
speed = appliedSpeed(runner) speed = appliedSpeed(runner)
end end
local thisRunnerMoved, thisRunnerScored = local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
self:updateRunner(runner, runnerIndex, speed, isAutoRun, deltaSeconds) someRunnerMoved = someRunnerMoved or thisRunnerMoved
runnersStillMoving = runnersStillMoving or thisRunnerMoved
if thisRunnerScored then if thisRunnerScored then
runnersScored = runnersScored + 1 runnersScored = runnersScored + 1
end end
end end
end end
if runnersStillMoving then if someRunnerMoved then
self.secondsSinceLastRunnerMove = 0
self:updateForcedRunners() self:updateForcedRunners()
else
self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds
end end
return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove return someRunnerMoved, runnersScored
end end
-- luacheck: ignore -- luacheck: ignore

View File

@ -1,68 +0,0 @@
---@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 = 6 C.PitchAfterSeconds = 8
C.ReturnToPitcherAfterSeconds = 2.4 C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050 C.PitchFlyMs = 1050
C.PitchStart = utils.xy(195, 105) C.PitchStartX = 195
C.PitchEndY = 240 C.PitchStartY, C.PitchEndY = 105, 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 = 3 C.BallCatchHitbox = 15
--- 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 = 0 -- 10 C.GloveZ = 10
C.SmallestBallRadius = 6 C.SmallestBallRadius = 6
C.BatLength = 35 C.BatLength = 35
---@alias OffenseState "batting" | "running" | "walking" | "homeRun" -- TODO: enums implemented this way are probably going to be difficult to serialize!
---@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,14 +109,15 @@ 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
@ -124,24 +125,6 @@ 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

View File

@ -1,100 +0,0 @@
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,8 +16,7 @@ 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()
@ -36,68 +35,6 @@ 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

View File

@ -1,11 +0,0 @@
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

View File

@ -1,203 +0,0 @@
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

@ -1,77 +0,0 @@
-- 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)

View File

@ -1,151 +0,0 @@
---@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

View File

@ -1,36 +0,0 @@
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

28
src/draw/fielder.lua Normal file
View File

@ -0,0 +1,28 @@
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,7 +1,6 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics local gfx = playdate.graphics
local ScoreFont <const> = FontFullCircle local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")
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
@ -83,10 +82,11 @@ 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(battingTeam) function getIndicators(teams, battingTeam)
if battingTeam == "home" then if teams.home == battingTeam 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) function drawScoreboardImpl(x, y, teams)
local homeScore = stats.homeScore local homeScore = stats.homeScore
local awayScore = stats.awayScore local awayScore = stats.awayScore
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(stats.battingTeam) local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, 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,18 +145,17 @@ end
local newStats = stats local newStats = stats
function drawScoreboard(x, y, statistics, outs, battingTeam, inning) function drawScoreboard(x, y, teams, outs, battingTeam, inning)
local homeScore, awayScore = utils.totalScores(statistics)
if if
newStats.homeScore ~= homeScore newStats.homeScore ~= teams.home.score
or newStats.awayScore ~= awayScore or newStats.awayScore ~= teams.away.score
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 = homeScore, homeScore = teams.home.score,
awayScore = awayScore, awayScore = teams.away.score,
outs = outs, outs = outs,
inning = inning, inning = inning,
battingTeam = battingTeam, battingTeam = battingTeam,
@ -165,5 +164,5 @@ function drawScoreboard(x, y, statistics, outs, battingTeam, inning)
stats = newStats stats = newStats
end) end)
end end
drawScoreboardImpl(x, y) drawScoreboardImpl(x, y, teams)
end end

View File

@ -1,52 +0,0 @@
---@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

69
src/draw/player.lua Normal file
View File

@ -0,0 +1,69 @@
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)

View File

@ -1,53 +0,0 @@
---@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

View File

@ -1,99 +0,0 @@
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,25 +1,22 @@
--- @class Fielder { --- @class Glove
--- @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 targets XyPair[] --- @field target XyPair | nil
--- @field speed number --- @field speed number
--- @field spriteIndex number --- @field armStrength number
---@class Fielders -- TODO: Run down baserunners in a pickle.
---@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 Fielders ---@field fielders table<string, Fielder>
---@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)
@ -32,11 +29,15 @@ local function newFielder(name, speed)
return { return {
name = name, name = name,
speed = speed * C.FielderRunMult, speed = speed * C.FielderRunMult,
spriteIndex = math.random(#HomeTeamSpriteGroup), catchEligible = true,
armStrength = 10,
glove = {
z = C.GloveZ
},
} }
end end
function Fielding.new() function Fielding.new(onFlyOut)
return setmetatable({ return setmetatable({
fielders = { fielders = {
first = newFielder("First", 40), first = newFielder("First", 40),
@ -49,6 +50,7 @@ function Fielding.new()
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 })
@ -57,18 +59,17 @@ 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.targets = { position } self.fielders.first.target = position
self.fielders.second.targets = { position } self.fielders.second.target = position
self.fielders.shortstop.targets = { position } self.fielders.shortstop.target = position
self.fielders.third.targets = { position } self.fielders.third.target = position
self.fielders.pitcher.targets = { position } self.fielders.pitcher.target = position
self.fielders.catcher.targets = { position } self.fielders.catcher.target = 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.
---@param immediate boolean | nil function Fielding:resetFielderPositions(fromOffTheField)
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
@ -76,103 +77,123 @@ function Fielding:resetFielderPositions(fromOffTheField, immediate)
end end
end end
self.fielders.first.targets = { utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) } self.fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48)
self.fielders.second.targets = { utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) } self.fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30)
self.fielders.shortstop.targets = { utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) } self.fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30)
self.fielders.third.targets = { utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) } self.fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48)
self.fielders.pitcher.targets = { utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) } self.fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y)
self.fielders.catcher.targets = { utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) } self.fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92)
self.fielders.left.targets = { utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1) } self.fielders.left.target = utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1)
self.fielders.center.targets = { utils.xy(C.Center.x, C.Screen.H * -0.4) } self.fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4)
self.fielders.right.targets = { utils.xy(C.Screen.W * 1.6, self.fielders.left.targets[1].y) } self.fielders.right.target = utils.xy(C.Screen.W * 1.6, self.fielders.left.target.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 ball Ball ---@param ballPos Point3d
---@return boolean canCatch ---@return boolean inCatchingRange
local function updateFielderPosition(deltaSeconds, fielder, ball) local function updateFielderPosition(deltaSeconds, fielder, ballPos)
if #fielder.targets > 0 then if fielder.target ~= nil then
local nextFielderPos = utils.xy(fielder.x, fielder.y) -- if fielder.name == "Left" then
local currentTarget = fielder.targets[#fielder.targets] -- printTable({ target = fielder.target })
local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget) -- end
if fielder.target.z then
if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall, 40) then if not utils.moveAtSpeedZ(fielder, fielder.speed * deltaSeconds, fielder.target) then
local targetCount = #fielder.targets if fielder.name == "Left" then
-- Back up a little print("CLEAR LEFT'S 3D TARGET")
fielder.targets[targetCount + 2] = utils.xy(fielder.x, fielder.y + 5) end
-- Try to come at it from below fielder.target = nil
fielder.targets[targetCount + 1] = utils.xy(currentTarget.x, fielder.y + 10) end
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
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.targets[#fielder.targets]) then --local distance = utils.distanceBetweenZ(fielder.x, fielder.y, C.GloveZ, ballPos.x, ballPos.y, ballPos.z)
table.remove(fielder.targets, #fielder.targets) if ballPos.z > C.GloveZ * 2 then
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 ball Point3d ---@param ballDestX number
---@param ballDest XyPair ---@param ballDestY number
function Fielding:haveSomeoneChase(ball, ballDest) ---@param ball Ball
local chasingFielder = utils.getNearestOf(self.fielders, ballDest.x, ballDest.y) function Fielding:haveSomeoneChase(ballDestX, ballDestY, ball)
-- Start moving toward the ball directly after reaching ballDest local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
chasingFielder.targets = { ball, ballDest } chasingFielder.target = ball
-- 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)
-- Skip the pitcher for 2B - they're considered closer than second or shortstop. -- For now, skipping the pitcher because they're considered closer to 2B than second or shortstop
if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher
return false
end
return fielder ~= chasingFielder
end) end)
nearest.targets = { base } nearest.target = base
end end
end end
--- **Also updates `ball.heldby`**
---@param ball Ball ---@param ball Ball
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil, boolean fielderHoldingBall nil if no fielder is currently touching the ball, true if caught a fly ball ---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the 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
ball.heldBy = fielder -- How much havoc will this wreak? if ball.flyBall then
if ball.isFlyBall then self.onFlyOut()
ball.isFlyBall = false ball.flyBall = false
caughtAFlyBall = true end
end ball:caughtBy(fielder)
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, caughtAFlyBall return fielderHoldingBall
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?
@ -181,20 +202,17 @@ 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 true do while field.fielderHoldingBall == nil do
if field.fielderHoldingBall == nil then
coroutine.yield() coroutine.yield()
else end
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.targets = { targetBase } closestFielder.target = targetBase
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, false, 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.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -1,22 +1,15 @@
local gfx <const> = playdate.graphics --- Assumes that background image is of size
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
-- Keep the ball approximately in the center, once it's past C.Center.y - 30 offsetY = math.max(0, -1.4 * ballY)
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)
@ -24,22 +17,7 @@ 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.3, offsetY return offsetX * 1.7, 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
@ -48,15 +26,16 @@ 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, spriteCollection) function blipper.new(msInterval, smiling, lowHat)
local blinker = gfx.animation.blinker.new(msInterval, msInterval, true) local blinker = playdate.graphics.animation.blinker.new(msInterval, msInterval, true)
blinker:start() blinker:start()
return { return {
blinker = blinker, blinker = blinker,
draw = function(self, disableBlipping, x, y, hasSpriteIndex) smiling = smiling,
local spriteBundle = spriteCollection[hasSpriteIndex.spriteIndex] lowHat = lowHat,
local currentImage = (disableBlipping or self.blinker.on) and spriteBundle.lowHat or spriteBundle.smiling draw = function(self, disableBlipping, x, y)
local offsetY = currentImage == spriteBundle.lowHat and -1 or 0 local currentImage = (disableBlipping or self.blinker.on) and self.lowHat or self.smiling
local offsetY = currentImage == lowHat and -1 or 0
currentImage:draw(x, y + offsetY) currentImage:draw(x, y + offsetY)
end, end,
} }

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 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: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 592 B

After

Width:  |  Height:  |  Size: 592 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: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.8 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

View File

Before

Width:  |  Height:  |  Size: 601 B

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 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

View File

@ -1,7 +1,3 @@
-- 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) } }
@ -9,40 +5,30 @@ MainMenu = {
} }
local gfx = playdate.graphics local gfx = playdate.graphics
local ScoreFont <const> = FontFullCircle local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft")
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 = function() playdate.update = MainMenu.update
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,
homeTeamSpriteGroup = HomeTeamSpriteGroup, homeTeamSprites = HomeTeamSprites,
awayTeamSpriteGroup = AwayTeamSpriteGroup, awayTeamSprites = AwayTeamSprites,
}) })
playdate.resetElapsedTime() playdate.resetElapsedTime()
transitionBetween(MainMenu, next) playdate.update = function()
MenuMusic:setPaused(true) next:update()
end
end end
---@param baseEaser EasingFunc
---@return EasingFunc
local function pausingEaser(baseEaser) local function pausingEaser(baseEaser)
--- t: elapsedTime --- t: elapsedTime
--- d: duration --- d: duration
@ -67,8 +53,7 @@ local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill)
animatorY.repeatCount = -1 animatorY.repeatCount = -1
animatorY.reverses = true animatorY.reverses = true
---@type number local crankStartPos = nil
local crankStartPos
---@generic T ---@generic T
---@param array T[] ---@param array T[]
@ -79,21 +64,9 @@ local function arrayElementFromCrank(array, crankPosition)
return array[i] return array[i]
end end
---@type pd_image local currentLogo = nil
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()
@ -109,25 +82,21 @@ 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.kButtonUp) or playdate.buttonJustPressed(playdate.kButtonRight) then if playdate.buttonJustPressed(playdate.kButtonA) then
inningCountSelection = math.min(99, inningCountSelection + 1) startGame()
end end
if playdate.buttonJustPressed(playdate.kButtonDown) or playdate.buttonJustPressed(playdate.kButtonLeft) then startGame()
inningCountSelection = math.max(1, inningCountSelection - 1) if playdate.buttonJustPressed(playdate.kButtonUp) then
inningCountSelection = inningCountSelection + 1
end
if playdate.buttonJustPressed(playdate.kButtonDown) then
inningCountSelection = inningCountSelection - 1
end end
local logoCenter = 90 GameLogo:drawCentered(C.Center.x, 50)
GameLogo:drawCentered(C.Center.x, logoCenter)
TinyFont:drawTextAligned("a game by Sage", C.Center.x, logoCenter + 35, kTextAlignment.center)
local promptOffsetX = 120 StartFont:drawTextAligned("Press A to start!", C.Center.x, 140, kTextAlignment.center)
ScoreFont:drawTextAligned( gfx.drawTextAligned("with " .. inningCountSelection .. " innings", C.Center.x, 190, kTextAlignment.center)
"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(),
@ -136,12 +105,10 @@ function MainMenu:update()
size = 6, size = 6,
} }
local fielder1 = { x = 30, y = 200, spriteIndex = 1 } local ballIsHeld = drawFielder(AwayTeamSprites, ball, 30, 200)
local ballIsHeld = drawFielder(AwayTeamSpriteGroup, fielder1, ball) ballIsHeld = drawFielder(HomeTeamSprites, ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld
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,75 +6,52 @@ 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.
---@type pd_graphics_lib local gfx <const>, C <const> = playdate.graphics, C
local gfx <const> = playdate.graphics
local C <const> = C ---@alias Team { score: number, benchPosition: XyPair }
---@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),
}, },
} }
@ -85,33 +62,36 @@ local teams <const> = {
---@class Settings ---@class Settings
---@field finalInning number ---@field finalInning number
---@field userTeam TeamId | nil ---@field userTeam TeamId | nil
---@field awayTeamSpriteGroup SpriteCollection ---@field awayTeamSprites SpriteCollection
---@field homeTeamSpriteGroup SpriteCollection ---@field homeTeamSprites SpriteCollection
---@class MutableState ---@class MutableState
---@field deltaSeconds number ---@field deltaSeconds number
---@field ball Ball ---@field ball Ball
---@field battingTeam TeamId ---@field battingTeam TeamId
---@field pitchIsOver boolean ---@field catcherThrownBall boolean
---@field didSwing boolean
---@field offenseState OffenseState ---@field offenseState OffenseState
---@field inning number ---@field inning number
---@field stats Statistics ---@field batBase XyPair
---@field batTip XyPair
--- Ephemeral data ONLY used during rendering ---@field batAngleDeg number
---@class RenderState -- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
---@field bat BatRenderState ---@field secondsSinceLastRunnerMove number
---@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 batting Batting ---@field private npc Npc
---@field private characters Characters ---@field private homeTeamBlipper Blipper
---@field private npc InputHandler ---@field private awayTeamBlipper Blipper
---@field private userInput InputHandler
---@field private panner Panner
---@field private state MutableState ---@field private state MutableState
Game = {} Game = {}
@ -119,47 +99,57 @@ 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 InputHandler | nil ---@param npc Npc | 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()
fielding = fielding or Fielding.new() settings.userTeam = "home" -- "away"
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,
fielding = fielding, homeTeamBlipper = homeTeamBlipper,
panner = Panner.new(ball), awayTeamBlipper = awayTeamBlipper,
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,
pitchIsOver = true, catcherThrownBall = true,
didSwing = false, secondsSinceLastRunnerMove = 0,
stats = Statistics.new(), secondsSincePitchAllowed = 0,
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, settings.userTeam == nil) o.fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(settings.userTeam == nil and 10 or 2000, function() playdate.timer.new(3500, function()
o:returnToPitcher() print("Start pitcher with ball")
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()
@ -169,6 +159,46 @@ 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)
@ -181,6 +211,10 @@ 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
@ -201,114 +235,73 @@ 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
---@param accuracy number The closer to 1.0, the better function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy) Fielding.markIneligible(self.fielding.fielders.pitcher)
if pitchTypeIndex == nil then self.state.ball:launch(C.PitchStartX, C.PitchEndY, nil, false, nil, 2000 / pitchFlyTimeMs)
return -- No throw!
end
self.state.ball:markUncatchable()
self.state.ball.heldBy = nil self.state.ball.heldBy = nil
self.state.pitchIsOver = false self.state.catcherThrownBall = false
self.state.offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex](accuracy, self.state.ball) -- local current = Pitches[pitchTypeIndex](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.floatAnimator = current.z -- -- ball.zAnimator = current.z
-- ball.floatAnimator:reset() -- -- ball.zAnimator: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
if pitchFlyTimeMs then self.state.secondsSincePitchAllowed = 0
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
if self:checkForGameOver() then
return
end
self.fielding:benchTo(self:getFieldingTeam().benchPosition) self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...") self.announcer:say("SWITCHING SIDES...")
self.fielding:resetFielderPositions()
if self.state.battingTeam == "home" then
self.state.inning = self.state.inning + 1
self.state.stats:pushInning()
end end
playdate.timer.new(2000, function()
if gameOver then
self.announcer:say("AND THAT'S THE BALL GAME!")
else
self.fielding:resetFielderPositions()
if self.state.battingTeam == teams.home then
self.state.inning = self.state.inning + 1
end
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam) self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function()
if self.state.battingTeam == teams.home then
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 battingTeamStats = self:battingTeamCurrentInning() local batting = self:getBattingTeam()
battingTeamStats.score = battingTeamStats.score + scoredRunCount batting.score = batting.score + scoredRunCount
self.announcer:say("SCORE!") self.announcer:say("SCORE!")
self:checkForGameOver()
end end
---@param throwFlyMs number ---@param throwFlyMs number
@ -316,26 +309,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.Third targetBase = C.Bases[C.Third]
elseif playdate.buttonIsPressed(playdate.kButtonUp) then elseif playdate.buttonIsPressed(playdate.kButtonUp) then
targetBase = C.Second targetBase = C.Bases[C.Second]
elseif playdate.buttonIsPressed(playdate.kButtonRight) then elseif playdate.buttonIsPressed(playdate.kButtonRight) then
targetBase = C.First targetBase = C.Bases[C.First]
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
targetBase = C.Home targetBase = C.Bases[C.Home]
else else
return false return false
end end
self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs) self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
self.baserunning.secondsSinceLastRunnerMove = 0 self.state.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()
pitchTracker.secondsSinceLastPitch = -3 self.state.secondsSincePitchAllowed = -3
self.baserunning.batter = nil self.baserunning.batter = nil
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
pitchTracker:reset() pitchTracker:reset()
@ -359,210 +352,200 @@ 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, "Strike out!") self.baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
self:nextBatter() self:nextBatter()
end end
function Game:saveToFile() ---@param batDeg number
playdate.datastore.write({ currentGame = self.state }, "data", true) function Game:updateBatting(batDeg, batSpeed)
end local batAngle = math.rad(batDeg)
function Game.load()
local loaded = playdate.datastore.read("data")
---@type Game
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
-- Hit!
-- TODO: animate bat-flip or something -- TODO: animate bat-flip or something
local isFlyBall = math.random() > 0.5 self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
self:saveToFile() self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
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!
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()
local flyTimeMs = 8000 print("Hit ball!")
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(ballDest) then if utils.isFoulBall(ballDestX, ballDestY) 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
local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall)
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
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.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ball, ballDest)
self.fielding:haveSomeoneChase(ballDestX, ballDestY, self.state.ball)
end
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---@param forcedOnly boolean ---@return boolean someRunnerMoved
---@param isAutoRun boolean function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove local runnerMoved, runnersScored =
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun) self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
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 runnersStillMoving, secondsSinceLastRunnerMove return runnerMoved
end end
function Game:returnToPitcher() ---@param throwFly number
self.fielding:resetFielderPositions() function Game:userPitch(throwFly)
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
if self:pitcherIsReady() then local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
return -- Don't then! if not aPressed and not bPressed 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
actionQueue:newOnly("returnToPitcher", 60 * 1000, function() function Game:updateGameState()
while not self:pitcherIsOnTheMound() do self.state.deltaSeconds = playdate.getElapsedTime() or 0
coroutine.yield() playdate.resetElapsedTime()
end local crankChange = playdate.getCrankChange() --[[@as number]]
if not self.baserunning.batter then local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
self.baserunning:pushNewBatter() if crankChange < 0 then
end crankLimited = crankLimited * -1
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
end)
end end
---@param defenseHandler InputHandler self.state.ball:updatePosition(self.state.deltaSeconds)
function Game:updatePitching(defenseHandler)
pitchTracker:recordIfPassed(self.state.ball)
if self:pitcherIsOnTheMound() then local userOnOffense, userOnDefense = self:userIsOn("offense")
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
if userOnDefense then
throwMeter:applyCharge(self.state.deltaSeconds, crankLimited)
end end
if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then if self.state.offenseState == C.Offense.batting then
self:saveToFile() if self.state.ball.y < C.StrikeZoneStartY then
local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning()) pitchTracker.recordedPitchX = nil
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
self:returnToPitcher() -- Catcher has the ball. Throw it back to the pitcher
self.state.pitchIsOver = true print("Catcher return ball to pitcher")
self.state.didSwing = false self.state.ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, 20)
self.fielding:markAllIneligible()
self.state.catcherThrownBall = true
end end
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then local batSpeed
self:pitch(defenseHandler:pitch()) if userOnOffense then
end self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
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
function Game:updateGameState() self:updateBatting(self.state.batAngleDeg, batSpeed)
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
self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds) -- TODO: Ensure batter can't be nil, here
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 = function(runner) local appliedSpeed = userOnOffense and crankLimited
return offenseHandler:runningSpeed(runner, self.state.ball) or function(runner)
return self.npc:runningSpeed(runner, self.state.ball)
end end
if self:updateNonBatterRunners(appliedSpeed) then
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false) self.state.secondsSinceLastRunnerMove = 0
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then else
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
self:returnToPitcher() -- TODO: Remove, or replace with nextBatter()
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 outedSomeRunner = false local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly then
self:buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then if fielderHoldingBall then
outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall) local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
end if not userOnDefense and self.state.offenseState == C.Offense.running then
defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball) self.npc: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
@ -570,36 +553,90 @@ 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 state = self.state local ballY = ball.y - (ball.z * 0.2)
local offsetX, offsetY = self.panner:get(state.deltaSeconds) local offsetX, offsetY = getDrawOffset(ball.x, ballY)
gfx.setDrawOffset(offsetX, offsetY) gfx.setDrawOffset(offsetX, offsetY)
fans.draw() GrassBackground:draw(-400, -240)
GrassBackground:draw(-400, -720)
local ballHeldBy = ---@type { y: number, drawAction: fun() }[]
self.characters:drawAll(self.fielding, self.baserunning, self.batting.state, state.battingTeam, state.ball) local characterDraws = {}
function addDraw(y, drawAction)
if self:userIsOn("defense") then characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
throwMeter:drawNearFielder(ballHeldBy)
end end
if not ballHeldBy then local danceOffset = FielderDanceAnimator:currentValue()
state.ball:draw() local ballIsHeld = false
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) > 20 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 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)
@ -609,14 +646,25 @@ 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,11 +1,10 @@
local npcBatDeg = 0 local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1000 local BaseNpcBatSpeed <const> = 1500
local npcBatSpeed = BaseNpcBatSpeed local npcBatSpeed = 1500
---@class Npc: InputHandler ---@class Npc
---@field runners Runner[] ---@field runners Runner[]
---@field fielders Fielder[] ---@field fielders Fielder[]
-- selene: allow(unscoped_variables)
Npc = {} Npc = {}
---@param runners Runner[] ---@param runners Runner[]
@ -18,42 +17,27 @@ 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 pitchIsOver boolean ---@param catcherThrownBall boolean
---@param deltaSec number ---@param deltaSec number
---@return number batAngleDeg, number batSpeed ---@return number
function Npc:updateBatAngle(ball, pitchIsOver, deltaSec) function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec) -- luacheck: no unused args
if if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
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 = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360) npcBatDeg = 230
end end
return npcBatDeg, (self:batSpeed() * deltaSec) return npcBatDeg
end end
---@return number function Npc:batSpeed() -- luacheck: no unused args
function Npc:batSpeed() return npcBatSpeed / 1.5
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
@ -137,16 +121,21 @@ 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)
if targetX == nil or targetY == nil then printTable({ targetX = targetX, targetY = targetY })
return if targetX ~= nil and targetY ~= nil then
end local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
return grabCandidate.catchEligible
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) end)
nearestFielder.targets = { utils.xy(targetX, targetY) } nearestFielder.target = utils.xy(targetX, targetY)
if nearestFielder == fielder then if nearestFielder == fielder then
ball.heldBy = fielder ball.heldBy = fielder
else else
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) playdate.timer.new(500, function()
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
@ -154,21 +143,18 @@ 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 not fielder then if outedSomeRunner then
return -- Delay a little before the next play
end playdate.timer.new(750, function()
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() function Npc:pitchSpeed() -- luacheck: no unused args
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=assets/images/launcher imagePath=images/launcher
version=0.1 version=0.1
buildNumber=1 buildNumber=1

View File

@ -1,222 +0,0 @@
---@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

View File

@ -1,61 +0,0 @@
-- 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,68 +1,13 @@
utils = require("utils") utils = require("utils")
local currentTimeMs = 0 local mockPlaydate = {
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,30 +9,3 @@ 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

@ -1,87 +0,0 @@
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())

View File

@ -1,14 +0,0 @@
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 (number | XyPair) ---@alias BaseIndexOrXyPair (integer | 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, false, 0.001) baserunning:updateNonBatterRunners(0.001, false, 0.001)
end end
else else
-- Is a raw XyPair -- Is a raw XyPair
@ -42,21 +42,22 @@ end
---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] } ---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] }
---@param expectedOuts number ---@param expected boolean
---@param fielderWithBallAt XyPair ---@param fielderWithBallAt XyPair
---@param when number[][] ---@param when integer[][]
function assertRunnerOutCondition(expectedOuts, when, fielderWithBallAt) function assertRunnerOutCondition(expected, 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)
baserunning:outEligibleRunners(fielderWithBallAt) local outedSomeRunner = baserunning:outEligibleRunners(fielderWithBallAt)
luaunit.assertEquals(expectedOuts, baserunning.outs, "Incorrect number of outs.") luaunit.failIf(outedSomeRunner ~= expected, "Runner should have been " .. msg .. ", but was not!")
end end
end end
---@param condition Condition ---@param condition Condition
function assertRunnerStatuses(condition) function assertRunnerStatuses(condition)
assertRunnerOutCondition(1, condition.outWhen, condition.fielderWithBallAt) assertRunnerOutCondition(true, condition.outWhen, condition.fielderWithBallAt)
assertRunnerOutCondition(0, condition.safeWhen, condition.fielderWithBallAt) assertRunnerOutCondition(false, condition.safeWhen, condition.fielderWithBallAt)
end end
function testForceOutsAtFirst() function testForceOutsAtFirst()
@ -156,18 +157,4 @@ 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,54 +1,52 @@
require("test/setup") require("test/setup")
require("ball")
---@return Fielding, Fielder someBaseman ---@return Fielding, number fielderCount
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.targets[#fielder.targets].x fielder.x = fielder.target.x
fielder.y = fielder.targets[#fielder.targets].y fielder.y = fielder.target.y
fielderCount = fielderCount + 1 fielderCount = fielderCount + 1
end end
return fielding, fielding.fielders.second return fielding, fielderCount
end end
---@param x number ---@param x number
---@param y number ---@param y number
---@param z number | nil ---@param z number | nil
local function ballAt(x, y, z) function fakeBall(x, y, z)
local ball = Ball.new(playdate.graphics.animator) return {
ball.x = x x = x,
ball.y = y y = y,
ball.z = z z = z or 0,
return ball heldBy = nil,
caughtBy = function(self, fielder)
self.heldBy = fielder
end,
}
end end
function testBallPickedUpByNearbyFielders() function testBallPickedUpByNearbyFielders()
local fielding, baseman = fieldersAtDefaultPositions() local fielding, fielderCount = fieldersAtDefaultPositions()
local ball = ballAt(baseman.x, baseman.y, baseman.z) luaunit.assertIs("table", type(fielding))
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(baseman, ball.heldBy, "Ball should be held by the nearest fielder") luaunit.assertIs(secondBaseman, 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())

View File

@ -1,37 +0,0 @@
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())

View File

@ -1,35 +0,0 @@
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

@ -1,19 +0,0 @@
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())

View File

@ -1,69 +0,0 @@
---@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