Compare commits
58 Commits
real-physi
...
main
Author | SHA1 | Date |
---|---|---|
|
4b9a94c2c2 | |
|
decd1f7080 | |
|
04d25127fc | |
|
8b66e2e826 | |
|
876f828117 | |
|
ce9a2d335e | |
|
80015dbe62 | |
|
176a7e6d5e | |
|
55a3a7b0ee | |
|
ddfdc8947a | |
|
e035c0ca72 | |
|
668fa9ffd4 | |
|
b4ac028cd9 | |
|
30aa5bd6c6 | |
|
09e48b65b4 | |
|
9bbd68c302 | |
|
7c7b5ff762 | |
|
b928ee3658 | |
|
3a465cb02d | |
|
b44756ff57 | |
|
48a9854653 | |
|
51c80fa427 | |
|
19ddae6273 | |
|
687bf74979 | |
|
aa72d2a19f | |
|
f42ef06ff6 | |
|
aceefeb25c | |
|
d82ab06534 | |
|
3715361718 | |
|
7525daccb6 | |
|
9dc8b10f15 | |
|
cea10a7706 | |
|
7deadbe316 | |
|
7b49603760 | |
|
786f80b0df | |
|
384a14fe5f | |
|
56c0c27d75 | |
|
35c7754207 | |
|
92985da58f | |
|
17a30e9822 | |
|
2d6f83a23f | |
|
e45231dadd | |
|
08a3189780 | |
|
d77675b0cb | |
|
56a5e197cd | |
|
699dab8c7d | |
|
b003c148a4 | |
|
52434fe891 | |
|
ad82035ccc | |
|
aebbc35bac | |
|
2d812f2046 | |
|
1bdcc62347 | |
|
c3a9122580 | |
|
e20ad0d3ad | |
|
4c9fbcdee7 | |
|
1ccf8765ee | |
|
5c45b7bba0 | |
|
6007ac971f |
|
@ -0,0 +1,4 @@
|
|||
std = "lua54+playdate"
|
||||
stds.project = {
|
||||
read_globals = {"playdate"}
|
||||
}
|
2
Makefile
|
@ -9,7 +9,7 @@ check: assets
|
|||
luacheck -d --codes src/ --exclude-files src/test/
|
||||
|
||||
test: check
|
||||
(cd src; find ./test -name '*lua' | xargs -L1 lua)
|
||||
(cd src; find ./test -name '*lua' | xargs -L1 -I %% lua %% -v)
|
||||
|
||||
lint:
|
||||
stylua --indent-type Spaces src/
|
||||
|
|
|
@ -2,8 +2,7 @@
|
|||
-- These warning-allieviators could also be injected directly into __types.lua
|
||||
-- Base __types.lua can be found at https://github.com/balpha/playdate-types
|
||||
|
||||
-- selene: allow(unused_variable)
|
||||
-- selene: allow(unscoped_variables)
|
||||
---@type pd_playdate_lib
|
||||
playdate = playdate
|
||||
|
||||
-- selene: allow(unscoped_variables)
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
std = "lua53"
|
|
@ -1,5 +1,6 @@
|
|||
---@class ActionQueue
|
||||
---@field queue table<any, { coroutine: thread, expireTimeMs: number }>
|
||||
actionQueue = {
|
||||
---@type table<any, { coroutine: thread, expireTimeMs: number }>
|
||||
queue = {},
|
||||
}
|
||||
|
||||
|
@ -25,8 +26,23 @@ function actionQueue:upsert(id, maxTimeMs, action)
|
|||
}
|
||||
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.
|
||||
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
|
||||
---@param deltaSeconds number
|
||||
function actionQueue:runWaiting(deltaSeconds)
|
||||
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
|
||||
|
||||
|
@ -42,3 +58,8 @@ function actionQueue:runWaiting(deltaSeconds)
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- luacheck: ignore
|
||||
if not playdate or playdate.TEST_MODE then
|
||||
return actionQueue
|
||||
end
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
local gfx = playdate.graphics
|
||||
|
||||
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
|
||||
local AnnouncementFont <const> = Roobert20Medium
|
||||
local AnnouncementTransitionMs <const> = 300
|
||||
local AnnouncerMarginX <const> = 26
|
||||
|
||||
|
@ -44,6 +44,7 @@ function Announcer:popIn()
|
|||
end)
|
||||
end
|
||||
|
||||
---@param text string
|
||||
function Announcer:say(text)
|
||||
self.textQueue[#self.textQueue + 1] = text
|
||||
if #self.textQueue == 1 then
|
||||
|
@ -51,6 +52,8 @@ function Announcer:say(text)
|
|||
end
|
||||
end
|
||||
|
||||
---@param x number
|
||||
---@param y number
|
||||
function Announcer:draw(x, y)
|
||||
if #self.textQueue == 0 then
|
||||
return
|
||||
|
|
223
src/assets.lua
|
@ -2,60 +2,191 @@
|
|||
-- Instead, edit the source file directly: assets.lua2p.
|
||||
|
||||
-- luacheck: ignore
|
||||
DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.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")
|
||||
---@type pd_image
|
||||
BallBackground = playdate.graphics.image.new("assets/images/game/BallBackground.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
|
||||
---@type pd_image
|
||||
BigBat = playdate.graphics.image.new("assets/images/game/BigBat.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
|
||||
---@type pd_image
|
||||
DarkPlayerAwayBack = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBack.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
|
||||
---@type pd_image
|
||||
DarkPlayerAwayBase = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBase.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav")
|
||||
---@type pd_image
|
||||
DarkPlayerFrown = playdate.graphics.image.new("assets/images/game/DarkPlayerFrown.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
DarkPlayerHomeBack = playdate.graphics.image.new("assets/images/game/DarkPlayerHomeBack.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
DarkPlayerHomeBase = playdate.graphics.image.new("assets/images/game/DarkPlayerHomeBase.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
DarkPlayerSmile = playdate.graphics.image.new("assets/images/game/DarkPlayerSmile.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
DarkSkinFan = playdate.graphics.image.new("assets/images/game/DarkSkinFan.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
GameLogo = playdate.graphics.image.new("assets/images/game/GameLogo.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
GloveHoldingBall = playdate.graphics.image.new("assets/images/game/GloveHoldingBall.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
Glove = playdate.graphics.image.new("assets/images/game/Glove.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
GrassBackground = playdate.graphics.image.new("assets/images/game/GrassBackground.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
GrassBackgroundSmall = playdate.graphics.image.new("assets/images/game/GrassBackgroundSmall.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
Hat = playdate.graphics.image.new("assets/images/game/Hat.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightPlayerAwayBack = playdate.graphics.image.new("assets/images/game/LightPlayerAwayBack.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightPlayerAwayBase = playdate.graphics.image.new("assets/images/game/LightPlayerAwayBase.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightPlayerFrown = playdate.graphics.image.new("assets/images/game/LightPlayerFrown.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightPlayerHomeBack = playdate.graphics.image.new("assets/images/game/LightPlayerHomeBack.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightPlayerHomeBase = playdate.graphics.image.new("assets/images/game/LightPlayerHomeBase.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightPlayerSmile = playdate.graphics.image.new("assets/images/game/LightPlayerSmile.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
LightSkinFan = playdate.graphics.image.new("assets/images/game/LightSkinFan.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
MenuImage = playdate.graphics.image.new("assets/images/game/MenuImage.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
Minimap = playdate.graphics.image.new("assets/images/game/Minimap.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
PerfectPowerBg = playdate.graphics.image.new("assets/images/game/PerfectPowerBg.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
PerfectPowerFlickerLeft = playdate.graphics.image.new("assets/images/game/PerfectPowerFlickerLeft.png")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
PerfectPowerFlickerRight = playdate.graphics.image.new("assets/images/game/PerfectPowerFlickerRight.png")
|
||||
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_sampleplayer
|
||||
BatCrackReverb = playdate.sound.sampleplayer.new("assets/sounds/BatCrackReverb.wav")
|
||||
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_sampleplayer
|
||||
BootTuneOrgany = playdate.sound.sampleplayer.new("assets/music/BootTuneOrgany.wav")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_sampleplayer
|
||||
BootTune = playdate.sound.sampleplayer.new("assets/music/BootTune.wav")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_sampleplayer
|
||||
MenuMusic = playdate.sound.sampleplayer.new("assets/music/MenuMusic.wav")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_sampleplayer
|
||||
TinnyBackground = playdate.sound.sampleplayer.new("assets/music/TinnyBackground.wav")
|
||||
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_font
|
||||
AshevilleSans14Bold = playdate.graphics.font.new("assets/fonts/Asheville-Sans-14-Bold.pft")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_font
|
||||
FontFullCircle = playdate.graphics.font.new("assets/fonts/Font-Full-Circle.pft")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_font
|
||||
NanoSans = playdate.graphics.font.new("assets/fonts/Nano Sans.pft")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_font
|
||||
Roobert11Medium = playdate.graphics.font.new("assets/fonts/Roobert-11-Medium.pft")
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_font
|
||||
Roobert20Medium = playdate.graphics.font.new("assets/fonts/Roobert-20-Medium.pft")
|
||||
|
||||
|
||||
Logos = {
|
||||
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
|
||||
{ name = "Base", image = playdate.graphics.image.new("assets/images/game/logos/Base.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Arrows", image = playdate.graphics.image.new("assets/images/game/logos/Arrows.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Cats", image = playdate.graphics.image.new("assets/images/game/logos/Cats.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Checkmarks", image = playdate.graphics.image.new("assets/images/game/logos/Checkmarks.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "FingerGuns", image = playdate.graphics.image.new("assets/images/game/logos/FingerGuns.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Frown", image = playdate.graphics.image.new("assets/images/game/logos/Frown.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Hearts", image = playdate.graphics.image.new("assets/images/game/logos/Hearts.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Smiles", image = playdate.graphics.image.new("assets/images/game/logos/Smiles.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
---@type pd_image
|
||||
{ name = "Turds", image = playdate.graphics.image.new("assets/images/game/logos/Turds.png") },
|
||||
|
||||
-- luacheck: ignore
|
||||
{ name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") },
|
||||
-- luacheck: ignore
|
||||
{ name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") },
|
||||
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
!(function dirLookup(dir, extension, newFunc, sep, handle)
|
||||
sep = sep or "\n"
|
||||
!(function dirLookup(dir, extension, newFunc, type, sep, indent, handle)
|
||||
indent = indent or ""
|
||||
sep = sep or "\n\n"
|
||||
handle = handle ~= nil and handle or function(varName, value)
|
||||
return varName .. ' = ' .. value
|
||||
end
|
||||
--Open directory look for files, save data in p. By giving '-type f' as parameter, it returns all files.
|
||||
local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f')
|
||||
|
||||
local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f | sort -h')
|
||||
|
||||
local assetCode = ""
|
||||
--Loop through all files
|
||||
|
@ -12,8 +13,9 @@
|
|||
if file:find(extension) then
|
||||
local varName = file:gsub(".*/(.*)." .. extension, "%1")
|
||||
file = file:gsub("src/", "")
|
||||
assetCode = assetCode .. '-- luacheck: ignore\n'
|
||||
assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
|
||||
assetCode = assetCode .. indent .. '-- luacheck: ignore\n'
|
||||
assetCode = assetCode .. indent .. '---@type ' .. type ..'\n'
|
||||
assetCode = assetCode .. indent .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
|
||||
end
|
||||
end
|
||||
return assetCode
|
||||
|
@ -23,13 +25,16 @@ function generatedFileWarning()
|
|||
return "-- GENERATED FILE - DO NOT EDIT\n-- Instead, edit the source file directly: assets.lua2p."
|
||||
end)!!(generatedFileWarning())
|
||||
|
||||
!!(dirLookup('images/game', 'png', 'playdate.graphics.image.new'))
|
||||
!!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new'))
|
||||
!!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new'))
|
||||
!!(dirLookup('assets/images/game', 'png', 'playdate.graphics.image.new', 'pd_image'))
|
||||
!!(dirLookup('assets/sounds', 'wav', 'playdate.sound.sampleplayer.new', 'pd_sampleplayer'))
|
||||
!!(dirLookup('assets/music', 'wav', 'playdate.sound.sampleplayer.new', 'pd_sampleplayer'))
|
||||
!!(dirLookup('assets/fonts', 'fnt', 'playdate.graphics.font.new', 'pd_font', nil, nil, function(varName, value)
|
||||
return varName:gsub("[- ]", "") .. " = " .. value:gsub("fnt", "pft")
|
||||
end))
|
||||
Logos = {
|
||||
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
|
||||
{ name = "Base", image = playdate.graphics.image.new("assets/images/game/logos/Base.png") },
|
||||
|
||||
!!(dirLookup('images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', ",\n", function(varName, value)
|
||||
!!(dirLookup('assets/images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', 'pd_image', ",\n\n", " ", function(varName, value)
|
||||
return '{ name = "' .. varName .. '", image = ' .. value .. ' }'
|
||||
end))
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 10 KiB |
|
@ -0,0 +1,295 @@
|
|||
tracking=1
|
||||
space 3
|
||||
! 2
|
||||
" 5
|
||||
# 9
|
||||
$ 8
|
||||
% 12
|
||||
& 11
|
||||
' 3
|
||||
( 5
|
||||
) 5
|
||||
* 8
|
||||
+ 8
|
||||
, 3
|
||||
- 6
|
||||
. 2
|
||||
/ 6
|
||||
0 9
|
||||
1 4
|
||||
2 9
|
||||
3 9
|
||||
4 9
|
||||
5 9
|
||||
6 9
|
||||
7 9
|
||||
8 10
|
||||
9 9
|
||||
: 2
|
||||
; 2
|
||||
< 7
|
||||
= 7
|
||||
> 7
|
||||
? 9
|
||||
@ 11
|
||||
A 10
|
||||
B 9
|
||||
C 9
|
||||
D 9
|
||||
E 8
|
||||
F 8
|
||||
G 9
|
||||
H 9
|
||||
I 2
|
||||
J 8
|
||||
K 10
|
||||
L 9
|
||||
M 12
|
||||
N 9
|
||||
O 9
|
||||
P 9
|
||||
Q 9
|
||||
R 9
|
||||
S 9
|
||||
T 10
|
||||
U 9
|
||||
V 10
|
||||
W 14
|
||||
X 8
|
||||
Y 8
|
||||
Z 8
|
||||
[ 3
|
||||
\ 6
|
||||
] 3
|
||||
^ 6
|
||||
_ 8
|
||||
` 3
|
||||
a 8
|
||||
b 8
|
||||
c 8
|
||||
d 8
|
||||
e 8
|
||||
f 6
|
||||
g 8
|
||||
h 8
|
||||
i 2
|
||||
j 4
|
||||
k 8
|
||||
l 2
|
||||
m 12
|
||||
n 8
|
||||
o 8
|
||||
p 8
|
||||
q 8
|
||||
r 6
|
||||
s 8
|
||||
t 6
|
||||
u 8
|
||||
v 8
|
||||
w 12
|
||||
x 9
|
||||
y 8
|
||||
z 8
|
||||
{ 6
|
||||
| 2
|
||||
} 6
|
||||
~ 10
|
||||
… 8
|
||||
¥ 8
|
||||
‼ 5
|
||||
™ 8
|
||||
© 11
|
||||
® 11
|
||||
。 16
|
||||
、 16
|
||||
ぁ 16
|
||||
あ 16
|
||||
ぃ 16
|
||||
い 16
|
||||
ぅ 16
|
||||
う 16
|
||||
ぇ 16
|
||||
え 16
|
||||
ぉ 16
|
||||
お 16
|
||||
か 16
|
||||
が 16
|
||||
き 16
|
||||
ぎ 16
|
||||
く 16
|
||||
ぐ 16
|
||||
け 16
|
||||
げ 16
|
||||
こ 16
|
||||
ご 16
|
||||
さ 16
|
||||
ざ 16
|
||||
し 16
|
||||
じ 16
|
||||
す 16
|
||||
ず 16
|
||||
せ 16
|
||||
ぜ 16
|
||||
そ 16
|
||||
ぞ 16
|
||||
た 16
|
||||
だ 16
|
||||
ち 16
|
||||
ぢ 16
|
||||
っ 16
|
||||
つ 16
|
||||
づ 16
|
||||
て 16
|
||||
で 16
|
||||
と 16
|
||||
ど 16
|
||||
な 16
|
||||
に 16
|
||||
ぬ 16
|
||||
ね 16
|
||||
の 16
|
||||
は 16
|
||||
ば 16
|
||||
ぱ 16
|
||||
ひ 16
|
||||
び 16
|
||||
ぴ 16
|
||||
ふ 16
|
||||
ぶ 16
|
||||
ぷ 16
|
||||
へ 16
|
||||
べ 16
|
||||
ぺ 16
|
||||
ほ 16
|
||||
ぼ 16
|
||||
ぽ 16
|
||||
ま 16
|
||||
み 16
|
||||
む 16
|
||||
め 16
|
||||
も 16
|
||||
ゃ 16
|
||||
や 16
|
||||
ゅ 16
|
||||
ゆ 16
|
||||
ょ 16
|
||||
よ 16
|
||||
ら 16
|
||||
り 16
|
||||
る 16
|
||||
れ 16
|
||||
ろ 16
|
||||
ゎ 16
|
||||
わ 16
|
||||
ゐ 16
|
||||
ゑ 16
|
||||
を 16
|
||||
ん 16
|
||||
ゔ 16
|
||||
ゕ 16
|
||||
ゖ 16
|
||||
゛ 1
|
||||
゜ 0
|
||||
ゝ 16
|
||||
ゞ 16
|
||||
ゟ 16
|
||||
゠ 16
|
||||
ァ 16
|
||||
ア 16
|
||||
ィ 16
|
||||
イ 16
|
||||
ゥ 16
|
||||
ウ 16
|
||||
ェ 16
|
||||
エ 16
|
||||
ォ 16
|
||||
オ 16
|
||||
カ 16
|
||||
ガ 16
|
||||
キ 16
|
||||
ギ 16
|
||||
ク 16
|
||||
グ 16
|
||||
ケ 16
|
||||
ゲ 16
|
||||
コ 16
|
||||
ゴ 16
|
||||
サ 16
|
||||
ザ 16
|
||||
シ 16
|
||||
ジ 16
|
||||
ス 16
|
||||
ズ 16
|
||||
セ 16
|
||||
ゼ 16
|
||||
ソ 16
|
||||
ゾ 16
|
||||
タ 16
|
||||
ダ 16
|
||||
チ 16
|
||||
ヂ 16
|
||||
ッ 16
|
||||
ツ 16
|
||||
ヅ 16
|
||||
テ 16
|
||||
デ 16
|
||||
ト 16
|
||||
ド 16
|
||||
ナ 16
|
||||
ニ 16
|
||||
ヌ 16
|
||||
ネ 16
|
||||
ノ 16
|
||||
ハ 16
|
||||
バ 16
|
||||
パ 16
|
||||
ヒ 16
|
||||
ビ 16
|
||||
ピ 16
|
||||
フ 16
|
||||
ブ 16
|
||||
プ 16
|
||||
ヘ 16
|
||||
ベ 16
|
||||
ペ 16
|
||||
ホ 16
|
||||
ボ 16
|
||||
ポ 16
|
||||
マ 16
|
||||
ミ 16
|
||||
ム 16
|
||||
メ 16
|
||||
モ 16
|
||||
ャ 16
|
||||
ヤ 16
|
||||
ュ 16
|
||||
ユ 16
|
||||
ョ 16
|
||||
ヨ 16
|
||||
ラ 16
|
||||
リ 16
|
||||
ル 16
|
||||
レ 16
|
||||
ロ 16
|
||||
ヮ 16
|
||||
ワ 16
|
||||
ヰ 16
|
||||
ヱ 16
|
||||
ヲ 16
|
||||
ン 16
|
||||
ヴ 16
|
||||
ヵ 16
|
||||
ヶ 16
|
||||
ヷ 16
|
||||
ヸ 16
|
||||
ヹ 16
|
||||
ヺ 16
|
||||
・ 16
|
||||
ー 16
|
||||
ヽ 16
|
||||
ヾ 16
|
||||
ヿ 16
|
||||
「 16
|
||||
」 16
|
||||
円 16
|
||||
<EFBFBD> 13
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1,104 @@
|
|||
tracking=1
|
||||
|
||||
space 2
|
||||
A 3
|
||||
B 3
|
||||
T 3
|
||||
a 3
|
||||
b 3
|
||||
c 3
|
||||
d 3
|
||||
e 3
|
||||
f 3
|
||||
g 3
|
||||
h 3
|
||||
i 1
|
||||
l 2
|
||||
q 3
|
||||
r 3
|
||||
s 3
|
||||
w 5
|
||||
z 3
|
||||
j 1
|
||||
n 3
|
||||
o 3
|
||||
p 3
|
||||
m 5
|
||||
k 3
|
||||
t 3
|
||||
u 3
|
||||
v 3
|
||||
y 3
|
||||
x 3
|
||||
. 1
|
||||
C 3
|
||||
D 3
|
||||
E 3
|
||||
F 3
|
||||
G 3
|
||||
H 3
|
||||
I 3
|
||||
0 3
|
||||
1 3
|
||||
8 3
|
||||
9 3
|
||||
7 3
|
||||
6 3
|
||||
5 3
|
||||
4 3
|
||||
3 3
|
||||
2 3
|
||||
: 1
|
||||
; 1
|
||||
! 1
|
||||
" 3
|
||||
{ 3
|
||||
} 3
|
||||
| 1
|
||||
J 3
|
||||
K 3
|
||||
L 3
|
||||
M 5
|
||||
N 4
|
||||
O 3
|
||||
W 5
|
||||
U 3
|
||||
V 3
|
||||
X 3
|
||||
Y 3
|
||||
Z 3
|
||||
Q 3
|
||||
S 3
|
||||
R 3
|
||||
P 3
|
||||
[ 2
|
||||
] 2
|
||||
^ 3
|
||||
< 3
|
||||
= 3
|
||||
> 3
|
||||
? 3
|
||||
@ 4
|
||||
\ 3
|
||||
_ 3
|
||||
` 2
|
||||
~ 5
|
||||
¥ 3
|
||||
… 5
|
||||
™ 5
|
||||
‼ 3
|
||||
© 5
|
||||
® 5
|
||||
<EFBFBD> 5
|
||||
# 5
|
||||
/ 3
|
||||
- 3
|
||||
+ 3
|
||||
, 1
|
||||
* 3
|
||||
) 2
|
||||
( 2
|
||||
' 1
|
||||
$ 3
|
||||
% 3
|
||||
& 4
|
After Width: | Height: | Size: 7.5 KiB |
|
@ -0,0 +1,242 @@
|
|||
--metrics={"baseline":0,"xHeight":0,"capHeight":0,"pairs":{"ac":[0,0],"ad":[0,0],"ae":[0,0],"af":[-1,0,0,0],"ag":[0,0],"ap":[0,0],"ar":[1,0,0,0],"at":[-1,0,0,0],"au":[0,0],"av":[-1,0,0,0],"aw":[-1,0,0,0],"ay":[-1,0,0,0],"b,":[-1,0,0,0],"b.":[-1,0,0,0],"bl":[0,0],"br":[0,0],"bu":[0,0],"by":[-1,0,0,0],"ca":[0,0],"ch":[0,0],"ck":[0,0],"d,":[-1,0,0,0],"d.":[0,0],"da":[0,0],"dc":[0,0],"de":[0,0],"dg":[0,0],"do":[0,0],"dt":[0,0],"du":[0,0],"dv":[0,0],"dw":[0,0],"dy":[0,0],"e,":[-1,0,0,0],"e.":[-1,0,0,0],"ea":[0,0],"ei":[0,0],"el":[0,0],"em":[0,0],"en":[0,0],"ep":[0,0],"er":[0,0],"et":[-1,0,0,0],"eu":[0,0],"ev":[-1,0,0,0],"ew":[-1,0,0,0],"ey":[-1,0,0,0],"f,":[-2,0,0,0],"f.":[-2,0,0,0],"fa":[-1,0,0,0],"fe":[-1,0,0,0],"ff":[-2,0,0,0],"fi":[0,0],"fl":[-1,0,0,0],"fo":[-2,0,0,0],"g,":[0,0],"g.":[0,0],"ga":[0,0],"ge":[0,0],"gg":[0,0],"gh":[0,0],"gl":[0,0],"go":[0,0],"hc":[0,0],"hd":[0,0],"he":[0,0],"hg":[0,0],"ho":[0,0],"hp":[0,0],"ht":[-1,0,0,0],"hu":[0,0],"hv":[-1,0,0,0],"hw":[-1,0,0,0],"hy":[-1,0,0,0],"ic":[-1,0,0,0],"id":[-1,0,0,0],"ie":[-1,0,0,0],"ig":[-1,0,0,0],"io":[-1,0,0,0],"ip":[-1,0,0,0],"it":[-2,0,0,0],"iu":[-1,0,0,0],"iv":[-1,0,0,0],"j,":[0,0],"j.":[0,0],"ja":[0,0],"je":[0,0],"jo":[0,0],"ju":[0,0],"ka":[-2,0,0,0],"kc":[-2,0,0,0],"kd":[-2,0,0,0],"ke":[-2,0,0,0],"kg":[-2,0,0,0],"ko":[-2,0,0,0],"la":[0,0],"lc":[0,0],"ld":[0,0],"le":[0,0],"lf":[0,0],"lg":[0,0],"lo":[0,0],"Lo":[-1,0,0,0],"lp":[0,0],"lq":[0,0],"lu":[0,0],"lv":[0,0],"lw":[0,0],"ly":[0,0],"ma":[0,0],"mc":[0,0],"md":[0,0],"me":[0,0],"mg":[0,0],"mn":[0,0],"mo":[0,0],"mp":[0,0],"mt":[-1,0,0,0],"mu":[0,0],"mv":[-1,0,0,0],"my":[-1,0,0,0],"nc":[0,0],"nd":[0,0],"ne":[0,0],"ng":[0,0],"no":[0,0],"np":[0,0],"nt":[-1,0,0,0],"nu":[0,0],"nv":[-1,0,0,0],"nw":[-1,0,0,0],"ny":[-1,0,0,0],"o,":[-2,0,0,0],"o.":[-1,0,0,0],"ob":[0,0],"of":[-2,0,0,0],"oh":[0,0],"oj":[-2,0,0,0],"ok":[0,0],"ol":[0,0],"om":[0,0],"on":[0,0],"op":[0,0],"or":[0,0],"ou":[0,0],"ov":[-1,0,0,0],"ow":[-1,0,0,0],"ox":[-1,0,0,0],"oy":[-1,0,0,0],"p,":[-1,0,0,0],"p.":[-1,0,0,0],"pa":[0,0],"ph":[0,0],"pi":[0,0],"pl":[0,0],"pp":[0,0],"pu":[0,0],"qu":[0,0],"r,":[-3,0,0,0],"r.":[-2,0,0,0],"ra":[-1,0,0,0],"rd":[-1,0,0,0],"re":[-1,0,0,0],"rg":[-1,0,0,0],"rk":[0,0],"rl":[0,0],"rm":[0,0],"rn":[0,0],"ro":[-2,0,0,0],"rq":[-1,0,0,0],"rr":[0,0],"rt":[-1,0,0,0],"rv":[0,0],"ry":[0,0],"s,":[-1,0,0,0],"s.":[-1,0,0,0],"sh":[0,0],"st":[-1,0,0,0],"su":[0,0],"t,":[0,0],"t.":[1,0,0,0],"ta":[1,0,0,0],"td":[0,0],"te":[0,0],"th":[0,0],"ti":[1,0,0,0],"tl":[1,0,0,0],"to":[0,0],"ua":[0,0],"uc":[0,0],"ud":[0,0],"ue":[0,0],"ug":[0,0],"uo":[0,0],"up":[1,0,0,0],"uq":[0,0],"ur":[1,0,0,0],"ut":[0,0],"uv":[0,0],"uw":[0,0],"uy":[0,0],"v,":[-2,0,0,0],"v.":[-2,0,0,0],"va":[0,0],"vb":[0,0],"vc":[-1,0,0,0],"vd":[-1,0,0,0],"ve":[-1,0,0,0],"vg":[-1,0,0,0],"vo":[-1,0,0,0],"vv":[0,0],"vy":[-1,0,0,0],"w,":[-2,0,0,0],"w.":[-1,0,0,0],"wa":[-1,0,0,0],"wd":[-1,0,0,0],"we":[-1,0,0,0],"wg":[-1,0,0,0],"wh":[0,0],"wo":[-1,0,0,0],"wx":[-1,0,0,0],"xa":[-1,0,0,0],"xe":[-1,0,0,0],"xo":[-1,0,0,0],"y,":[-3,0,0,0],"y.":[-2,0,0,0],"ya":[-1,0,0,0],"yc":[-1,0,0,0],"yd":[-1,0,0,0],"ye":[-1,0,0,0],"Yo":[-2,0,0,0],"yo":[-1,0,0,0],"LO":[-2,0,0,0],"AT":[-3,0,0,0],"AY":[-3,0,0,0],"//":[-4,0,0,0],"/d":[-2,0,0,0],"/p":[-1,0,0,0],"tp":[1,0,0,0],"t:":[1,0,0,0],"/w":[-1,0,0,0],"ot":[-1,0,0,0],"Wo":[-2,0,0,0],"Fo":[-2,0,0,0],"Fu":[-2,0,0,0],"Vu":[-1,0,0,0],"Tu":[-2,0,0,0],"To":[-3,0,0,0],"Vo":[-2,0,0,0],"Yu":[-1,0,0,0],"Zo":[-1,0,0,0],"ty":[-1,0,0,0],"is":[-1,0,0,0]},"left":[],"right":[]}
|
||||
tracking=1
|
||||
|
||||
0 12
|
||||
1 5
|
||||
2 11
|
||||
3 12
|
||||
4 12
|
||||
5 11
|
||||
6 12
|
||||
7 11
|
||||
8 11
|
||||
9 12
|
||||
space 3
|
||||
! 2
|
||||
" 6
|
||||
# 14
|
||||
$ 11
|
||||
% 15
|
||||
& 13
|
||||
' 2
|
||||
( 5
|
||||
) 5
|
||||
* 8
|
||||
+ 10
|
||||
, 3
|
||||
- 8
|
||||
. 2
|
||||
/ 9
|
||||
: 2
|
||||
; 4
|
||||
< 9
|
||||
= 11
|
||||
> 9
|
||||
? 9
|
||||
@ 18
|
||||
A 13
|
||||
B 11
|
||||
C 14
|
||||
D 12
|
||||
E 10
|
||||
F 10
|
||||
G 14
|
||||
H 12
|
||||
I 2
|
||||
J 5
|
||||
K 12
|
||||
L 9
|
||||
M 15
|
||||
N 11
|
||||
O 15
|
||||
P 10
|
||||
Q 15
|
||||
R 10
|
||||
S 11
|
||||
T 12
|
||||
U 12
|
||||
V 12
|
||||
W 18
|
||||
X 11
|
||||
Y 10
|
||||
Z 11
|
||||
[ 5
|
||||
\ 9
|
||||
] 5
|
||||
^ 7
|
||||
_ 11
|
||||
` 3
|
||||
a 9
|
||||
b 10
|
||||
c 10
|
||||
d 10
|
||||
e 10
|
||||
f 7
|
||||
g 10
|
||||
h 9
|
||||
i 3
|
||||
j 4
|
||||
k 10
|
||||
l 2
|
||||
m 16
|
||||
n 9
|
||||
o 11
|
||||
p 10
|
||||
q 10
|
||||
r 6
|
||||
s 8
|
||||
t 7
|
||||
u 9
|
||||
v 8
|
||||
w 14
|
||||
x 9
|
||||
y 10
|
||||
z 9
|
||||
{ 6
|
||||
| 2
|
||||
} 6
|
||||
~ 10
|
||||
¥ 10
|
||||
… 12
|
||||
™ 16
|
||||
‼ 6
|
||||
© 15
|
||||
® 15
|
||||
<EFBFBD> 15
|
||||
Ⓐ 18
|
||||
Ⓑ 18
|
||||
🌐 18
|
||||
› 14
|
||||
▸ 12
|
||||
⊙ 18
|
||||
‘ 3
|
||||
’ 3
|
||||
“ 6
|
||||
” 6
|
||||
|
||||
af -1
|
||||
ar 1
|
||||
at -1
|
||||
av -1
|
||||
aw -1
|
||||
ay -1
|
||||
b, -1
|
||||
b. -1
|
||||
by -1
|
||||
d, -1
|
||||
e, -1
|
||||
e. -1
|
||||
et -1
|
||||
ev -1
|
||||
ew -1
|
||||
ey -1
|
||||
f, -2
|
||||
f. -2
|
||||
fa -1
|
||||
fe -1
|
||||
ff -2
|
||||
fl -1
|
||||
fo -2
|
||||
ht -1
|
||||
hv -1
|
||||
hw -1
|
||||
hy -1
|
||||
ic -1
|
||||
id -1
|
||||
ie -1
|
||||
ig -1
|
||||
io -1
|
||||
ip -1
|
||||
it -2
|
||||
iu -1
|
||||
iv -1
|
||||
ka -2
|
||||
kc -2
|
||||
kd -2
|
||||
ke -2
|
||||
kg -2
|
||||
ko -2
|
||||
Lo -1
|
||||
mt -1
|
||||
mv -1
|
||||
my -1
|
||||
nt -1
|
||||
nv -1
|
||||
nw -1
|
||||
ny -1
|
||||
o, -2
|
||||
o. -1
|
||||
of -2
|
||||
oj -2
|
||||
ov -1
|
||||
ow -1
|
||||
ox -1
|
||||
oy -1
|
||||
p, -1
|
||||
p. -1
|
||||
r, -3
|
||||
r. -2
|
||||
ra -1
|
||||
rd -1
|
||||
re -1
|
||||
rg -1
|
||||
ro -2
|
||||
rq -1
|
||||
rt -1
|
||||
s, -1
|
||||
s. -1
|
||||
st -1
|
||||
t. 1
|
||||
ta 1
|
||||
ti 1
|
||||
tl 1
|
||||
up 1
|
||||
ur 1
|
||||
v, -2
|
||||
v. -2
|
||||
vc -1
|
||||
vd -1
|
||||
ve -1
|
||||
vg -1
|
||||
vo -1
|
||||
vy -1
|
||||
w, -2
|
||||
w. -1
|
||||
wa -1
|
||||
wd -1
|
||||
we -1
|
||||
wg -1
|
||||
wo -1
|
||||
wx -1
|
||||
xa -1
|
||||
xe -1
|
||||
xo -1
|
||||
y, -3
|
||||
y. -2
|
||||
ya -1
|
||||
yc -1
|
||||
yd -1
|
||||
ye -1
|
||||
Yo -2
|
||||
yo -1
|
||||
LO -2
|
||||
AT -3
|
||||
AY -3
|
||||
// -4
|
||||
/d -2
|
||||
/p -1
|
||||
tp 1
|
||||
t: 1
|
||||
/w -1
|
||||
ot -1
|
||||
Wo -2
|
||||
Fo -2
|
||||
Fu -2
|
||||
Vu -1
|
||||
Tu -2
|
||||
To -3
|
||||
Vo -2
|
||||
Yu -1
|
||||
Zo -1
|
||||
ty -1
|
||||
is -1
|
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 626 B |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 592 B After Width: | Height: | Size: 592 B |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 738 B After Width: | Height: | Size: 738 B |
Before Width: | Height: | Size: 601 B After Width: | Height: | Size: 601 B |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 654 B |
After Width: | Height: | Size: 611 B |
After Width: | Height: | Size: 614 B |
Before Width: | Height: | Size: 592 B After Width: | Height: | Size: 592 B |
Before Width: | Height: | Size: 589 B After Width: | Height: | Size: 589 B |
Before Width: | Height: | Size: 600 B After Width: | Height: | Size: 600 B |
Before Width: | Height: | Size: 579 B After Width: | Height: | Size: 579 B |
Before Width: | Height: | Size: 589 B After Width: | Height: | Size: 589 B |
Before Width: | Height: | Size: 593 B After Width: | Height: | Size: 593 B |
Before Width: | Height: | Size: 596 B After Width: | Height: | Size: 596 B |
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 587 B |
Before Width: | Height: | Size: 598 B After Width: | Height: | Size: 598 B |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 10 KiB |
224
src/ball.lua
|
@ -1,30 +1,31 @@
|
|||
---@class Ball
|
||||
---@field x number
|
||||
---@field y number
|
||||
---@field xVelocity number
|
||||
---@field yVelocity number
|
||||
---@field z number
|
||||
---@field flyBall boolean
|
||||
---@field size number
|
||||
---@field heldBy Fielder | nil
|
||||
---@field catchable boolean
|
||||
---@field isFlyBall boolean
|
||||
---@field xAnimator SimpleAnimator
|
||||
---@field yAnimator SimpleAnimator
|
||||
---@field sizeAnimator SimpleAnimator
|
||||
---@field zAnimator SimpleAnimator
|
||||
---@field floatAnimator SimpleAnimator
|
||||
---@field private animatorLib pd_animator_lib
|
||||
---@field private bounce thread Requires deltaSeconds on resume, returns ball height.
|
||||
---@field private flyTimeMs number
|
||||
Ball = {}
|
||||
|
||||
local function defaultFloatAnimator(animatorLib)
|
||||
return animatorLib.new(2000, -60, 0, utils.easingHill)
|
||||
end
|
||||
|
||||
---@param animatorLib pd_animator_lib
|
||||
---@return Ball
|
||||
function Ball.new(animatorLib)
|
||||
return setmetatable({
|
||||
animatorLib = animatorLib,
|
||||
x = 400 --[[@as number]],
|
||||
y = 300 --[[@as number]],
|
||||
x = C.Center.x --[[@as number]],
|
||||
y = C.Center.y --[[@as number]],
|
||||
z = 0,
|
||||
flyBall = false,
|
||||
catchable = true,
|
||||
size = C.SmallestBallRadius,
|
||||
heldBy = nil --[[@type Runner | nil]],
|
||||
|
||||
|
@ -34,184 +35,69 @@ function Ball.new(animatorLib)
|
|||
-- TODO? Replace these with a ballAnimatorZ?
|
||||
-- ...that might lose some of the magic of both. Compromise available? idk
|
||||
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
|
||||
zAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
|
||||
floatAnimator = defaultFloatAnimator(animatorLib),
|
||||
}, { __index = Ball })
|
||||
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
|
||||
function Ball:updatePosition(deltaSeconds)
|
||||
-- printTable({ x = self.x, y = self.y, z = self.z })
|
||||
if self.heldBy then
|
||||
utils.moveAtSpeedZ(self, deltaSeconds * 10, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ })
|
||||
-- self.x = self.heldBy.x
|
||||
-- self.y = self.heldBy.y
|
||||
-- self.z = C.GloveZ
|
||||
-- self.size = C.SmallestBallRadius
|
||||
utils.moveAtSpeedZ(self, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ })
|
||||
self.size = C.SmallestBallRadius
|
||||
else
|
||||
-- self.x = self.x + self.xVelocity
|
||||
-- self.x = self.xAnimator:currentValue()
|
||||
-- self.y = self.yAnimator:currentValue()
|
||||
-- self.z = self.zAnimator:currentValue()
|
||||
if self.bounce then
|
||||
local alive, z, justBounced = coroutine.resume(self.bounce, deltaSeconds)
|
||||
if alive then
|
||||
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
|
||||
self.x = self.xAnimator:currentValue()
|
||||
local z = self.floatAnimator:currentValue()
|
||||
-- TODO: This `+ z` is more graphics logic than physics logic
|
||||
self.y = self.yAnimator:currentValue() + z
|
||||
self.z = z
|
||||
if self.z < 2 and self.isFlyBall then
|
||||
print("Ball hit the ground!")
|
||||
self.isFlyBall = false
|
||||
end
|
||||
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
|
||||
self.size = C.SmallestBallRadius + math.max(0, (self.floatAnimator:currentValue() - C.GloveZ) / 2)
|
||||
end
|
||||
end
|
||||
|
||||
---@alias DestinationAndFlightTime { destX: number, destY: number, flyTimeMs: number }
|
||||
|
||||
---@alias LaunchControls DestinationAndFlightTime
|
||||
function Ball:markUncatchable()
|
||||
self.catchable = false
|
||||
playdate.timer.new(200, function()
|
||||
self.catchable = true
|
||||
end)
|
||||
end
|
||||
|
||||
--- Launches the ball from its current position to the given destination.
|
||||
---@param destX number
|
||||
---@param destY number
|
||||
---@param easingFunc EasingFunc
|
||||
---@param freshHit boolean | nil
|
||||
---@param flyTimeMs number | nil The angle away from parallel to the ground.
|
||||
--- 0 is straight forward, 90 is straight up, 180 is straight behind.
|
||||
function Ball:launch(destX, destY, _, freshHit, _, power)
|
||||
if freshHit then
|
||||
self.flyBall = true
|
||||
end
|
||||
throwMeter:reset()
|
||||
---@param flyTimeMs number | nil
|
||||
---@param floaty boolean | nil
|
||||
---@param customFloater pd_animator | nil
|
||||
---@param isHit boolean
|
||||
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customFloater, isHit)
|
||||
self.heldBy = nil
|
||||
self.isFlyBall = isHit
|
||||
|
||||
local flightDistance, x, y = utils.distanceBetween(self.x, self.y, destX, destY)
|
||||
local timeToBounce = timeToFirstBounce(10)
|
||||
-- Prevent silly insta-catches
|
||||
self:markUncatchable()
|
||||
|
||||
-- if not flyTimeMs then
|
||||
-- flyTimeMs = flightDistance * C.DefaultLaunchPower
|
||||
-- end
|
||||
if not flyTimeMs then
|
||||
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
|
||||
end
|
||||
|
||||
-- TODO? set a maxThrowDistance to limit throws by, instead
|
||||
power = power or 5
|
||||
self.xVelocity = -1.1 * x -- (x / -flightDistance) * power
|
||||
self.yVelocity = -1.1 * y -- (y / -flightDistance) * power
|
||||
printTable({ x = x, y = y })
|
||||
printTable({
|
||||
destX = destX,
|
||||
destY = destY,
|
||||
x = x,
|
||||
y = y,
|
||||
-- xVelocity = self.xVelocity,
|
||||
-- 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)
|
||||
|
||||
self.bounce = coroutine.create(bouncer(10))
|
||||
if customFloater then
|
||||
self.floatAnimator = customFloater
|
||||
else
|
||||
self.sizeAnimator = self.animatorLib.new(flyTimeMs, C.SmallestBallRadius, 9, utils.easingHill)
|
||||
self.floatAnimator = defaultFloatAnimator(self.animatorLib)
|
||||
end
|
||||
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
|
||||
self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
|
||||
if floaty then
|
||||
self.floatAnimator:reset(flyTimeMs)
|
||||
end
|
||||
end
|
||||
|
||||
-- luacheck: ignore
|
||||
if not playdate or playdate.TEST_MODE then
|
||||
return Ball
|
||||
end
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
--- @alias Runner {
|
||||
--- x: number,
|
||||
--- y: number,
|
||||
--- nextBase: Base,
|
||||
--- prevBase: Base | nil,
|
||||
--- forcedTo: Base | nil,
|
||||
--- }
|
||||
--- @class Runner
|
||||
--- @field x number
|
||||
--- @field y number
|
||||
--- @field nextBase Base
|
||||
--- @field prevBase Base | nil
|
||||
--- @field forcedTo Base | nil
|
||||
--- @field spriteIndex number
|
||||
|
||||
---@class Baserunning
|
||||
---@field runners Runner[]
|
||||
|
@ -12,15 +12,18 @@
|
|||
---@field scoredRunners Runner[]
|
||||
---@field batter Runner | nil
|
||||
---@field outs number
|
||||
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
|
||||
---@field secondsSinceLastRunnerMove number
|
||||
---@field announcer Announcer
|
||||
---@field onThirdOut fun()
|
||||
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 onThirdOutCallback fun()
|
||||
---@return Baserunning
|
||||
function Baserunning.new(announcer, onThirdOut)
|
||||
function Baserunning.new(announcer, onThirdOutCallback)
|
||||
local o = setmetatable({
|
||||
runners = {},
|
||||
outRunners = {},
|
||||
|
@ -30,7 +33,7 @@ function Baserunning.new(announcer, onThirdOut)
|
|||
--- it seems sensible to store the value here.
|
||||
outs = 0,
|
||||
announcer = announcer,
|
||||
onThirdOut = onThirdOut,
|
||||
onThirdOut = onThirdOutCallback,
|
||||
}, { __index = Baserunning })
|
||||
|
||||
o:pushNewBatter()
|
||||
|
@ -38,7 +41,7 @@ function Baserunning.new(announcer, onThirdOut)
|
|||
return o
|
||||
end
|
||||
|
||||
---@param runner integer | Runner
|
||||
---@param runner number | Runner
|
||||
---@param message string | nil
|
||||
---@return boolean wasThirdOut
|
||||
function Baserunning:outRunner(runner, message)
|
||||
|
@ -50,9 +53,8 @@ function Baserunning:outRunner(runner, message)
|
|||
end
|
||||
end
|
||||
end
|
||||
if type(runner) ~= "number" then
|
||||
error("Expected runner to have type 'number', but was: " .. type(runner))
|
||||
end
|
||||
local runnerType = type(runner)
|
||||
assert(runnerType == "number", "Expected runner to have type 'number', but was: " .. runnerType)
|
||||
self.outRunners[#self.outRunners + 1] = self.runners[runner]
|
||||
table.remove(self.runners, runner)
|
||||
|
||||
|
@ -65,7 +67,6 @@ function Baserunning:outRunner(runner, message)
|
|||
self.onThirdOut()
|
||||
self.outs = 0
|
||||
|
||||
-- TODO: outRunners/scoredRunners split
|
||||
while #self.runners > 0 do
|
||||
self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
|
||||
end
|
||||
|
@ -79,9 +80,17 @@ function Baserunning:outEligibleRunners(fielder)
|
|||
local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
|
||||
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
|
||||
local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
|
||||
-- TODO: Tag-outs when two baserunners are on the same base.
|
||||
local runnerOnBase = runnerBaseBiMap[runner]
|
||||
if -- Force out
|
||||
touchedBase
|
||||
and runner.prevBase -- Make sure the runner is not standing at home
|
||||
|
@ -126,18 +135,32 @@ function Baserunning:convertBatterToRunner()
|
|||
end
|
||||
|
||||
---@param deltaSeconds number
|
||||
function Baserunning:walkAwayOutRunners(deltaSeconds)
|
||||
for i, runner in ipairs(self.outRunners) do
|
||||
---@param runner Runner
|
||||
---@return boolean isStillWalking
|
||||
local function walkWayOutRunner(deltaSeconds, runner)
|
||||
if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
|
||||
runner.x = runner.x + (deltaSeconds * 25)
|
||||
runner.y = runner.y + (deltaSeconds * 25)
|
||||
else
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---@param deltaSeconds number
|
||||
function Baserunning:walkAwayOutRunners(deltaSeconds)
|
||||
for i, runner in ipairs(self.outRunners) do
|
||||
if not walkWayOutRunner(deltaSeconds, runner) then
|
||||
table.remove(self.outRunners, i)
|
||||
end
|
||||
end
|
||||
for i, runner in ipairs(self.scoredRunners) do
|
||||
if not walkWayOutRunner(deltaSeconds, runner) then
|
||||
table.remove(self.scoredRunners, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@return Runner
|
||||
---@return Runner theBatterPushed
|
||||
function Baserunning:pushNewBatter()
|
||||
local new = {
|
||||
-- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol.
|
||||
|
@ -146,27 +169,31 @@ function Baserunning:pushNewBatter()
|
|||
nextBase = C.RightHandedBattersBox,
|
||||
prevBase = nil,
|
||||
forcedTo = C.Bases[C.First],
|
||||
spriteIndex = math.random(#HomeTeamSpriteGroup),
|
||||
}
|
||||
self.runners[#self.runners + 1] = new
|
||||
self.batter = new
|
||||
return new
|
||||
end
|
||||
|
||||
---@param self table
|
||||
---@param runnerIndex integer
|
||||
function Baserunning:getNewestRunner()
|
||||
return self.runners[#self.runners]
|
||||
end
|
||||
|
||||
---@param runnerIndex number
|
||||
function Baserunning:runnerScored(runnerIndex)
|
||||
-- TODO: outRunners/scoredRunners split
|
||||
self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex]
|
||||
self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex]
|
||||
table.remove(self.runners, runnerIndex)
|
||||
end
|
||||
|
||||
--- Returns true only if the given runner moved during this update.
|
||||
---@param runner Runner | nil
|
||||
---@param runnerIndex integer | nil May only be nil if runner == batter
|
||||
---@param runnerIndex number | nil May only be nil if runner == batter
|
||||
---@param appliedSpeed number
|
||||
---@param isAutoRun boolean
|
||||
---@param deltaSeconds number
|
||||
---@return boolean runnerMoved, boolean runnerScored
|
||||
function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSeconds)
|
||||
function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun, deltaSeconds)
|
||||
local autoRunSpeed = 20 * deltaSeconds
|
||||
|
||||
if not runner or not runner.nextBase then
|
||||
|
@ -178,7 +205,7 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
|
|||
if
|
||||
nearestBaseDistance < 5
|
||||
and runnerIndex ~= nil
|
||||
and runner ~= self.batter --runner.prevBase
|
||||
and runner ~= self.batter
|
||||
and runner.nextBase == C.Bases[C.Home]
|
||||
and nearestBase == C.Bases[C.Home]
|
||||
then
|
||||
|
@ -213,9 +240,12 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
|
|||
-- TODO: Make this less "sticky" for the user.
|
||||
-- Currently it can be a little hard to run *past* a base.
|
||||
|
||||
local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
|
||||
local autoRun = 0
|
||||
if not isAutoRun then
|
||||
autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
|
||||
or nearestBaseDistance < 5 and 0
|
||||
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
|
||||
end
|
||||
|
||||
mult = autoRun + (appliedSpeed / 20)
|
||||
runner.x = runner.x - (x * mult)
|
||||
|
@ -227,9 +257,12 @@ end
|
|||
--- Update non-batter runners.
|
||||
--- Returns true only if at least one of the given runners moved during this update
|
||||
---@param appliedSpeed number | fun(runner: Runner): number
|
||||
---@return boolean someRunnerMoved, number runnersScored
|
||||
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
|
||||
local someRunnerMoved = false
|
||||
---@param forcedOnly boolean If true, only move forced runners (e.g. for a walk)
|
||||
---@param isAutoRun boolean If true, does not attempt to hug the bases
|
||||
---@param deltaSeconds number
|
||||
---@return boolean runnersStillMoving, number runnersScored, number secondsSinceLastMove
|
||||
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun, deltaSeconds)
|
||||
local runnersStillMoving = false
|
||||
local runnersScored = 0
|
||||
|
||||
local speedIsFunction = type(appliedSpeed) == "function"
|
||||
|
@ -240,19 +273,23 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
|
|||
if speedIsFunction then
|
||||
speed = appliedSpeed(runner)
|
||||
end
|
||||
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
|
||||
someRunnerMoved = someRunnerMoved or thisRunnerMoved
|
||||
local thisRunnerMoved, thisRunnerScored =
|
||||
self:updateRunner(runner, runnerIndex, speed, isAutoRun, deltaSeconds)
|
||||
runnersStillMoving = runnersStillMoving or thisRunnerMoved
|
||||
if thisRunnerScored then
|
||||
runnersScored = runnersScored + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if someRunnerMoved then
|
||||
if runnersStillMoving then
|
||||
self.secondsSinceLastRunnerMove = 0
|
||||
self:updateForcedRunners()
|
||||
else
|
||||
self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds
|
||||
end
|
||||
|
||||
return someRunnerMoved, runnersScored
|
||||
return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
|
||||
end
|
||||
|
||||
-- luacheck: ignore
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
---@class BatRenderState
|
||||
---@field batBase XyPair
|
||||
---@field batTip XyPair
|
||||
---@field batAngleDeg number
|
||||
---@field batSpeed number
|
||||
|
||||
---@class Batting
|
||||
---@field private Baserunning
|
||||
---@field state BatRenderState Is updated by checkForHit()
|
||||
Batting = {}
|
||||
|
||||
local SwingBackDeg <const> = 30
|
||||
local SwingForwardDeg <const> = 170
|
||||
local OffscreenPos <const> = utils.xy(-999, -999)
|
||||
|
||||
---@param baserunning Baserunning
|
||||
function Batting.new(baserunning)
|
||||
return setmetatable({
|
||||
baserunning = baserunning,
|
||||
state = {
|
||||
batAngleDeg = 0,
|
||||
batSpeed = 0,
|
||||
batTip = OffscreenPos,
|
||||
batBase = OffscreenPos,
|
||||
},
|
||||
}, { __index = Batting })
|
||||
end
|
||||
|
||||
-- TODO? Make the bat angle work more like the throw meter.
|
||||
-- Would instead constantly drift toward a default value, giving us a little more control,
|
||||
-- and letting the user find a crank position and direction that works for them
|
||||
|
||||
--- Assumes the bat is being held by self.baserunning.batter
|
||||
--- Mutates self.state for later rendering.
|
||||
---@param batDeg number
|
||||
---@param batSpeed number
|
||||
---@param ball Point3d
|
||||
---@return XyPair | nil, boolean, number | nil Ball destination or nil if no hit, true only if batter swung, power mult
|
||||
function Batting:checkForHit(batDeg, batSpeed, ball)
|
||||
local batter = self.baserunning.batter
|
||||
local isSwinging = batDeg > SwingBackDeg and batDeg < SwingForwardDeg
|
||||
local batRadians = math.rad(batDeg)
|
||||
|
||||
local base = batter and utils.xy(batter.x + C.BatterHandPos.x, batter.y + C.BatterHandPos.y) or OffscreenPos
|
||||
local tip = utils.xy(base.x + (C.BatLength * math.sin(batRadians)), base.y + (C.BatLength * math.cos(batRadians)))
|
||||
|
||||
self.state.batSpeed = batSpeed
|
||||
self.state.batAngleDeg = batDeg
|
||||
self.state.batTip = tip
|
||||
self.state.batBase = base
|
||||
|
||||
local ballWasHit = batSpeed > 0 and ball.y < 232 and utils.pointOnOrUnderLine(ball, base, tip, C.Screen.H)
|
||||
|
||||
if not ballWasHit then
|
||||
return nil, isSwinging
|
||||
end
|
||||
|
||||
local ballAngle = batRadians + math.rad(90)
|
||||
local mult = math.abs(batSpeed / 15)
|
||||
local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
|
||||
local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
|
||||
if ballVelY > 0 then
|
||||
ballVelX = ballVelX * -1
|
||||
ballVelY = ballVelY * -1
|
||||
end
|
||||
|
||||
return utils.xy(ball.x + ballVelX, ball.y + ballVelY), isSwinging, mult
|
||||
end
|
|
@ -67,39 +67,39 @@ C.ScoreboardDelayMs = 2000
|
|||
--- generally as a check for whether or not it's in play.
|
||||
C.BallOffscreen = 999
|
||||
|
||||
C.PitchAfterSeconds = 8
|
||||
C.PitchAfterSeconds = 6
|
||||
C.ReturnToPitcherAfterSeconds = 2.4
|
||||
C.PitchFlyMs = 1050
|
||||
C.PitchStartX = 195
|
||||
C.PitchStartY, C.PitchEndY = 105, 240
|
||||
C.PitchStart = utils.xy(195, 105)
|
||||
C.PitchEndY = 240
|
||||
|
||||
C.DefaultLaunchPower = 4
|
||||
|
||||
--- The max distance at which a fielder can tag out a runner.
|
||||
C.TagDistance = 15
|
||||
|
||||
C.BallCatchHitbox = 15
|
||||
C.BallCatchHitbox = 3
|
||||
|
||||
--- The max distance at which a runner can be considered on base.
|
||||
C.BaseHitbox = 10
|
||||
|
||||
C.BattingPower = 25
|
||||
C.BatterHandPos = utils.xy(25, 15)
|
||||
C.GloveZ = 10
|
||||
C.GloveZ = 0 -- 10
|
||||
|
||||
C.SmallestBallRadius = 6
|
||||
|
||||
C.BatLength = 35
|
||||
|
||||
-- TODO: enums implemented this way are probably going to be difficult to serialize!
|
||||
|
||||
---@alias OffenseState "batting" | "running" | "walking"
|
||||
---@alias OffenseState "batting" | "running" | "walking" | "homeRun"
|
||||
--- An enum for what state the offense is in
|
||||
---@type table<string, OffenseState>
|
||||
C.Offense = {
|
||||
batting = "batting",
|
||||
running = "running",
|
||||
walking = "walking",
|
||||
homeRun = "homeRun",
|
||||
fliedOut = "running",
|
||||
}
|
||||
|
||||
---@alias Side "offense" | "defense"
|
||||
|
@ -109,15 +109,14 @@ C.PitcherStartPos = {
|
|||
y = C.Screen.H * 0.40,
|
||||
}
|
||||
|
||||
C.ThrowMeterMax = 10
|
||||
C.ThrowMeterDrainPerSec = 150
|
||||
|
||||
--- Controls how hard the ball can be hit, and
|
||||
--- how fast the ball can be thrown.
|
||||
C.CrankPower = 10
|
||||
|
||||
C.FielderRunMult = 1.3
|
||||
|
||||
C.PlayerHeightOffset = 20
|
||||
|
||||
C.UserThrowPower = 0.3
|
||||
|
||||
--- How fast baserunners move after a walk
|
||||
|
@ -125,6 +124,24 @@ C.WalkedRunnerSpeed = 10
|
|||
|
||||
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
|
||||
return C
|
||||
end
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
local gfx = playdate.graphics
|
||||
|
||||
local HeaderFont <const> = Roobert11Medium
|
||||
local DetailFont <const> = FontFullCircle
|
||||
|
||||
---@alias TextObject { text: string, font: pd_font }
|
||||
|
||||
---@param texts TextObject[]
|
||||
local function drawTexts(texts)
|
||||
local xOffset = 10
|
||||
local initialOffset <const> = -(HeaderFont:getHeight()) / 2
|
||||
local yOffset = initialOffset
|
||||
|
||||
--- The text height plus a margin scaled to that height
|
||||
function getOffsetOffset(textObject)
|
||||
return (-4 + math.floor(textObject.font:getHeight() * 1.6)) / 2
|
||||
end
|
||||
|
||||
-- Inverted buffer around text to separate it from the background
|
||||
for _, textObject in ipairs(texts) do
|
||||
local offsetOffset = getOffsetOffset(textObject)
|
||||
yOffset = yOffset + offsetOffset
|
||||
gfx.setImageDrawMode(gfx.kDrawModeInverted)
|
||||
for x = xOffset - 6, xOffset + 6 do
|
||||
for y = yOffset - 6, yOffset + 6 do
|
||||
textObject.font:drawText(textObject.text, x, y)
|
||||
end
|
||||
end
|
||||
yOffset = yOffset + offsetOffset
|
||||
end
|
||||
|
||||
-- Drawing the actual text afterward (instead of inline) keeps the inverted buffer from drawing over it.
|
||||
yOffset = initialOffset
|
||||
gfx.setImageDrawMode(gfx.kDrawModeCopy)
|
||||
for _, textObject in ipairs(texts) do
|
||||
local offsetOffset = getOffsetOffset(textObject)
|
||||
yOffset = yOffset + offsetOffset
|
||||
textObject.font:drawText(textObject.text, xOffset, yOffset)
|
||||
yOffset = yOffset + offsetOffset
|
||||
end
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@return TextObject
|
||||
local function header(text)
|
||||
return { text = text, font = HeaderFont }
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@return TextObject
|
||||
local function detail(text)
|
||||
return { text = text, font = DetailFont }
|
||||
end
|
||||
|
||||
---@class ControlScreen
|
||||
---@field sceneToReturnTo Scene
|
||||
---@field private renderedImage pd_image Static image doesn't need to be constantly re-rendered.
|
||||
ControlScreen = {}
|
||||
|
||||
---@return pd_image
|
||||
local function draw()
|
||||
local image = gfx.image.new(C.Screen.W, C.Screen.H)
|
||||
gfx.pushContext(image)
|
||||
BallBackground:draw(0, 0)
|
||||
drawTexts({
|
||||
header("Batting:"),
|
||||
detail("Swing the crank to swing your bat"),
|
||||
detail("But watch out! Some pitches are tricky!"),
|
||||
|
||||
header("Pitching:"),
|
||||
detail("Swing the crank to pitch the ball"),
|
||||
detail("But be careful! Throw too hard and it might go wild!"),
|
||||
detail("(shh: try holding A or B while you pitch)"),
|
||||
|
||||
header("Fielding:"),
|
||||
detail("To throw, hold a direction button and crank!"),
|
||||
detail("Right throws to 1st, Up goes to 2nd, etc."),
|
||||
})
|
||||
gfx.popContext()
|
||||
return image
|
||||
end
|
||||
|
||||
---@param sceneToReturnTo Scene
|
||||
---@return ControlScreen
|
||||
function ControlScreen.new(sceneToReturnTo)
|
||||
return setmetatable({
|
||||
sceneToReturnTo = sceneToReturnTo,
|
||||
renderedImage = draw(),
|
||||
}, { __index = ControlScreen })
|
||||
end
|
||||
|
||||
function ControlScreen:update()
|
||||
gfx.animation.blinker.updateAll()
|
||||
gfx.clear()
|
||||
self.renderedImage:draw(0, 0)
|
||||
drawButton("B", 370, 210)
|
||||
if playdate.buttonJustPressed(playdate.kButtonA) or playdate.buttonJustPressed(playdate.kButtonB) then
|
||||
transitionBetween(self, self.sceneToReturnTo)
|
||||
end
|
||||
end
|
65
src/dbg.lua
|
@ -16,7 +16,8 @@ function dbg.label(value, name)
|
|||
return value
|
||||
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)
|
||||
br:pushNewBatter()
|
||||
br:pushNewBatter()
|
||||
|
@ -35,6 +36,68 @@ function dbg.loadTheBases(br)
|
|||
br.runners[4].nextBase = C.Bases[C.Home]
|
||||
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[]
|
||||
function dbg.drawLine(points)
|
||||
for i = 2, #points do
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
local gfx <const> = playdate.graphics
|
||||
|
||||
function Ball:draw()
|
||||
gfx.setLineWidth(2)
|
||||
|
||||
gfx.setColor(gfx.kColorWhite)
|
||||
gfx.fillCircleAtPoint(self.x, self.y, self.size)
|
||||
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
gfx.drawCircleAtPoint(self.x, self.y, self.size)
|
||||
end
|
|
@ -0,0 +1,203 @@
|
|||
local MarginY <const> = 70
|
||||
|
||||
local SmallFont <const> = FontFullCircle
|
||||
local ScoreFont <const> = AshevilleSans14Bold
|
||||
local NumWidth <const> = ScoreFont:getTextWidth("0")
|
||||
local NumHeight <const> = ScoreFont:getHeight()
|
||||
local AwayWidth <const> = ScoreFont:getTextWidth("AWAY")
|
||||
local InningMargin = 4
|
||||
|
||||
local InningDrawWidth <const> = (InningMargin * 2) + (NumWidth * 2)
|
||||
local ScoreDrawHeight = NumHeight * 2
|
||||
|
||||
-- luacheck: ignore 143
|
||||
---@type pd_graphics_lib
|
||||
local gfx = playdate.graphics
|
||||
|
||||
local function formatScore(n)
|
||||
if n <= 9 then
|
||||
return " " .. n
|
||||
elseif n <= 19 then
|
||||
return " " .. n
|
||||
else
|
||||
return tostring(n)
|
||||
end
|
||||
end
|
||||
|
||||
local HomeY <const> = -4 + (NumHeight * 2) + MarginY
|
||||
local AwayY <const> = -4 + (NumHeight * 3) + MarginY
|
||||
|
||||
local function drawInning(x, inningNumber, homeScore, awayScore)
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
gfx.setColor(gfx.kColorWhite)
|
||||
gfx.setLineWidth(1)
|
||||
gfx.drawRect(x, 34 + MarginY, InningDrawWidth, ScoreDrawHeight)
|
||||
|
||||
inningNumber = " " .. inningNumber
|
||||
homeScore = formatScore(homeScore)
|
||||
awayScore = formatScore(awayScore)
|
||||
|
||||
x = x - 8 + (InningDrawWidth / 2)
|
||||
ScoreFont:drawTextAligned(inningNumber, x, -4 + NumHeight + MarginY, gfx.kAlignRight)
|
||||
ScoreFont:drawTextAligned(awayScore, x, HomeY, gfx.kAlignRight)
|
||||
ScoreFont:drawTextAligned(homeScore, x, AwayY, gfx.kAlignRight)
|
||||
end
|
||||
|
||||
---@class BoxScore
|
||||
---@field stats Statistics
|
||||
---@field private targetY number
|
||||
BoxScore = {}
|
||||
|
||||
---@param stats Statistics
|
||||
---@return BoxScore
|
||||
function BoxScore.new(stats)
|
||||
return setmetatable({
|
||||
stats = stats,
|
||||
targetY = 0,
|
||||
}, { __index = BoxScore })
|
||||
end
|
||||
|
||||
function BoxScore:drawBoxScore()
|
||||
local inningStart = 4 + (AwayWidth * 1.5)
|
||||
local widthAndMarg = InningDrawWidth + 4
|
||||
ScoreFont:drawTextAligned(" HOME", 10, HomeY, gfx.kAlignRight)
|
||||
ScoreFont:drawTextAligned("AWAY", 10, AwayY, gfx.kAlignRight)
|
||||
for i = 1, #self.stats.innings do
|
||||
local inningStats = self.stats.innings[i]
|
||||
drawInning(inningStart + ((i - 1) * widthAndMarg), i, inningStats.home.score, inningStats.away.score)
|
||||
end
|
||||
local homeScore, awayScore = utils.totalScores(self.stats)
|
||||
drawInning(4 + inningStart + (widthAndMarg * #self.stats.innings), "F", homeScore, awayScore)
|
||||
ScoreFont:drawTextAligned("v", C.Center.x, C.Screen.H - 40, gfx.kAlignCenter)
|
||||
end
|
||||
|
||||
local GraphM = 10
|
||||
local GraphW = C.Screen.W - (GraphM * 2)
|
||||
local GraphH = C.Screen.H - (GraphM * 2)
|
||||
|
||||
function BoxScore:drawScoreGraph(y)
|
||||
-- TODO: Actually draw score legend
|
||||
|
||||
-- Offset by 2 to support a) the zero-index b) the score legend
|
||||
local segmentWidth = GraphW / (#self.stats.innings + 2)
|
||||
|
||||
local legendX = segmentWidth * (#self.stats.innings + 2) - GraphM
|
||||
gfx.drawLine(GraphM / 2, y + GraphM + GraphH, legendX, y + GraphM + GraphH)
|
||||
gfx.drawLine(legendX, y + GraphM, legendX, y + GraphH + GraphM)
|
||||
|
||||
gfx.setLineWidth(3)
|
||||
local homeScore, awayScore = utils.totalScores(self.stats)
|
||||
local highestScore = math.max(homeScore, awayScore)
|
||||
|
||||
local heightPerPoint = (GraphH - 6) / highestScore
|
||||
|
||||
function point(inning, score)
|
||||
return utils.xy(GraphM + (inning * segmentWidth), y + GraphM + GraphH + (score * -heightPerPoint))
|
||||
end
|
||||
|
||||
function drawLine(teamId)
|
||||
local linePoints = { point(0, 0) }
|
||||
local scoreTotal = 0
|
||||
for i, inning in ipairs(self.stats.innings) do
|
||||
scoreTotal = scoreTotal + inning[teamId].score
|
||||
linePoints[#linePoints + 1] = point(i, scoreTotal)
|
||||
end
|
||||
dbg.drawLine(linePoints)
|
||||
local finalPoint = linePoints[#linePoints]
|
||||
SmallFont:drawTextAligned(string.upper(teamId), finalPoint.x + 3, finalPoint.y - 7, gfx.kAlignRight)
|
||||
end
|
||||
|
||||
drawLine("home")
|
||||
gfx.setDitherPattern(0.5)
|
||||
drawLine("away")
|
||||
gfx.setDitherPattern(0)
|
||||
end
|
||||
|
||||
---@param realHit XyPair
|
||||
---@return XyPair
|
||||
function convertHitToMini(realHit)
|
||||
-- Convert to all-positive y
|
||||
local y = realHit.y + C.Screen.H
|
||||
y = y / 2
|
||||
|
||||
local x = realHit.x + C.Screen.W
|
||||
x = x / 3
|
||||
return utils.xy(x, y)
|
||||
end
|
||||
|
||||
function BoxScore:drawHitChart(y)
|
||||
local leftMargin = 8
|
||||
GrassBackgroundSmall:drawCentered(C.Center.x, y + C.Center.y + 54)
|
||||
gfx.setLineWidth(1)
|
||||
ScoreFont:drawTextAligned("AWAY", leftMargin, y + C.Screen.H - NumHeight, gfx.kAlignRight)
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer2x2)
|
||||
gfx.fillRect(leftMargin, y + C.Screen.H - NumHeight, ScoreFont:getTextWidth("AWAY"), NumHeight)
|
||||
|
||||
gfx.setColor(gfx.kColorWhite)
|
||||
gfx.setDitherPattern(0.5)
|
||||
for _, inning in ipairs(self.stats.innings) do
|
||||
for _, hit in ipairs(inning.away.hits) do
|
||||
local miniHitPos = convertHitToMini(hit)
|
||||
gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4)
|
||||
end
|
||||
end
|
||||
gfx.setColor(gfx.kColorWhite)
|
||||
gfx.setDitherPattern(0)
|
||||
ScoreFont:drawTextAligned(" HOME", leftMargin, y + C.Screen.H - (NumHeight * 2), gfx.kAlignRight)
|
||||
for _, inning in ipairs(self.stats.innings) do
|
||||
for _, hit in ipairs(inning.home.hits) do
|
||||
local miniHitPos = convertHitToMini(hit)
|
||||
gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local screens = {
|
||||
BoxScore.drawBoxScore,
|
||||
BoxScore.drawScoreGraph,
|
||||
BoxScore.drawHitChart,
|
||||
}
|
||||
|
||||
function BoxScore:render()
|
||||
local originalDrawMode = gfx.getImageDrawMode()
|
||||
gfx.clear(gfx.kColorBlack)
|
||||
gfx.setImageDrawMode(gfx.kDrawModeInverted)
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
|
||||
for i, screen in ipairs(screens) do
|
||||
screen(self, (i - 1) * C.Screen.H)
|
||||
end
|
||||
|
||||
gfx.setImageDrawMode(originalDrawMode)
|
||||
end
|
||||
|
||||
local renderedImage
|
||||
|
||||
function BoxScore:update()
|
||||
if not renderedImage then
|
||||
renderedImage = gfx.image.new(C.Screen.W, C.Screen.H * #screens)
|
||||
gfx.pushContext(renderedImage)
|
||||
self:render()
|
||||
gfx.popContext()
|
||||
end
|
||||
|
||||
local deltaSeconds = playdate.getElapsedTime()
|
||||
playdate.resetElapsedTime()
|
||||
|
||||
gfx.setDrawOffset(0, self.targetY)
|
||||
renderedImage:draw(0, 0)
|
||||
|
||||
local crankChange = playdate.getCrankChange()
|
||||
if crankChange ~= 0 then
|
||||
self.targetY = self.targetY - (crankChange * 0.8)
|
||||
else
|
||||
local closestScreen = math.floor(0.5 + (self.targetY / C.Screen.H)) * C.Screen.H
|
||||
if math.abs(self.targetY - closestScreen) > 3 then
|
||||
local needsIncrease = self.targetY < closestScreen
|
||||
local change = needsIncrease and 200 * deltaSeconds or -200 * deltaSeconds
|
||||
self.targetY = self.targetY + change
|
||||
end
|
||||
end
|
||||
self.targetY = math.max(math.min(self.targetY, 0), -C.Screen.H * (#screens - 1))
|
||||
end
|
|
@ -0,0 +1,77 @@
|
|||
-- selene: allow(shadowing)
|
||||
local gfx = playdate.graphics
|
||||
|
||||
---@alias PlayerImageBundle { smiling: pd_image, lowHat: pd_image, frowning: pd_image, back: pd_image }
|
||||
|
||||
---@alias SpriteCollection PlayerImageBundle[]
|
||||
|
||||
---@param image pd_image
|
||||
---@param drawInverted boolean
|
||||
function maybeDrawInverted(image, x, y, drawInverted)
|
||||
local drawMode = gfx.getImageDrawMode()
|
||||
if drawInverted then
|
||||
gfx.setImageDrawMode(gfx.kDrawModeInverted)
|
||||
end
|
||||
image:draw(x, y)
|
||||
gfx.setImageDrawMode(drawMode)
|
||||
end
|
||||
|
||||
--- TODO: Custom names on jerseys?
|
||||
---@return PlayerImageBundle
|
||||
---@param base pd_image
|
||||
---@param isInverted boolean
|
||||
function buildPlayerBundle(base, back, smile, frown, logo, isInverted)
|
||||
local smiling = gfx.image.new(base:getSize())
|
||||
gfx.lockFocus(smiling)
|
||||
base:draw(0, 0)
|
||||
Hat:draw(6, 0)
|
||||
smile:draw(5, 9)
|
||||
maybeDrawInverted(logo, 3, 25, isInverted)
|
||||
|
||||
local lowHat = gfx.image.new(base:getSize())
|
||||
gfx.lockFocus(lowHat)
|
||||
base:draw(0, 0)
|
||||
Hat:draw(6, 2)
|
||||
smile:draw(5, 9)
|
||||
maybeDrawInverted(logo, 3, 25, isInverted)
|
||||
|
||||
local frowning = gfx.image.new(base:getSize())
|
||||
|
||||
gfx.lockFocus(frowning)
|
||||
base:draw(0, 0)
|
||||
maybeDrawInverted(logo, 3, 25, isInverted)
|
||||
Hat:draw(6, 0)
|
||||
frown:draw(5, 9)
|
||||
|
||||
gfx.unlockFocus()
|
||||
|
||||
return {
|
||||
smiling = smiling,
|
||||
lowHat = lowHat,
|
||||
frowning = frowning,
|
||||
back = back,
|
||||
}
|
||||
end
|
||||
|
||||
---@type SpriteCollection
|
||||
AwayTeamSpriteGroup = nil
|
||||
|
||||
---@type SpriteCollection
|
||||
HomeTeamSpriteGroup = nil
|
||||
|
||||
function replaceAwayLogo(logo)
|
||||
AwayTeamSpriteGroup = {
|
||||
buildPlayerBundle(DarkPlayerAwayBase, DarkPlayerAwayBack, DarkPlayerSmile, DarkPlayerFrown, logo, true),
|
||||
buildPlayerBundle(LightPlayerAwayBase, LightPlayerAwayBack, LightPlayerSmile, LightPlayerFrown, logo, true),
|
||||
}
|
||||
end
|
||||
|
||||
function replaceHomeLogo(logo)
|
||||
HomeTeamSpriteGroup = {
|
||||
buildPlayerBundle(DarkPlayerHomeBase, DarkPlayerHomeBack, DarkPlayerSmile, DarkPlayerFrown, logo, true),
|
||||
buildPlayerBundle(LightPlayerHomeBase, LightPlayerHomeBack, LightPlayerSmile, LightPlayerFrown, logo, true),
|
||||
}
|
||||
end
|
||||
|
||||
replaceAwayLogo(Logos[1].image)
|
||||
replaceHomeLogo(Logos[2].image)
|
|
@ -0,0 +1,151 @@
|
|||
---@class Characters
|
||||
---@field homeSprites SpriteCollection
|
||||
---@field awaySprites SpriteCollection
|
||||
---@field homeBlipper table
|
||||
---@field awayBlipper table
|
||||
Characters = {}
|
||||
|
||||
local gfx <const> = playdate.graphics
|
||||
|
||||
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
|
||||
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
|
||||
|
||||
---@param homeSprites SpriteCollection
|
||||
---@param awaySprites SpriteCollection
|
||||
function Characters.new(homeSprites, awaySprites)
|
||||
return setmetatable({
|
||||
homeSprites = homeSprites,
|
||||
awaySprites = awaySprites,
|
||||
homeBlipper = blipper.new(100, homeSprites),
|
||||
awayBlipper = blipper.new(100, awaySprites),
|
||||
}, { __index = Characters })
|
||||
end
|
||||
|
||||
---@param ball Point3d
|
||||
---@param fielderX number
|
||||
---@param fielderY number
|
||||
---@return boolean isHoldingBall
|
||||
local function drawFielderGlove(ball, fielderX, fielderY, flip)
|
||||
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
|
||||
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
|
||||
if distanceFromBall > 20 then
|
||||
Glove:draw(shoulderX, shoulderY, flip)
|
||||
return false
|
||||
else
|
||||
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
---@param fieldingTeamSprites SpriteCollection
|
||||
---@param fielder Fielder
|
||||
---@param ball Point3d
|
||||
---@param flip boolean | nil
|
||||
---@return boolean isHoldingBall
|
||||
function drawFielder(fieldingTeamSprites, fielder, ball, flip)
|
||||
local danceOffset = FielderDanceAnimator:currentValue()
|
||||
|
||||
local x = fielder.x
|
||||
local y = fielder.y - danceOffset
|
||||
fieldingTeamSprites[fielder.spriteIndex].smiling:draw(fielder.x, y - 20, flip)
|
||||
return drawFielderGlove(ball, x, y)
|
||||
end
|
||||
|
||||
---@param batState BatRenderState
|
||||
local function drawBat(batState)
|
||||
gfx.setLineWidth(7)
|
||||
gfx.drawLine(batState.batBase.x, batState.batBase.y, batState.batTip.x, batState.batTip.y)
|
||||
|
||||
gfx.setColor(gfx.kColorWhite)
|
||||
gfx.setLineCapStyle(gfx.kLineCapStyleRound)
|
||||
gfx.setLineWidth(3)
|
||||
gfx.drawLine(batState.batBase.x, batState.batBase.y, batState.batTip.x, batState.batTip.y)
|
||||
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
end
|
||||
|
||||
---@param battingTeamSprites SpriteCollection
|
||||
---@param batter Runner
|
||||
---@param batState BatRenderState
|
||||
local function drawBatter(battingTeamSprites, batter, batState)
|
||||
local spriteCollection = battingTeamSprites[batter.spriteIndex]
|
||||
if batState.batAngleDeg > 50 and batState.batAngleDeg < 200 then
|
||||
drawBat(batState)
|
||||
spriteCollection.back:draw(batter.x, batter.y - C.PlayerHeightOffset)
|
||||
else
|
||||
spriteCollection.smiling:draw(batter.x, batter.y - C.PlayerHeightOffset)
|
||||
drawBat(batState)
|
||||
end
|
||||
end
|
||||
|
||||
---@param battingTeam TeamId
|
||||
---@return SpriteCollection battingTeam, SpriteCollection fieldingTeam, table runnerBlipper
|
||||
function Characters:getSpriteCollections(battingTeam)
|
||||
if battingTeam == "home" then
|
||||
return self.homeSprites, self.awaySprites, self.homeBlipper
|
||||
end
|
||||
return self.awaySprites, self.homeSprites, self.awayBlipper
|
||||
end
|
||||
|
||||
---@param fielding Fielding
|
||||
---@param baserunning Baserunning
|
||||
---@param batState BatRenderState
|
||||
---@param battingTeam TeamId
|
||||
---@param ball Point3d
|
||||
---@return Fielder | nil ballHeldBy
|
||||
function Characters:drawAll(fielding, baserunning, batState, battingTeam, ball)
|
||||
---@type { y: number, drawAction: fun() }[]
|
||||
local characterDraws = {}
|
||||
function addDraw(y, drawAction)
|
||||
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
|
||||
end
|
||||
|
||||
local battingTeamSprites, fieldingTeamSprites, runnerBlipper = self:getSpriteCollections(battingTeam)
|
||||
---@type Fielder | nil
|
||||
local ballHeldBy
|
||||
for _, fielder in pairs(fielding.fielders) do
|
||||
addDraw(fielder.y, function()
|
||||
local ballHeldByThisFielder = drawFielder(fieldingTeamSprites, fielder, ball)
|
||||
if ballHeldByThisFielder then
|
||||
ballHeldBy = fielder
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
for _, runner in pairs(baserunning.runners) do
|
||||
addDraw(runner.y, function()
|
||||
local currentBatter = baserunning.batter
|
||||
if runner == currentBatter then
|
||||
drawBatter(battingTeamSprites, currentBatter, batState)
|
||||
else
|
||||
-- TODO? Change blip speed depending on runner speed?
|
||||
runnerBlipper:draw(false, runner.x, runner.y - C.PlayerHeightOffset, runner)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
for _, runner in pairs(baserunning.outRunners) do
|
||||
addDraw(runner.y, function()
|
||||
battingTeamSprites[runner.spriteIndex].frowning:draw(runner.x, runner.y - C.PlayerHeightOffset)
|
||||
end)
|
||||
end
|
||||
for _, runner in pairs(baserunning.scoredRunners) do
|
||||
addDraw(runner.y, function()
|
||||
runnerBlipper:draw(false, runner.x, runner.y - C.PlayerHeightOffset, runner)
|
||||
end)
|
||||
end
|
||||
|
||||
table.sort(characterDraws, function(a, b)
|
||||
return a.y < b.y
|
||||
end)
|
||||
for _, character in pairs(characterDraws) do
|
||||
character.drawAction()
|
||||
end
|
||||
|
||||
return ballHeldBy
|
||||
end
|
||||
|
||||
-- luacheck: ignore
|
||||
if not playdate or playdate.TEST_MODE then
|
||||
return Characters
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
local gfx <const> = playdate.graphics
|
||||
|
||||
fans = {}
|
||||
|
||||
local FanImages <const> = { DarkSkinFan, LightSkinFan }
|
||||
local FanWidth <const>, FanHeight <const> = FanImages[1]:getSize()
|
||||
local BgWidth <const>, BgHeight <const> = GrassBackground:getSize()
|
||||
|
||||
local AudienceImage1 <const> = gfx.image.new(BgWidth, BgHeight)
|
||||
local AudienceImage2 <const> = gfx.image.new(BgWidth, BgHeight)
|
||||
|
||||
local height = 0
|
||||
while height < BgHeight do
|
||||
local width = 0
|
||||
while width < BgWidth do
|
||||
gfx.pushContext(AudienceImage1)
|
||||
local image = FanImages[math.random(#FanImages)]
|
||||
local jiggle = math.random(5)
|
||||
image:draw(width + jiggle, height)
|
||||
gfx.popContext()
|
||||
|
||||
gfx.pushContext(AudienceImage2)
|
||||
image:draw(width + jiggle + math.random(0, 2), height)
|
||||
gfx.popContext()
|
||||
|
||||
width = width + FanWidth
|
||||
end
|
||||
height = height + FanHeight - 10
|
||||
end
|
||||
local AudienceMovement = gfx.animation.blinker.new(200, 200, true)
|
||||
AudienceMovement:start()
|
||||
|
||||
function fans.draw()
|
||||
local currentImage = AudienceMovement.on and AudienceImage1 or AudienceImage2
|
||||
currentImage:draw(-400, -720)
|
||||
end
|
|
@ -1,28 +0,0 @@
|
|||
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
|
||||
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
|
||||
|
||||
---@param ball Point3d
|
||||
---@param fielderX number
|
||||
---@param fielderY number
|
||||
---@return boolean isHoldingBall
|
||||
local function drawFielderGlove(ball, fielderX, fielderY, flip)
|
||||
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
|
||||
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
|
||||
if distanceFromBall > 20 then
|
||||
Glove:draw(shoulderX, shoulderY, flip)
|
||||
return false
|
||||
else
|
||||
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
---@param playerSprites SpriteCollection
|
||||
---@param ball Point3d
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@return boolean isHoldingBall
|
||||
function drawFielder(playerSprites, ball, x, y, flip)
|
||||
playerSprites.smiling:draw(x, y - 20, flip)
|
||||
return drawFielderGlove(ball, x, y)
|
||||
end
|
|
@ -1,6 +1,7 @@
|
|||
-- selene: allow(shadowing)
|
||||
local gfx = playdate.graphics
|
||||
|
||||
local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")
|
||||
local ScoreFont <const> = FontFullCircle
|
||||
|
||||
local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize()
|
||||
local MinimapPosX, MinimapPosY = C.Screen.W - MinimapSizeX, C.Screen.H - MinimapSizeY
|
||||
|
@ -82,11 +83,10 @@ local ScoreboardHeight <const> = 55
|
|||
local Indicator = "> "
|
||||
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
|
||||
|
||||
---@param teams any
|
||||
---@param battingTeam any
|
||||
---@return string, number, string, number
|
||||
function getIndicators(teams, battingTeam)
|
||||
if teams.home == battingTeam then
|
||||
function getIndicators(battingTeam)
|
||||
if battingTeam == "home" then
|
||||
return Indicator, 0, "", IndicatorWidth
|
||||
end
|
||||
return "", IndicatorWidth, Indicator, 0
|
||||
|
@ -100,11 +100,11 @@ local stats = {
|
|||
battingTeam = nil,
|
||||
}
|
||||
|
||||
function drawScoreboardImpl(x, y, teams)
|
||||
function drawScoreboardImpl(x, y)
|
||||
local homeScore = stats.homeScore
|
||||
local awayScore = stats.awayScore
|
||||
|
||||
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, stats.battingTeam)
|
||||
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(stats.battingTeam)
|
||||
|
||||
local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore)
|
||||
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
|
||||
|
@ -145,17 +145,18 @@ end
|
|||
|
||||
local newStats = stats
|
||||
|
||||
function drawScoreboard(x, y, teams, outs, battingTeam, inning)
|
||||
function drawScoreboard(x, y, statistics, outs, battingTeam, inning)
|
||||
local homeScore, awayScore = utils.totalScores(statistics)
|
||||
if
|
||||
newStats.homeScore ~= teams.home.score
|
||||
or newStats.awayScore ~= teams.away.score
|
||||
newStats.homeScore ~= homeScore
|
||||
or newStats.awayScore ~= awayScore
|
||||
or newStats.outs ~= outs
|
||||
or newStats.inning ~= inning
|
||||
or newStats.battingTeam ~= battingTeam
|
||||
then
|
||||
newStats = {
|
||||
homeScore = teams.home.score,
|
||||
awayScore = teams.away.score,
|
||||
homeScore = homeScore,
|
||||
awayScore = awayScore,
|
||||
outs = outs,
|
||||
inning = inning,
|
||||
battingTeam = battingTeam,
|
||||
|
@ -164,5 +165,5 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning)
|
|||
stats = newStats
|
||||
end)
|
||||
end
|
||||
drawScoreboardImpl(x, y, teams)
|
||||
drawScoreboardImpl(x, y)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
---@class Panner
|
||||
Panner = {}
|
||||
|
||||
local function panCoroutine(ball)
|
||||
local offset = utils.xy(getDrawOffset(ball.x, ball.y))
|
||||
while true do
|
||||
local target, deltaSeconds = coroutine.yield(offset.x, offset.y)
|
||||
if target == nil then
|
||||
offset = utils.xy(getDrawOffset(ball.x, ball.y))
|
||||
else
|
||||
while utils.moveAtSpeed(offset, 200 * deltaSeconds, target, 20) do
|
||||
target, deltaSeconds = coroutine.yield(offset.x, offset.y)
|
||||
end
|
||||
-- -- Pan back to ball
|
||||
-- while utils.moveAtSpeed(offset, 200 * deltaSeconds, ball, 20) do
|
||||
-- target, deltaSeconds = coroutine.yield(offset.x, offset.y)
|
||||
-- end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param ball XyPair
|
||||
function Panner.new(ball)
|
||||
return setmetatable({
|
||||
coroutine = coroutine.create(function()
|
||||
panCoroutine(ball)
|
||||
end),
|
||||
panTarget = nil,
|
||||
}, { __index = Panner })
|
||||
end
|
||||
|
||||
---@param deltaSeconds number
|
||||
---@return number offsetX, number offsetY
|
||||
function Panner:get(deltaSeconds)
|
||||
if self.holdUntil and self.holdUntil() then
|
||||
self:reset()
|
||||
end
|
||||
local _, offsetX, offsetY = coroutine.resume(self.coroutine, self.panTarget, deltaSeconds)
|
||||
return offsetX, offsetY
|
||||
end
|
||||
|
||||
---@param panTarget XyPair
|
||||
---@param holdUntil fun(): boolean
|
||||
function Panner:panTo(panTarget, holdUntil)
|
||||
self.panTarget = panTarget
|
||||
self.holdUntil = holdUntil
|
||||
end
|
||||
|
||||
function Panner:reset()
|
||||
self.holdUntil = nil
|
||||
self.panTarget = nil
|
||||
end
|
|
@ -1,69 +0,0 @@
|
|||
local gfx = playdate.graphics
|
||||
|
||||
---@alias SpriteCollection { smiling: pd_image, lowHat: pd_image, frowning: pd_image, back: pd_image }
|
||||
|
||||
---@param image pd_image
|
||||
---@param drawInverted boolean
|
||||
function maybeDrawInverted(image, x, y, drawInverted)
|
||||
-- TODO: Bring logo up a pixel on the dark player base?
|
||||
local drawMode = gfx.getImageDrawMode()
|
||||
if drawInverted then
|
||||
gfx.setImageDrawMode(gfx.kDrawModeInverted)
|
||||
end
|
||||
image:draw(x, y)
|
||||
gfx.setImageDrawMode(drawMode)
|
||||
end
|
||||
|
||||
--- TODO: Custom names on jerseys?
|
||||
---@return SpriteCollection
|
||||
---@param base pd_image
|
||||
---@param isDark boolean
|
||||
function buildCollection(base, back, logo, isDark)
|
||||
local smiling = gfx.image.new(base:getSize())
|
||||
gfx.lockFocus(smiling)
|
||||
base:draw(0, 0)
|
||||
Hat:draw(6, 0)
|
||||
PlayerSmile:draw(5, 9)
|
||||
maybeDrawInverted(logo, 3, 25, isDark)
|
||||
|
||||
local lowHat = gfx.image.new(base:getSize())
|
||||
gfx.lockFocus(lowHat)
|
||||
base:draw(0, 0)
|
||||
Hat:draw(6, 2)
|
||||
PlayerSmile:draw(5, 9)
|
||||
maybeDrawInverted(logo, 3, 25, isDark)
|
||||
|
||||
local frowning = gfx.image.new(base:getSize())
|
||||
|
||||
gfx.lockFocus(frowning)
|
||||
base:draw(0, 0)
|
||||
maybeDrawInverted(logo, 3, 25, isDark)
|
||||
Hat:draw(6, 0)
|
||||
PlayerFrown:draw(5, 9)
|
||||
|
||||
gfx.unlockFocus()
|
||||
|
||||
return {
|
||||
smiling = smiling,
|
||||
lowHat = lowHat,
|
||||
frowning = frowning,
|
||||
back = back,
|
||||
}
|
||||
end
|
||||
|
||||
---@type SpriteCollection
|
||||
AwayTeamSprites = nil
|
||||
|
||||
---@type SpriteCollection
|
||||
HomeTeamSprites = nil
|
||||
|
||||
function replaceAwayLogo(logo)
|
||||
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, logo, true)
|
||||
end
|
||||
|
||||
function replaceHomeLogo(logo)
|
||||
HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, logo, false)
|
||||
end
|
||||
|
||||
replaceAwayLogo(Logos[1].image)
|
||||
replaceHomeLogo(Logos[2].image)
|
|
@ -0,0 +1,53 @@
|
|||
---@type pd_graphics_lib
|
||||
local gfx <const> = playdate.graphics
|
||||
|
||||
local ThrowMeterHeight <const> = 50
|
||||
local ThrowMeterLingerSec <const> = 1.5
|
||||
|
||||
local flickerTimer = gfx.animation.blinker.new(50, 50, true)
|
||||
flickerTimer:start()
|
||||
|
||||
---@param x number
|
||||
---@param y number
|
||||
function throwMeter:draw(x, y)
|
||||
gfx.setLineWidth(1)
|
||||
gfx.drawRect(x, y, 14, ThrowMeterHeight)
|
||||
if self.lastReadThrow then
|
||||
local ratio = 1
|
||||
if not self.wasPerfect then
|
||||
ratio = (self.lastReadThrow - throwMeter.MinCharge) / (self.idealPower - throwMeter.MinCharge)
|
||||
end
|
||||
local height = ThrowMeterHeight * ratio
|
||||
gfx.fillRect(x + 2, y + ThrowMeterHeight - height, 10, height)
|
||||
-- TODO: Dither or bend if the user throws too hard
|
||||
-- Or maybe dither if it's too soft - bend if it's too hard
|
||||
if self.wasPerfect then
|
||||
PerfectPowerBg:draw(x - 11, y - 9)
|
||||
if flickerTimer.on then
|
||||
PerfectPowerFlickerLeft:draw(x - 11, y - 9)
|
||||
else
|
||||
PerfectPowerFlickerRight:draw(x - 11, y - 9)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function throwMeter:drawNearFielder(fielder)
|
||||
if not fielder and not self.lastThrower then
|
||||
return
|
||||
end
|
||||
if fielder then
|
||||
if fielder ~= self.lastThrower then
|
||||
self.lastReadThrow = nil
|
||||
end
|
||||
self.lastThrower = fielder
|
||||
actionQueue:upsert("throwMeterLinger", 200 + ThrowMeterLingerSec * 1000, function()
|
||||
local dt = 0
|
||||
while dt < ThrowMeterLingerSec do
|
||||
dt = dt + coroutine.yield()
|
||||
end
|
||||
self.lastThrower = nil
|
||||
end)
|
||||
end
|
||||
self:draw(self.lastThrower.x - 25, self.lastThrower.y - 10)
|
||||
end
|
|
@ -0,0 +1,99 @@
|
|||
Transitions = {
|
||||
---@type Scene | nil
|
||||
nextScene = nil,
|
||||
---@type Scene | nil
|
||||
previousScene = nil,
|
||||
}
|
||||
|
||||
local gfx = playdate.graphics
|
||||
|
||||
local previousSceneImage
|
||||
local previousSceneMask
|
||||
local nextSceneImage
|
||||
|
||||
local batImageTable = {}
|
||||
|
||||
local batOffset = 80
|
||||
local degStep = 3
|
||||
|
||||
function loadBatImageTable()
|
||||
for deg = 90 - (degStep * 3), 270 + (degStep * 3), degStep do
|
||||
local img = gfx.image.new(C.Screen.W, C.Screen.H)
|
||||
gfx.pushContext(img)
|
||||
BigBat:drawRotated(C.Center.x, C.Screen.H + batOffset, 90 + deg)
|
||||
gfx.popContext()
|
||||
batImageTable[deg] = img
|
||||
end
|
||||
end
|
||||
loadBatImageTable()
|
||||
|
||||
local function update()
|
||||
local lastAngle
|
||||
local seamAngle = math.rad(270)
|
||||
while seamAngle > math.rad(90) do
|
||||
local deltaSeconds = playdate.getElapsedTime()
|
||||
playdate.resetElapsedTime()
|
||||
-- Setting a max value keeps from leaving unmasked areas
|
||||
seamAngle = seamAngle - math.min(0.1, deltaSeconds * 3)
|
||||
local seamAngleDeg = math.floor(math.deg(seamAngle))
|
||||
seamAngleDeg = seamAngleDeg - (seamAngleDeg % degStep)
|
||||
|
||||
-- Skip re-drawing if no change
|
||||
if lastAngle ~= seamAngleDeg then
|
||||
lastAngle = seamAngleDeg
|
||||
nextSceneImage:draw(0, 0)
|
||||
|
||||
gfx.pushContext(previousSceneMask)
|
||||
gfx.setImageDrawMode(gfx.kDrawModeFillBlack)
|
||||
batImageTable[seamAngleDeg]:draw(0, 0)
|
||||
gfx.popContext()
|
||||
|
||||
previousSceneImage:setMaskImage(previousSceneMask)
|
||||
previousSceneImage:draw(0, 0)
|
||||
batImageTable[seamAngleDeg]:draw(0, 0)
|
||||
end
|
||||
|
||||
coroutine.yield()
|
||||
end
|
||||
playdate.update = function()
|
||||
Transitions.nextScene:update()
|
||||
end
|
||||
end
|
||||
|
||||
---@param nextScene fun() The next playdate.update function
|
||||
function transitionTo(nextScene)
|
||||
if not Transitions.nextScene then
|
||||
error("Expected Transitions to already have nextScene defined! E.g. by calling transitionBetween")
|
||||
end
|
||||
local previousScene = Transitions.nextScene
|
||||
transitionBetween(previousScene, nextScene)
|
||||
end
|
||||
|
||||
---@param scene Scene
|
||||
---@return pd_image
|
||||
local function getSceneRender(scene)
|
||||
local image = gfx.image.new(C.Screen.W, C.Screen.H)
|
||||
gfx.pushContext(image)
|
||||
scene:update()
|
||||
gfx.popContext()
|
||||
return image
|
||||
end
|
||||
|
||||
---@param previousScene Scene Has the current playdate.update function
|
||||
---@param nextScene Scene Has the next playdate.update function
|
||||
function transitionBetween(previousScene, nextScene)
|
||||
playdate.wait(2) -- TODO: There's some sort of timing wack here.
|
||||
playdate.update = update
|
||||
|
||||
previousSceneImage = getSceneRender(previousScene)
|
||||
nextSceneImage = getSceneRender(nextScene)
|
||||
|
||||
previousSceneMask = gfx.image.new(C.Screen.W, C.Screen.H, gfx.kColorWhite)
|
||||
previousSceneImage:setMaskImage(previousSceneMask)
|
||||
|
||||
Transitions.nextScene = nextScene
|
||||
Transitions.previousScene = previousScene
|
||||
|
||||
-- Prevents bad transition calculations due to a long "delta"
|
||||
playdate.resetElapsedTime()
|
||||
end
|
222
src/fielding.lua
|
@ -1,22 +1,25 @@
|
|||
--- @class Glove
|
||||
--- @field z number
|
||||
|
||||
--- @class Fielder
|
||||
--- @field glove Glove
|
||||
--- @field catchEligible boolean
|
||||
--- @class Fielder {
|
||||
--- @field name string
|
||||
--- @field x number
|
||||
--- @field y number
|
||||
--- @field target XyPair | nil
|
||||
--- @field targets XyPair[]
|
||||
--- @field speed number
|
||||
--- @field armStrength number
|
||||
--- @field spriteIndex number
|
||||
|
||||
-- TODO: Run down baserunners in a pickle.
|
||||
---@class Fielders
|
||||
---@field first Fielder
|
||||
---@field second Fielder
|
||||
---@field shortstop Fielder
|
||||
---@field third Fielder
|
||||
---@field pitcher Fielder
|
||||
---@field catcher Fielder
|
||||
---@field left Fielder
|
||||
---@field center Fielder
|
||||
---@field right Fielder
|
||||
|
||||
---@class Fielding
|
||||
---@field fielders table<string, Fielder>
|
||||
---@field fielders Fielders
|
||||
---@field fielderHoldingBall Fielder | nil
|
||||
---@field private onFlyOut fun()
|
||||
Fielding = {}
|
||||
|
||||
FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
|
||||
|
@ -29,15 +32,11 @@ local function newFielder(name, speed)
|
|||
return {
|
||||
name = name,
|
||||
speed = speed * C.FielderRunMult,
|
||||
catchEligible = true,
|
||||
armStrength = 10,
|
||||
glove = {
|
||||
z = C.GloveZ
|
||||
},
|
||||
spriteIndex = math.random(#HomeTeamSpriteGroup),
|
||||
}
|
||||
end
|
||||
|
||||
function Fielding.new(onFlyOut)
|
||||
function Fielding.new()
|
||||
return setmetatable({
|
||||
fielders = {
|
||||
first = newFielder("First", 40),
|
||||
|
@ -50,7 +49,6 @@ function Fielding.new(onFlyOut)
|
|||
center = newFielder("Center", 50),
|
||||
right = newFielder("Right", 50),
|
||||
},
|
||||
onFlyOut = onFlyOut,
|
||||
---@type Fielder | nil
|
||||
fielderHoldingBall = nil,
|
||||
}, { __index = Fielding })
|
||||
|
@ -59,17 +57,18 @@ end
|
|||
--- Actually only benches the infield, because outfielders are far away!
|
||||
---@param position XyPair
|
||||
function Fielding:benchTo(position)
|
||||
self.fielders.first.target = position
|
||||
self.fielders.second.target = position
|
||||
self.fielders.shortstop.target = position
|
||||
self.fielders.third.target = position
|
||||
self.fielders.pitcher.target = position
|
||||
self.fielders.catcher.target = position
|
||||
self.fielders.first.targets = { position }
|
||||
self.fielders.second.targets = { position }
|
||||
self.fielders.shortstop.targets = { position }
|
||||
self.fielders.third.targets = { position }
|
||||
self.fielders.pitcher.targets = { position }
|
||||
self.fielders.catcher.targets = { position }
|
||||
end
|
||||
|
||||
--- 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.
|
||||
function Fielding:resetFielderPositions(fromOffTheField)
|
||||
---@param immediate boolean | nil
|
||||
function Fielding:resetFielderPositions(fromOffTheField, immediate)
|
||||
if fromOffTheField then
|
||||
for _, fielder in pairs(self.fielders) do
|
||||
fielder.x = fromOffTheField.x
|
||||
|
@ -77,123 +76,103 @@ function Fielding:resetFielderPositions(fromOffTheField)
|
|||
end
|
||||
end
|
||||
|
||||
self.fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48)
|
||||
self.fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30)
|
||||
self.fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30)
|
||||
self.fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48)
|
||||
self.fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y)
|
||||
self.fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92)
|
||||
self.fielders.left.target = utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1)
|
||||
self.fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4)
|
||||
self.fielders.right.target = utils.xy(C.Screen.W * 1.6, self.fielders.left.target.y)
|
||||
self.fielders.first.targets = { 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.shortstop.targets = { 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.pitcher.targets = { 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.left.targets = { 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.right.targets = { utils.xy(C.Screen.W * 1.6, self.fielders.left.targets[1].y) }
|
||||
|
||||
if immediate then
|
||||
for _, fielder in pairs(self.fielders) do
|
||||
fielder.x = fielder.targets[1].x
|
||||
fielder.y = fielder.targets[1].y
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param deltaSeconds number
|
||||
---@param fielder Fielder
|
||||
---@param ballPos Point3d
|
||||
---@return boolean inCatchingRange
|
||||
local function updateFielderPosition(deltaSeconds, fielder, ballPos)
|
||||
if fielder.target ~= nil then
|
||||
-- if fielder.name == "Left" then
|
||||
-- printTable({ target = fielder.target })
|
||||
-- end
|
||||
if fielder.target.z then
|
||||
if not utils.moveAtSpeedZ(fielder, fielder.speed * deltaSeconds, fielder.target) then
|
||||
if fielder.name == "Left" then
|
||||
print("CLEAR LEFT'S 3D TARGET")
|
||||
end
|
||||
fielder.target = nil
|
||||
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
|
||||
---@param ball Ball
|
||||
---@return boolean canCatch
|
||||
local function updateFielderPosition(deltaSeconds, fielder, ball)
|
||||
if #fielder.targets > 0 then
|
||||
local nextFielderPos = utils.xy(fielder.x, fielder.y)
|
||||
local currentTarget = fielder.targets[#fielder.targets]
|
||||
local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget)
|
||||
|
||||
if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall, 40) then
|
||||
local targetCount = #fielder.targets
|
||||
-- Back up a little
|
||||
fielder.targets[targetCount + 2] = utils.xy(fielder.x, fielder.y + 5)
|
||||
-- Try to come at it from below
|
||||
fielder.targets[targetCount + 1] = utils.xy(currentTarget.x, fielder.y + 10)
|
||||
end
|
||||
|
||||
--local distance = utils.distanceBetweenZ(fielder.x, fielder.y, C.GloveZ, ballPos.x, ballPos.y, ballPos.z)
|
||||
if ballPos.z > C.GloveZ * 2 then
|
||||
return false
|
||||
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.targets[#fielder.targets]) then
|
||||
table.remove(fielder.targets, #fielder.targets)
|
||||
end
|
||||
local distance = utils.distanceBetween(fielder.x, fielder.y, ballPos.x, ballPos.y)
|
||||
return distance < C.BallCatchHitbox
|
||||
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.
|
||||
--- Other fielders should attempt to cover their bases
|
||||
---@param ballDestX number
|
||||
---@param ballDestY number
|
||||
---@param ball Ball
|
||||
function Fielding:haveSomeoneChase(ballDestX, ballDestY, ball)
|
||||
local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
|
||||
chasingFielder.target = ball
|
||||
-- local timer = playdate.timer.new(1000)
|
||||
-- timer.updateCallback = function()
|
||||
-- printTable(chasingFielder.target)
|
||||
-- end
|
||||
print("chasingFielder: " .. chasingFielder.name)
|
||||
printTable(ball)
|
||||
---@param ball Point3d
|
||||
---@param ballDest XyPair
|
||||
function Fielding:haveSomeoneChase(ball, ballDest)
|
||||
local chasingFielder = utils.getNearestOf(self.fielders, ballDest.x, ballDest.y)
|
||||
-- Start moving toward the ball directly after reaching ballDest
|
||||
chasingFielder.targets = { ball, ballDest }
|
||||
|
||||
for _, base in ipairs(C.Bases) do
|
||||
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
|
||||
-- For now, skipping the pitcher because they're considered closer to 2B than second or shortstop
|
||||
return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher
|
||||
-- Skip the pitcher for 2B - they're considered closer than second or shortstop.
|
||||
if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then
|
||||
return false
|
||||
end
|
||||
return fielder ~= chasingFielder
|
||||
end)
|
||||
nearest.target = base
|
||||
nearest.targets = { base }
|
||||
end
|
||||
end
|
||||
|
||||
--- **Also updates `ball.heldby`**
|
||||
---@param ball Ball
|
||||
---@param deltaSeconds number
|
||||
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
|
||||
---@return Fielder | nil, boolean fielderHoldingBall nil if no fielder is currently touching the ball, true if caught a fly ball
|
||||
function Fielding:updateFielderPositions(ball, deltaSeconds)
|
||||
local fielderHoldingBall
|
||||
local caughtAFlyBall = false
|
||||
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?
|
||||
local canCatch = updateFielderPosition(deltaSeconds, fielder, ball)
|
||||
if canCatch then
|
||||
fielderHoldingBall = fielder
|
||||
if ball.flyBall then
|
||||
self.onFlyOut()
|
||||
ball.flyBall = false
|
||||
end
|
||||
ball:caughtBy(fielder)
|
||||
ball.heldBy = fielder -- How much havoc will this wreak?
|
||||
if ball.isFlyBall then
|
||||
ball.isFlyBall = false
|
||||
caughtAFlyBall = true
|
||||
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
|
||||
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)
|
||||
return fielderHoldingBall, caughtAFlyBall
|
||||
end
|
||||
|
||||
-- TODO? Start moving target fielders close sooner?
|
||||
|
@ -202,17 +181,20 @@ end
|
|||
---@param ball { launch: LaunchBall }
|
||||
---@param throwFlyMs number
|
||||
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
|
||||
while field.fielderHoldingBall == nil do
|
||||
while true do
|
||||
if field.fielderHoldingBall == nil then
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
else
|
||||
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
|
||||
end)
|
||||
|
||||
closestFielder.target = targetBase
|
||||
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, false, throwFlyMs)
|
||||
Fielding.markIneligible(field.fielderHoldingBall)
|
||||
closestFielder.targets = { targetBase }
|
||||
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
|
||||
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Buffer in a fielder throw action.
|
||||
|
|
Before Width: | Height: | Size: 11 KiB |
|
@ -1,15 +1,22 @@
|
|||
--- Assumes that background image is of size
|
||||
local gfx <const> = playdate.graphics
|
||||
|
||||
local ButtonFont <const> = FontFullCircle
|
||||
|
||||
--- Assumes that background image is of size:
|
||||
---
|
||||
--- XXX
|
||||
--- XOX
|
||||
---
|
||||
--- Where each character is the size of the screen, and 'O' is the default view.
|
||||
function getDrawOffset(ballX, ballY)
|
||||
local offsetX, offsetY
|
||||
if ballY > C.Screen.H or ballX >= C.BallOffscreen then
|
||||
return 0, 0
|
||||
end
|
||||
offsetY = math.max(0, -1.4 * ballY)
|
||||
-- Keep the ball approximately in the center, once it's past C.Center.y - 30
|
||||
offsetY = math.max(0, (-1 * ballY) + C.Center.y - 30)
|
||||
|
||||
if ballX > 0 and ballX < C.Screen.W then
|
||||
if ballX >= 0 and ballX <= C.Screen.W then
|
||||
offsetX = 0
|
||||
elseif ballX < 0 then
|
||||
offsetX = math.max(-1 * C.Screen.W, ballX * -1)
|
||||
|
@ -17,7 +24,22 @@ function getDrawOffset(ballX, ballY)
|
|||
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
|
||||
end
|
||||
|
||||
return offsetX * 1.7, offsetY
|
||||
return offsetX * 1.3, offsetY
|
||||
end
|
||||
|
||||
local buttonBlinker = gfx.animation.blinker.new(750, 500, true)
|
||||
buttonBlinker:start()
|
||||
|
||||
--- Requires calling `playdate.graphics.animation.blinker.updateAll()` during `update()` to blink correctly.
|
||||
function drawButton(buttonLabel, x, y)
|
||||
gfx.setColor(gfx.kColorWhite)
|
||||
gfx.fillCircleAtPoint(x + 4, y + 7, 12)
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
if buttonBlinker.on then
|
||||
gfx.setLineWidth(1)
|
||||
gfx.drawCircleAtPoint(x + 4, y + 7, 10)
|
||||
end
|
||||
ButtonFont:drawText(buttonLabel, x, y)
|
||||
end
|
||||
|
||||
---@class Blipper
|
||||
|
@ -26,16 +48,15 @@ blipper = {}
|
|||
|
||||
--- 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.
|
||||
function blipper.new(msInterval, smiling, lowHat)
|
||||
local blinker = playdate.graphics.animation.blinker.new(msInterval, msInterval, true)
|
||||
function blipper.new(msInterval, spriteCollection)
|
||||
local blinker = gfx.animation.blinker.new(msInterval, msInterval, true)
|
||||
blinker:start()
|
||||
return {
|
||||
blinker = blinker,
|
||||
smiling = smiling,
|
||||
lowHat = lowHat,
|
||||
draw = function(self, disableBlipping, x, y)
|
||||
local currentImage = (disableBlipping or self.blinker.on) and self.lowHat or self.smiling
|
||||
local offsetY = currentImage == lowHat and -1 or 0
|
||||
draw = function(self, disableBlipping, x, y, hasSpriteIndex)
|
||||
local spriteBundle = spriteCollection[hasSpriteIndex.spriteIndex]
|
||||
local currentImage = (disableBlipping or self.blinker.on) and spriteBundle.lowHat or spriteBundle.smiling
|
||||
local offsetY = currentImage == spriteBundle.lowHat and -1 or 0
|
||||
currentImage:draw(x, y + offsetY)
|
||||
end,
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 603 B |
|
@ -1,3 +1,7 @@
|
|||
-- stylua: ignore start
|
||||
import "control-screen.lua"
|
||||
-- stylua: ignore end
|
||||
|
||||
---@class MainMenu
|
||||
MainMenu = {
|
||||
---@type { new: fun(settings: Settings): { update: fun(self) } }
|
||||
|
@ -5,30 +9,40 @@ MainMenu = {
|
|||
}
|
||||
local gfx = playdate.graphics
|
||||
|
||||
local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft")
|
||||
local ScoreFont <const> = FontFullCircle
|
||||
local TinyFont <const> = NanoSans
|
||||
|
||||
--- Take control of playdate.update
|
||||
--- Will replace playdate.update when the menu is done.
|
||||
---@param next { new: fun(settings: Settings): { update: fun(self) } }
|
||||
function MainMenu.start(next)
|
||||
MenuMusic:play(0)
|
||||
MainMenu.next = next
|
||||
playdate.update = MainMenu.update
|
||||
playdate.update = function()
|
||||
MainMenu:update()
|
||||
end
|
||||
end
|
||||
|
||||
local inningCountSelection = 3
|
||||
|
||||
function MainMenu:showControls()
|
||||
local next = ControlScreen.new(self)
|
||||
transitionBetween(MainMenu, next)
|
||||
end
|
||||
|
||||
local function startGame()
|
||||
local next = MainMenu.next.new({
|
||||
finalInning = inningCountSelection,
|
||||
homeTeamSprites = HomeTeamSprites,
|
||||
awayTeamSprites = AwayTeamSprites,
|
||||
homeTeamSpriteGroup = HomeTeamSpriteGroup,
|
||||
awayTeamSpriteGroup = AwayTeamSpriteGroup,
|
||||
})
|
||||
playdate.resetElapsedTime()
|
||||
playdate.update = function()
|
||||
next:update()
|
||||
end
|
||||
transitionBetween(MainMenu, next)
|
||||
MenuMusic:setPaused(true)
|
||||
end
|
||||
|
||||
---@param baseEaser EasingFunc
|
||||
---@return EasingFunc
|
||||
local function pausingEaser(baseEaser)
|
||||
--- t: elapsedTime
|
||||
--- d: duration
|
||||
|
@ -53,7 +67,8 @@ local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill)
|
|||
animatorY.repeatCount = -1
|
||||
animatorY.reverses = true
|
||||
|
||||
local crankStartPos = nil
|
||||
---@type number
|
||||
local crankStartPos
|
||||
|
||||
---@generic T
|
||||
---@param array T[]
|
||||
|
@ -64,9 +79,21 @@ local function arrayElementFromCrank(array, crankPosition)
|
|||
return array[i]
|
||||
end
|
||||
|
||||
local currentLogo = nil
|
||||
---@type pd_image
|
||||
local currentLogo
|
||||
|
||||
--luacheck: ignore
|
||||
function MainMenu:update()
|
||||
if playdate.buttonJustPressed(playdate.kButtonA) then
|
||||
startGame()
|
||||
return
|
||||
end
|
||||
|
||||
if playdate.buttonJustPressed(playdate.kButtonB) then
|
||||
self:showControls()
|
||||
return
|
||||
end
|
||||
|
||||
function MainMenu.update()
|
||||
playdate.timer.updateTimers()
|
||||
crankStartPos = crankStartPos or playdate.getCrankPosition()
|
||||
|
||||
|
@ -82,21 +109,25 @@ function MainMenu.update()
|
|||
currentLogo:drawScaled(20, C.Center.y + 40, 3)
|
||||
end
|
||||
|
||||
if playdate.buttonJustPressed(playdate.kButtonA) then
|
||||
startGame()
|
||||
if playdate.buttonJustPressed(playdate.kButtonUp) or playdate.buttonJustPressed(playdate.kButtonRight) then
|
||||
inningCountSelection = math.min(99, inningCountSelection + 1)
|
||||
end
|
||||
startGame()
|
||||
if playdate.buttonJustPressed(playdate.kButtonUp) then
|
||||
inningCountSelection = inningCountSelection + 1
|
||||
end
|
||||
if playdate.buttonJustPressed(playdate.kButtonDown) then
|
||||
inningCountSelection = inningCountSelection - 1
|
||||
if playdate.buttonJustPressed(playdate.kButtonDown) or playdate.buttonJustPressed(playdate.kButtonLeft) then
|
||||
inningCountSelection = math.max(1, inningCountSelection - 1)
|
||||
end
|
||||
|
||||
GameLogo:drawCentered(C.Center.x, 50)
|
||||
local logoCenter = 90
|
||||
GameLogo:drawCentered(C.Center.x, logoCenter)
|
||||
TinyFont:drawTextAligned("a game by Sage", C.Center.x, logoCenter + 35, kTextAlignment.center)
|
||||
|
||||
StartFont:drawTextAligned("Press A to start!", C.Center.x, 140, kTextAlignment.center)
|
||||
gfx.drawTextAligned("with " .. inningCountSelection .. " innings", C.Center.x, 190, kTextAlignment.center)
|
||||
local promptOffsetX = 120
|
||||
ScoreFont:drawTextAligned(
|
||||
"Press A to start with <" .. inningCountSelection .. "> innings",
|
||||
C.Center.x - promptOffsetX,
|
||||
180,
|
||||
kTextAlignment.left
|
||||
)
|
||||
ScoreFont:drawTextAligned("Press B for controls", C.Center.x - promptOffsetX, 198, kTextAlignment.left)
|
||||
|
||||
local ball = {
|
||||
x = animatorX:currentValue(),
|
||||
|
@ -105,10 +136,12 @@ function MainMenu.update()
|
|||
size = 6,
|
||||
}
|
||||
|
||||
local ballIsHeld = drawFielder(AwayTeamSprites, ball, 30, 200)
|
||||
ballIsHeld = drawFielder(HomeTeamSprites, ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld
|
||||
local fielder1 = { x = 30, y = 200, spriteIndex = 1 }
|
||||
local ballIsHeld = drawFielder(AwayTeamSpriteGroup, fielder1, ball)
|
||||
|
||||
local fielder2 = { x = 350, y = 200, spriteIndex = 2 }
|
||||
ballIsHeld = drawFielder(HomeTeamSpriteGroup, fielder2, ball, playdate.graphics.kImageFlippedX) or ballIsHeld
|
||||
|
||||
-- drawFielder(AwayTeamSprites, { x = 0, y = 0, z = 0 }, ball.x, ball.y)
|
||||
if not ballIsHeld then
|
||||
gfx.setLineWidth(2)
|
||||
|
||||
|
|
698
src/main.lua
|
@ -6,52 +6,75 @@ import 'CoreLibs/graphics.lua'
|
|||
import 'CoreLibs/object.lua'
|
||||
import 'CoreLibs/timer.lua'
|
||||
import 'CoreLibs/ui.lua'
|
||||
import 'CoreLibs/utilities/where.lua'
|
||||
-- stylua: ignore end
|
||||
|
||||
--- @alias Scene { update: fun(self: self) }
|
||||
|
||||
--- @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(
|
||||
--- self: self,
|
||||
--- destX: number,
|
||||
--- destY: number,
|
||||
--- easingFunc: EasingFunc,
|
||||
--- freshHit: boolean | nil,
|
||||
--- flyTimeMs: number | nil,
|
||||
--- floaty: boolean | nil,
|
||||
--- customBallScaler: pd_animator | nil,
|
||||
--- )
|
||||
|
||||
-- stylua: ignore start
|
||||
import 'utils.lua'
|
||||
import 'constants.lua'
|
||||
import 'assets.lua'
|
||||
import 'draw/player.lua'
|
||||
import 'draw/overlay.lua'
|
||||
import 'draw/fielder.lua'
|
||||
|
||||
import 'main-menu.lua'
|
||||
|
||||
import 'action-queue.lua'
|
||||
import 'announcer.lua'
|
||||
import 'ball.lua'
|
||||
import 'baserunning.lua'
|
||||
import 'batting.lua'
|
||||
import 'dbg.lua'
|
||||
import 'fielding.lua'
|
||||
import 'graphics.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
|
||||
|
||||
-- TODO: Customizable field structure. E.g. stands and ads etc.
|
||||
|
||||
local gfx <const>, C <const> = playdate.graphics, C
|
||||
---@type pd_graphics_lib
|
||||
local gfx <const> = playdate.graphics
|
||||
|
||||
---@alias Team { score: number, benchPosition: XyPair }
|
||||
local C <const> = C
|
||||
|
||||
---@alias Team { benchPosition: XyPair }
|
||||
---@type table<TeamId, Team>
|
||||
local teams <const> = {
|
||||
home = {
|
||||
score = 0, -- TODO: Extract this last bit of global mutable state.
|
||||
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
|
||||
},
|
||||
away = {
|
||||
score = 0,
|
||||
benchPosition = utils.xy(-10, C.Center.y),
|
||||
},
|
||||
}
|
||||
|
@ -62,36 +85,33 @@ local teams <const> = {
|
|||
---@class Settings
|
||||
---@field finalInning number
|
||||
---@field userTeam TeamId | nil
|
||||
---@field awayTeamSprites SpriteCollection
|
||||
---@field homeTeamSprites SpriteCollection
|
||||
---@field awayTeamSpriteGroup SpriteCollection
|
||||
---@field homeTeamSpriteGroup SpriteCollection
|
||||
|
||||
---@class MutableState
|
||||
---@field deltaSeconds number
|
||||
---@field ball Ball
|
||||
---@field battingTeam TeamId
|
||||
---@field catcherThrownBall boolean
|
||||
---@field pitchIsOver boolean
|
||||
---@field didSwing boolean
|
||||
---@field offenseState OffenseState
|
||||
---@field inning number
|
||||
---@field batBase XyPair
|
||||
---@field batTip XyPair
|
||||
---@field batAngleDeg number
|
||||
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
|
||||
---@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
|
||||
---@field stats Statistics
|
||||
|
||||
--- Ephemeral data ONLY used during rendering
|
||||
---@class RenderState
|
||||
---@field bat BatRenderState
|
||||
|
||||
---@class Game
|
||||
---@field private settings Settings
|
||||
---@field private announcer Announcer
|
||||
---@field private fielding Fielding
|
||||
---@field private baserunning Baserunning
|
||||
---@field private npc Npc
|
||||
---@field private homeTeamBlipper Blipper
|
||||
---@field private awayTeamBlipper Blipper
|
||||
---@field private batting Batting
|
||||
---@field private characters Characters
|
||||
---@field private npc InputHandler
|
||||
---@field private userInput InputHandler
|
||||
---@field private panner Panner
|
||||
---@field private state MutableState
|
||||
Game = {}
|
||||
|
||||
|
@ -99,57 +119,47 @@ Game = {}
|
|||
---@param announcer Announcer | nil
|
||||
---@param fielding Fielding | nil
|
||||
---@param baserunning Baserunning | nil
|
||||
---@param npc Npc | nil
|
||||
---@param npc InputHandler | nil
|
||||
---@param state MutableState | nil
|
||||
---@return Game
|
||||
function Game.new(settings, announcer, fielding, baserunning, npc, state)
|
||||
teams.away.score = 0
|
||||
teams.home.score = 0
|
||||
announcer = announcer or Announcer.new()
|
||||
settings.userTeam = "home" -- "away"
|
||||
fielding = fielding or Fielding.new()
|
||||
settings.userTeam = "away"
|
||||
|
||||
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
|
||||
local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat)
|
||||
local battingTeam = "away"
|
||||
local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper
|
||||
local ball = Ball.new(gfx.animator)
|
||||
|
||||
local o = setmetatable({
|
||||
settings = settings,
|
||||
announcer = announcer,
|
||||
homeTeamBlipper = homeTeamBlipper,
|
||||
awayTeamBlipper = awayTeamBlipper,
|
||||
fielding = fielding,
|
||||
panner = Panner.new(ball),
|
||||
state = state or {
|
||||
batBase = utils.xy(C.Center.x - 34, 215),
|
||||
batTip = utils.xy(0, 0),
|
||||
batAngleDeg = C.CrankOffsetDeg,
|
||||
deltaSeconds = 0,
|
||||
ball = ball,
|
||||
battingTeam = battingTeam,
|
||||
offenseState = C.Offense.batting,
|
||||
inning = 1,
|
||||
catcherThrownBall = true,
|
||||
secondsSinceLastRunnerMove = 0,
|
||||
secondsSincePitchAllowed = 0,
|
||||
battingTeamSprites = settings.awayTeamSprites,
|
||||
fieldingTeamSprites = settings.homeTeamSprites,
|
||||
runnerBlipper = runnerBlipper,
|
||||
pitchIsOver = true,
|
||||
didSwing = false,
|
||||
stats = Statistics.new(),
|
||||
},
|
||||
}, { __index = Game })
|
||||
|
||||
o.fielding = fielding or Fielding.new(function()
|
||||
print("Fly out!")
|
||||
end)
|
||||
o.baserunning = baserunning or Baserunning.new(announcer, function()
|
||||
o:nextHalfInning()
|
||||
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.fielding:resetFielderPositions(teams.home.benchPosition)
|
||||
playdate.timer.new(3500, function()
|
||||
print("Start pitcher with ball")
|
||||
ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, nil, 6)
|
||||
o.fielding:resetFielderPositions(teams.home.benchPosition, settings.userTeam == nil)
|
||||
playdate.timer.new(settings.userTeam == nil and 10 or 2000, function()
|
||||
o:returnToPitcher()
|
||||
end)
|
||||
o.characters = Characters.new(settings.homeTeamSpriteGroup, settings.awayTeamSpriteGroup)
|
||||
|
||||
BootTune:play()
|
||||
BootTune:setFinishCallback(function()
|
||||
|
@ -159,46 +169,6 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
|
|||
return o
|
||||
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
|
||||
---@return TeamId
|
||||
local function getOppositeTeamId(teamId)
|
||||
|
@ -211,10 +181,6 @@ local function getOppositeTeamId(teamId)
|
|||
end
|
||||
end
|
||||
|
||||
function Game:getBattingTeam()
|
||||
return teams[self.state.battingTeam] or error("Unknown battingTeam: " .. (self.state.battingTeam or "nil"))
|
||||
end
|
||||
|
||||
function Game:getFieldingTeam()
|
||||
return teams[getOppositeTeamId(self.state.battingTeam)]
|
||||
end
|
||||
|
@ -235,73 +201,114 @@ function Game:userIsOn(side)
|
|||
return ret, not ret
|
||||
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 pitchTypeIndex number | nil
|
||||
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
|
||||
Fielding.markIneligible(self.fielding.fielders.pitcher)
|
||||
self.state.ball:launch(C.PitchStartX, C.PitchEndY, nil, false, nil, 2000 / pitchFlyTimeMs)
|
||||
---@param accuracy number The closer to 1.0, the better
|
||||
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
|
||||
if pitchTypeIndex == nil then
|
||||
return -- No throw!
|
||||
end
|
||||
self.state.ball:markUncatchable()
|
||||
self.state.ball.heldBy = nil
|
||||
self.state.catcherThrownBall = false
|
||||
self.state.pitchIsOver = false
|
||||
self.state.offenseState = C.Offense.batting
|
||||
|
||||
-- local current = Pitches[pitchTypeIndex](self.state.ball)
|
||||
-- self.state.ball.xAnimator = current.x
|
||||
-- self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
|
||||
local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
|
||||
self.state.ball.xAnimator = current.x
|
||||
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.
|
||||
-- -- if current.z then
|
||||
-- -- ball.zAnimator = current.z
|
||||
-- -- 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()
|
||||
-- TODO: This would need to be sanely replaced in ball:launch() etc.
|
||||
-- if current.z then
|
||||
-- ball.floatAnimator = current.z
|
||||
-- ball.floatAnimator:reset()
|
||||
-- end
|
||||
|
||||
self.state.secondsSincePitchAllowed = 0
|
||||
if pitchFlyTimeMs then
|
||||
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
|
||||
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
|
||||
else
|
||||
self.state.ball.xAnimator:reset()
|
||||
self.state.ball.yAnimator:reset()
|
||||
end
|
||||
|
||||
pitchTracker.secondsSinceLastPitch = 0
|
||||
end
|
||||
|
||||
function Game:pitcherIsOnTheMound()
|
||||
return utils.distanceBetweenPoints(self.fielding.fielders.pitcher, C.PitcherStartPos) < C.BaseHitbox
|
||||
end
|
||||
|
||||
function Game:pitcherIsReady()
|
||||
local pitcher = self.fielding.fielders.pitcher
|
||||
return self:pitcherIsOnTheMound()
|
||||
and (
|
||||
self.state.ball.heldBy == pitcher
|
||||
or utils.distanceBetweenPoints(pitcher, self.state.ball) < C.BallCatchHitbox
|
||||
or utils.distanceBetweenPoints(self.state.ball, C.PitchStart) < 2
|
||||
)
|
||||
end
|
||||
|
||||
function Game:checkForGameOver()
|
||||
local state = self.state
|
||||
if state.stats:gameIsOver(state.inning, self.settings.finalInning, state.battingTeam) then
|
||||
self.announcer:say("THAT'S THE BALL GAME!")
|
||||
playdate.timer.new(3000, function()
|
||||
transitionTo(BoxScore.new(self.state.stats))
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
function Game:nextHalfInning()
|
||||
pitchTracker:reset()
|
||||
local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
|
||||
if not gameOver then
|
||||
Fielding.celebrate()
|
||||
self.state.secondsSinceLastRunnerMove = -7
|
||||
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
|
||||
self.announcer:say("SWITCHING SIDES...")
|
||||
|
||||
if self:checkForGameOver() then
|
||||
return
|
||||
end
|
||||
|
||||
if gameOver then
|
||||
self.announcer:say("AND THAT'S THE BALL GAME!")
|
||||
else
|
||||
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
|
||||
self.announcer:say("SWITCHING SIDES...")
|
||||
|
||||
self.fielding:resetFielderPositions()
|
||||
if self.state.battingTeam == teams.home then
|
||||
if self.state.battingTeam == "home" then
|
||||
self.state.inning = self.state.inning + 1
|
||||
self.state.stats:pushInning()
|
||||
end
|
||||
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
|
||||
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
|
||||
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
|
||||
|
||||
---@param scoredRunCount number
|
||||
function Game:score(scoredRunCount)
|
||||
local batting = self:getBattingTeam()
|
||||
batting.score = batting.score + scoredRunCount
|
||||
local battingTeamStats = self:battingTeamCurrentInning()
|
||||
battingTeamStats.score = battingTeamStats.score + scoredRunCount
|
||||
self.announcer:say("SCORE!")
|
||||
self:checkForGameOver()
|
||||
end
|
||||
|
||||
---@param throwFlyMs number
|
||||
|
@ -309,26 +316,26 @@ end
|
|||
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
|
||||
local targetBase
|
||||
if playdate.buttonIsPressed(playdate.kButtonLeft) then
|
||||
targetBase = C.Bases[C.Third]
|
||||
targetBase = C.Third
|
||||
elseif playdate.buttonIsPressed(playdate.kButtonUp) then
|
||||
targetBase = C.Bases[C.Second]
|
||||
targetBase = C.Second
|
||||
elseif playdate.buttonIsPressed(playdate.kButtonRight) then
|
||||
targetBase = C.Bases[C.First]
|
||||
targetBase = C.First
|
||||
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
|
||||
targetBase = C.Bases[C.Home]
|
||||
targetBase = C.Home
|
||||
else
|
||||
return false
|
||||
end
|
||||
|
||||
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
|
||||
self.state.secondsSinceLastRunnerMove = 0
|
||||
self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs)
|
||||
self.baserunning.secondsSinceLastRunnerMove = 0
|
||||
self.state.offenseState = C.Offense.running
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function Game:nextBatter()
|
||||
self.state.secondsSincePitchAllowed = -3
|
||||
pitchTracker.secondsSinceLastPitch = -3
|
||||
self.baserunning.batter = nil
|
||||
playdate.timer.new(2000, function()
|
||||
pitchTracker:reset()
|
||||
|
@ -352,200 +359,210 @@ end
|
|||
function Game:strikeOut()
|
||||
local outBatter = self.baserunning.batter
|
||||
self.baserunning.batter = nil
|
||||
self.baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
|
||||
self.baserunning:outRunner(outBatter, "Strike out!")
|
||||
self:nextBatter()
|
||||
end
|
||||
|
||||
---@param batDeg number
|
||||
function Game:updateBatting(batDeg, batSpeed)
|
||||
local batAngle = math.rad(batDeg)
|
||||
-- TODO: animate bat-flip or something
|
||||
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
|
||||
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))
|
||||
function Game:saveToFile()
|
||||
playdate.datastore.write({ currentGame = self.state }, "data", true)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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!
|
||||
-- TODO: animate bat-flip or something
|
||||
local isFlyBall = math.random() > 0.5
|
||||
self:saveToFile()
|
||||
BatCrackReverb:play()
|
||||
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()
|
||||
print("Hit ball!")
|
||||
self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, true, 3000, batSpeed / 3)
|
||||
local flyTimeMs = 8000
|
||||
-- TODO? A dramatic eye-level view on a home-run could be sick.
|
||||
local battingTeamStats = self:battingTeamCurrentInning()
|
||||
battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest
|
||||
|
||||
if utils.isFoulBall(ballDestX, ballDestY) then
|
||||
if utils.isFoulBall(ballDest) then
|
||||
self.announcer:say("Foul ball!")
|
||||
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
|
||||
-- TODO: Have a fielder chase for the fly-out
|
||||
return
|
||||
end
|
||||
|
||||
self.baserunning:convertBatterToRunner()
|
||||
local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall)
|
||||
|
||||
self.fielding:haveSomeoneChase(ballDestX, ballDestY, self.state.ball)
|
||||
if isPastOutfieldWall then
|
||||
if not isFlyBall then
|
||||
-- Grounder at the wall!
|
||||
ballDest.y = nearbyPointAbove.y - 8
|
||||
else
|
||||
-- Home run!
|
||||
playdate.timer.new(flyTimeMs, function()
|
||||
-- Verify that the home run wasn't intercepted
|
||||
if utils.distanceBetweenPoints(ball, ballDest) < 2 then
|
||||
self.announcer:say("HOME RUN!")
|
||||
self.state.offenseState = C.Offense.homeRun
|
||||
-- Linger on the home-run ball for a moment, before panning to the bases.
|
||||
playdate.timer.new(1000, function()
|
||||
self.panner:panTo(utils.xy(0, 0), function()
|
||||
return self:pitcherIsReady()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local ballHeightAnimator = isFlyBall
|
||||
and gfx.animator.new(flyTimeMs, C.GloveZ, 10 + (2 * mult * mult * 0.5), utils.hitEasingHill)
|
||||
or gfx.animator.new(flyTimeMs, 2 * (mult * mult), 0, utils.createBouncer(4))
|
||||
|
||||
ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, ballHeightAnimator, true)
|
||||
|
||||
self.baserunning:convertBatterToRunner()
|
||||
self.fielding:haveSomeoneChase(ball, ballDest)
|
||||
end
|
||||
|
||||
---@param appliedSpeed number | fun(runner: Runner): number
|
||||
---@return boolean someRunnerMoved
|
||||
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
|
||||
local runnerMoved, runnersScored =
|
||||
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
|
||||
---@param forcedOnly boolean
|
||||
---@param isAutoRun boolean
|
||||
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
|
||||
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun)
|
||||
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
|
||||
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun, self.state.deltaSeconds)
|
||||
if runnersScored ~= 0 then
|
||||
self:score(runnersScored)
|
||||
end
|
||||
return runnerMoved
|
||||
return runnersStillMoving, secondsSinceLastRunnerMove
|
||||
end
|
||||
|
||||
---@param throwFly number
|
||||
function Game:userPitch(throwFly)
|
||||
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
|
||||
local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
|
||||
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)
|
||||
function Game:returnToPitcher()
|
||||
self.fielding:resetFielderPositions()
|
||||
|
||||
if self:pitcherIsReady() then
|
||||
return -- Don't then!
|
||||
end
|
||||
|
||||
actionQueue:newOnly("returnToPitcher", 60 * 1000, function()
|
||||
while not self:pitcherIsOnTheMound() do
|
||||
coroutine.yield()
|
||||
end
|
||||
if not self.baserunning.batter then
|
||||
self.baserunning:pushNewBatter()
|
||||
end
|
||||
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
|
||||
end)
|
||||
end
|
||||
|
||||
function Game:updateGameState()
|
||||
self.state.deltaSeconds = playdate.getElapsedTime() or 0
|
||||
playdate.resetElapsedTime()
|
||||
local crankChange = playdate.getCrankChange() --[[@as number]]
|
||||
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
|
||||
if crankChange < 0 then
|
||||
crankLimited = crankLimited * -1
|
||||
---@param defenseHandler InputHandler
|
||||
function Game:updatePitching(defenseHandler)
|
||||
pitchTracker:recordIfPassed(self.state.ball)
|
||||
|
||||
if self:pitcherIsOnTheMound() then
|
||||
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
|
||||
end
|
||||
|
||||
self.state.ball:updatePosition(self.state.deltaSeconds)
|
||||
|
||||
local userOnOffense, userOnDefense = self:userIsOn("offense")
|
||||
|
||||
if userOnDefense then
|
||||
throwMeter:applyCharge(self.state.deltaSeconds, crankLimited)
|
||||
end
|
||||
|
||||
if self.state.offenseState == C.Offense.batting then
|
||||
if self.state.ball.y < C.StrikeZoneStartY then
|
||||
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 pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
|
||||
self:saveToFile()
|
||||
local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
|
||||
if outcome == PitchOutcomes.StrikeOut then
|
||||
self:strikeOut()
|
||||
elseif outcome == PitchOutcomes.Walk then
|
||||
self:walk()
|
||||
end
|
||||
-- Catcher has the ball. Throw it back to the pitcher
|
||||
print("Catcher return ball to pitcher")
|
||||
self.state.ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, 20)
|
||||
self.fielding:markAllIneligible()
|
||||
self.state.catcherThrownBall = true
|
||||
self:returnToPitcher()
|
||||
self.state.pitchIsOver = true
|
||||
self.state.didSwing = false
|
||||
end
|
||||
|
||||
local batSpeed
|
||||
if userOnOffense then
|
||||
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
|
||||
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
|
||||
self:pitch(defenseHandler:pitch())
|
||||
end
|
||||
end
|
||||
|
||||
function Game:updateGameState()
|
||||
playdate.timer.updateTimers()
|
||||
gfx.animation.blinker.updateAll()
|
||||
self.state.deltaSeconds = playdate.getElapsedTime() or 0
|
||||
playdate.resetElapsedTime()
|
||||
self.state.ball:updatePosition()
|
||||
|
||||
local fielderHoldingBall, caughtAFlyBall =
|
||||
self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
|
||||
if caughtAFlyBall then
|
||||
local fliedOut = self.baserunning:getNewestRunner()
|
||||
self.baserunning:outRunner(fliedOut, "Fly out!")
|
||||
self.state.offenseState = C.Offense.fliedOut
|
||||
self.baserunning:pushNewBatter()
|
||||
pitchTracker.secondsSinceLastPitch = -1
|
||||
end
|
||||
|
||||
self:updateBatting(self.state.batAngleDeg, batSpeed)
|
||||
local offenseHandler, defenseHandler = self:currentInputHandlers()
|
||||
|
||||
if self.state.offenseState == C.Offense.batting then
|
||||
self:updatePitching(defenseHandler)
|
||||
self:updateBatting(offenseHandler)
|
||||
|
||||
-- Walk batter to the plate
|
||||
-- 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
|
||||
self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds)
|
||||
elseif self.state.offenseState == C.Offense.running then
|
||||
local appliedSpeed = userOnOffense and crankLimited
|
||||
or function(runner)
|
||||
return self.npc:runningSpeed(runner, self.state.ball)
|
||||
local appliedSpeed = function(runner)
|
||||
return offenseHandler:runningSpeed(runner, self.state.ball)
|
||||
end
|
||||
if self:updateNonBatterRunners(appliedSpeed) then
|
||||
self.state.secondsSinceLastRunnerMove = 0
|
||||
else
|
||||
self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds
|
||||
if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
|
||||
|
||||
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false)
|
||||
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
|
||||
-- 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
|
||||
-- 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
|
||||
self:returnToPitcher()
|
||||
end
|
||||
|
||||
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
|
||||
local outedSomeRunner = false
|
||||
if fielderHoldingBall then
|
||||
local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
|
||||
if not userOnDefense and self.state.offenseState == C.Offense.running then
|
||||
self.npc:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
|
||||
outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
|
||||
end
|
||||
defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
|
||||
elseif self.state.offenseState == C.Offense.walking then
|
||||
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then
|
||||
self.state.offenseState = C.Offense.batting
|
||||
end
|
||||
elseif self.state.offenseState == C.Offense.homeRun then
|
||||
self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false, true)
|
||||
if #self.baserunning.runners == 0 then
|
||||
-- Give the player a moment to enjoy their home run.
|
||||
playdate.timer.new(1500, function()
|
||||
self:returnToPitcher()
|
||||
actionQueue:upsert("waitForPitcherToHaveBall", 10000, function()
|
||||
while not self:pitcherIsReady() do
|
||||
coroutine.yield()
|
||||
end
|
||||
self.state.offenseState = C.Offense.batting
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -553,90 +570,36 @@ function Game:updateGameState()
|
|||
actionQueue:runWaiting(self.state.deltaSeconds)
|
||||
end
|
||||
|
||||
-- TODO: Swappable update() for main menu, etc.
|
||||
|
||||
function Game:update()
|
||||
playdate.timer.updateTimers()
|
||||
gfx.animation.blinker.updateAll()
|
||||
self:updateGameState()
|
||||
local ball = self.state.ball
|
||||
|
||||
gfx.clear()
|
||||
gfx.setColor(gfx.kColorBlack)
|
||||
|
||||
local ballY = ball.y - (ball.z * 0.2)
|
||||
local offsetX, offsetY = getDrawOffset(ball.x, ballY)
|
||||
local state = self.state
|
||||
local offsetX, offsetY = self.panner:get(state.deltaSeconds)
|
||||
gfx.setDrawOffset(offsetX, offsetY)
|
||||
|
||||
GrassBackground:draw(-400, -240)
|
||||
fans.draw()
|
||||
GrassBackground:draw(-400, -720)
|
||||
|
||||
---@type { y: number, drawAction: fun() }[]
|
||||
local characterDraws = {}
|
||||
function addDraw(y, drawAction)
|
||||
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
|
||||
local ballHeldBy =
|
||||
self.characters:drawAll(self.fielding, self.baserunning, self.batting.state, state.battingTeam, state.ball)
|
||||
|
||||
if self:userIsOn("defense") then
|
||||
throwMeter:drawNearFielder(ballHeldBy)
|
||||
end
|
||||
|
||||
local danceOffset = FielderDanceAnimator:currentValue()
|
||||
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)
|
||||
if not ballHeldBy then
|
||||
state.ball:draw()
|
||||
end
|
||||
|
||||
gfx.setDrawOffset(0, 0)
|
||||
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
|
||||
if math.abs(offsetX) > 10 or math.abs(offsetY) > 20 then
|
||||
drawMinimap(self.baserunning.runners, self.fielding.fielders)
|
||||
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)
|
||||
self.announcer:draw(C.Center.x, 10)
|
||||
|
||||
|
@ -646,25 +609,14 @@ function Game:update()
|
|||
end
|
||||
end
|
||||
|
||||
-- luacheck: ignore
|
||||
if not playdate or playdate.TEST_MODE then
|
||||
return Game
|
||||
end
|
||||
|
||||
playdate.display.setRefreshRate(50)
|
||||
gfx.setBackgroundColor(gfx.kColorWhite)
|
||||
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
|
||||
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
|
||||
|
||||
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
|
||||
|
|
76
src/npc.lua
|
@ -1,10 +1,11 @@
|
|||
local npcBatDeg = 0
|
||||
local BaseNpcBatSpeed <const> = 1500
|
||||
local npcBatSpeed = 1500
|
||||
local BaseNpcBatSpeed <const> = 1000
|
||||
local npcBatSpeed = BaseNpcBatSpeed
|
||||
|
||||
---@class Npc
|
||||
---@class Npc: InputHandler
|
||||
---@field runners Runner[]
|
||||
---@field fielders Fielder[]
|
||||
-- selene: allow(unscoped_variables)
|
||||
Npc = {}
|
||||
|
||||
---@param runners Runner[]
|
||||
|
@ -17,27 +18,42 @@ function Npc.new(runners, fielders)
|
|||
}, { __index = Npc })
|
||||
end
|
||||
|
||||
function Npc.update() end
|
||||
|
||||
-- TODO: FAR more nuanced NPC batting.
|
||||
-- luacheck: no unused
|
||||
---@param ball XyPair
|
||||
---@param catcherThrownBall boolean
|
||||
---@param pitchIsOver boolean
|
||||
---@param deltaSec number
|
||||
---@return number
|
||||
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec) -- luacheck: no unused args
|
||||
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
|
||||
---@return number batAngleDeg, number batSpeed
|
||||
function Npc:updateBatAngle(ball, pitchIsOver, deltaSec)
|
||||
if
|
||||
not pitchIsOver
|
||||
and ball.y > 200
|
||||
and ball.y < 230
|
||||
and (ball.x < C.Center.x + 15)
|
||||
and (ball.x > C.Center.x - 12)
|
||||
then
|
||||
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
|
||||
else
|
||||
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
|
||||
npcBatDeg = 230
|
||||
npcBatDeg = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360)
|
||||
end
|
||||
return npcBatDeg
|
||||
return npcBatDeg, (self:batSpeed() * deltaSec)
|
||||
end
|
||||
|
||||
function Npc:batSpeed() -- luacheck: no unused args
|
||||
return npcBatSpeed / 1.5
|
||||
---@return number
|
||||
function Npc:batSpeed()
|
||||
return npcBatSpeed * 1.25
|
||||
end
|
||||
|
||||
---@return number flyTimeMs, number pitchId, number accuracy
|
||||
function Npc:pitch()
|
||||
return C.PitchFlyMs / self:pitchSpeed(), math.random(#Pitches), 0.9
|
||||
end
|
||||
|
||||
local baseRunningSpeed = 25
|
||||
|
||||
--- TODO: Individual runner control.
|
||||
---@param runner Runner
|
||||
---@param ball Point3d
|
||||
---@return number
|
||||
|
@ -121,21 +137,16 @@ end
|
|||
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
|
||||
local function tryToMakeAPlay(fielders, fielder, runners, ball)
|
||||
local targetX, targetY = getNextOutTarget(runners)
|
||||
printTable({ targetX = targetX, targetY = targetY })
|
||||
if targetX ~= nil and targetY ~= nil then
|
||||
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
|
||||
return grabCandidate.catchEligible
|
||||
end)
|
||||
nearestFielder.target = utils.xy(targetX, targetY)
|
||||
if targetX == nil or targetY == nil then
|
||||
return
|
||||
end
|
||||
|
||||
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
|
||||
nearestFielder.targets = { utils.xy(targetX, targetY) }
|
||||
if nearestFielder == fielder then
|
||||
ball.heldBy = fielder
|
||||
else
|
||||
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
|
||||
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -143,18 +154,21 @@ end
|
|||
---@param outedSomeRunner boolean
|
||||
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
|
||||
function Npc:fielderAction(fielder, outedSomeRunner, ball)
|
||||
if outedSomeRunner then
|
||||
-- Delay a little before the next play
|
||||
playdate.timer.new(750, function()
|
||||
if not fielder then
|
||||
return
|
||||
end
|
||||
local playDelay = outedSomeRunner and 0.5 or 0.1
|
||||
actionQueue:newOnly("npcFielderAction", 2000, function()
|
||||
local dt = 0
|
||||
while dt < playDelay do
|
||||
dt = dt + coroutine.yield()
|
||||
end
|
||||
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
|
||||
end)
|
||||
else
|
||||
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
|
||||
end
|
||||
end
|
||||
|
||||
---@return number
|
||||
function Npc:pitchSpeed() -- luacheck: no unused args
|
||||
function Npc:pitchSpeed()
|
||||
return 2
|
||||
end
|
||||
|
||||
|
|
|
@ -2,6 +2,6 @@ name=Batter Up!
|
|||
author=Sage Vaillancourt
|
||||
description=Crush dingers and hustle around the bases!
|
||||
bundleID=space.sagev.batterup
|
||||
imagePath=images/launcher
|
||||
imagePath=assets/images/launcher
|
||||
version=0.1
|
||||
buildNumber=1
|
||||
|
|
|
@ -0,0 +1,222 @@
|
|||
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
|
||||
---@alias Pitch fun(accuracy: number, ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
|
||||
|
||||
---@type pd_graphics_lib
|
||||
local gfx <const> = playdate.graphics
|
||||
|
||||
local StrikeZoneWidth <const> = C.StrikeZoneEndX - C.StrikeZoneStartX
|
||||
|
||||
-- TODO? Also degrade speed
|
||||
---@param accuracy number
|
||||
---@return number xValueToMissBy
|
||||
function getPitchMissBy(accuracy)
|
||||
accuracy = accuracy or 1.0
|
||||
local missBy = (1 - accuracy) * StrikeZoneWidth * 3
|
||||
if math.random() > 0.5 then
|
||||
missBy = missBy * -1
|
||||
end
|
||||
return missBy
|
||||
end
|
||||
|
||||
---@type Pitch[]
|
||||
Pitches = {
|
||||
-- Fastball
|
||||
function(accuracy)
|
||||
return {
|
||||
x = gfx.animator.new(
|
||||
0,
|
||||
C.PitchStart.x,
|
||||
getPitchMissBy(accuracy) + C.PitchStart.x,
|
||||
playdate.easingFunctions.linear
|
||||
),
|
||||
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
|
||||
}
|
||||
end,
|
||||
-- Curve ball
|
||||
function(accuracy)
|
||||
return {
|
||||
x = gfx.animator.new(
|
||||
C.PitchFlyMs,
|
||||
getPitchMissBy(accuracy) + C.PitchStart.x + 20,
|
||||
C.PitchStart.x,
|
||||
utils.easingHill
|
||||
),
|
||||
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
|
||||
}
|
||||
end,
|
||||
-- Slider
|
||||
function(accuracy)
|
||||
return {
|
||||
x = gfx.animator.new(
|
||||
C.PitchFlyMs,
|
||||
getPitchMissBy(accuracy) + C.PitchStart.x - 20,
|
||||
C.PitchStart.x,
|
||||
utils.easingHill
|
||||
),
|
||||
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
|
||||
}
|
||||
end,
|
||||
-- Wobbleball
|
||||
function(accuracy, ball)
|
||||
local missBy = getPitchMissBy(accuracy)
|
||||
return {
|
||||
x = {
|
||||
currentValue = function()
|
||||
return missBy
|
||||
+ C.PitchStart.x
|
||||
+ (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10))
|
||||
end,
|
||||
reset = function() end,
|
||||
},
|
||||
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
---@alias PitchOutcome "StrikeOut" | "Walk"
|
||||
|
||||
---@type table<string, PitchOutcome>
|
||||
PitchOutcomes = {
|
||||
StrikeOut = "StrikeOut",
|
||||
Walk = "Walk",
|
||||
}
|
||||
|
||||
pitchTracker = {
|
||||
--- Position of the pitch, or nil, if one has not been recorded.
|
||||
---@type number | nil
|
||||
recordedPitchX = nil,
|
||||
|
||||
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
|
||||
secondsSinceLastPitch = 0,
|
||||
|
||||
strikes = 0,
|
||||
balls = 0,
|
||||
}
|
||||
|
||||
function pitchTracker:reset()
|
||||
self.strikes = 0
|
||||
self.balls = 0
|
||||
end
|
||||
|
||||
---@param ball XyPair
|
||||
function pitchTracker:recordIfPassed(ball)
|
||||
if ball.y < C.StrikeZoneStartY then
|
||||
self.recordedPitchX = nil
|
||||
elseif not self.recordedPitchX then
|
||||
self.recordedPitchX = ball.x
|
||||
end
|
||||
end
|
||||
|
||||
---@param didSwing boolean
|
||||
---@param fieldingTeamInningData TeamInningData
|
||||
---@return PitchOutcome | nil
|
||||
function pitchTracker:updatePitchCounts(didSwing, fieldingTeamInningData)
|
||||
if not self.recordedPitchX then
|
||||
return
|
||||
end
|
||||
|
||||
local currentPitchingStats = fieldingTeamInningData.pitching
|
||||
|
||||
if didSwing or self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
|
||||
self.strikes = self.strikes + 1
|
||||
currentPitchingStats.strikes = currentPitchingStats.strikes + 1
|
||||
if self.strikes >= 3 then
|
||||
self:reset()
|
||||
return PitchOutcomes.StrikeOut
|
||||
end
|
||||
else
|
||||
self.balls = self.balls + 1
|
||||
currentPitchingStats.balls = currentPitchingStats.balls + 1
|
||||
if self.balls >= 4 then
|
||||
self:reset()
|
||||
return PitchOutcomes.Walk
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-----------------
|
||||
-- Throw Meter --
|
||||
-----------------
|
||||
|
||||
throwMeter = {
|
||||
MinCharge = 25,
|
||||
|
||||
idealPower = 50,
|
||||
|
||||
--- Used at draw-time only.
|
||||
---@type number
|
||||
lastReadThrow = nil,
|
||||
|
||||
--- Used at draw-time only.
|
||||
---@type Fielder | nil
|
||||
lastThrower = nil,
|
||||
|
||||
--- Used at draw-time only.
|
||||
---@type boolean
|
||||
wasPerfect = false,
|
||||
}
|
||||
|
||||
local MaxPowerRatio <const> = 1.5
|
||||
|
||||
--- Returns nil when a throw is NOT requested.
|
||||
---@param chargeAmount number
|
||||
---@return number | nil powerRatio, number | nil accuracy, boolean isPerfect
|
||||
function throwMeter:readThrow(chargeAmount)
|
||||
local power = self:readCharge(chargeAmount)
|
||||
if not power then
|
||||
return nil, nil, false
|
||||
end
|
||||
|
||||
local ratio = math.min(power / self.idealPower, MaxPowerRatio)
|
||||
self.wasPerfect = math.abs(ratio - 1) < 0.05
|
||||
|
||||
local accuracy = 1
|
||||
-- Only throw off accuracy on slow throws
|
||||
if ratio >= 1 and not self.wasPerfect then
|
||||
accuracy = 1 / ratio
|
||||
end
|
||||
|
||||
return ratio * 1.5, accuracy, self.wasPerfect
|
||||
end
|
||||
|
||||
local CrankRecordSec <const> = 0.33
|
||||
|
||||
---@alias CrankQueueEntry { time: number, chargeAmount: number }
|
||||
|
||||
---@type CrankQueueEntry[]
|
||||
local crankQueue = {}
|
||||
|
||||
--- If (within approx. a third of a second) the crank has moved more than 45 degrees, call that a throw.
|
||||
---@param chargeAmount number
|
||||
---@return number | nil
|
||||
function throwMeter:readCharge(chargeAmount)
|
||||
if chargeAmount == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
|
||||
local minTimeHasPassed = false
|
||||
while #crankQueue ~= 0 and (currentTimeMs - crankQueue[1].time) > CrankRecordSec do
|
||||
table.remove(crankQueue, 1)
|
||||
minTimeHasPassed = true
|
||||
end
|
||||
|
||||
crankQueue[#crankQueue + 1] = { time = currentTimeMs, chargeAmount = math.abs(chargeAmount) }
|
||||
|
||||
if not minTimeHasPassed then
|
||||
return nil
|
||||
end
|
||||
|
||||
local currentCharge = 0
|
||||
for _, v in ipairs(crankQueue) do
|
||||
currentCharge = currentCharge + v.chargeAmount
|
||||
end
|
||||
|
||||
if currentCharge > throwMeter.MinCharge then
|
||||
self.lastReadThrow = currentCharge
|
||||
crankQueue = {}
|
||||
return currentCharge
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
|
@ -0,0 +1,61 @@
|
|||
-- TODO? Some other stats
|
||||
-- * Scroll left and right through games that go into extra innings
|
||||
-- * Scroll up and down through other stats.
|
||||
-- + Balls and strikes
|
||||
-- + Batting average
|
||||
-- + Farthest hit ball
|
||||
|
||||
---@return TeamInningData
|
||||
local function newTeamInning()
|
||||
return {
|
||||
score = 0,
|
||||
pitching = {
|
||||
balls = 0,
|
||||
strikes = 0,
|
||||
},
|
||||
hits = {},
|
||||
}
|
||||
end
|
||||
|
||||
---@return table<TeamId, TeamInningData>
|
||||
local function newInning()
|
||||
return {
|
||||
home = newTeamInning(),
|
||||
away = newTeamInning(),
|
||||
}
|
||||
end
|
||||
|
||||
---@alias TeamInningData { score: number, pitching: { balls: number, strikes: number }, hits: XyPair[] }
|
||||
|
||||
--- E.g. statistics[1].home.pitching.balls
|
||||
---@class Statistics
|
||||
---@field innings (table<TeamId, TeamInningData>)[]
|
||||
Statistics = {}
|
||||
|
||||
---@return Statistics
|
||||
function Statistics.new()
|
||||
return setmetatable({
|
||||
innings = { newInning() },
|
||||
}, { __index = Statistics })
|
||||
end
|
||||
|
||||
function Statistics:pushInning()
|
||||
self.innings[#self.innings + 1] = newInning()
|
||||
end
|
||||
|
||||
---@param inning number
|
||||
---@param finalInning number
|
||||
---@param battingTeam TeamId
|
||||
---@return boolean gameOver
|
||||
function Statistics:gameIsOver(inning, finalInning, battingTeam)
|
||||
local homeScore, awayScore = utils.totalScores(self)
|
||||
local isFinalInning = inning >= finalInning
|
||||
local gameOver = isFinalInning and battingTeam == "home" and awayScore ~= homeScore
|
||||
gameOver = gameOver or battingTeam == "away" and isFinalInning and homeScore > awayScore
|
||||
return gameOver
|
||||
end
|
||||
|
||||
-- luacheck: ignore
|
||||
if not playdate or playdate.TEST_MODE then
|
||||
return Statistics
|
||||
end
|
|
@ -1,13 +1,68 @@
|
|||
utils = require("utils")
|
||||
|
||||
local mockPlaydate = {
|
||||
local currentTimeMs = 0
|
||||
|
||||
local mockPlaydate = {}
|
||||
|
||||
mockPlaydate = {
|
||||
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 = {
|
||||
animator = {
|
||||
new = function()
|
||||
return utils.staticAnimator(0)
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -9,3 +9,30 @@ playdate, announcer = mocks[1], mocks[2]
|
|||
|
||||
local _f = require("fielding")
|
||||
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
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
require("test/setup")
|
||||
require("action-queue")
|
||||
|
||||
function testActionQueueRunsToCompletion()
|
||||
actionQueue.queue = {}
|
||||
local invokeTotalSec = 0
|
||||
local hasYielded = false
|
||||
actionQueue:upsert("testAction", 9999999999, function(delta)
|
||||
while invokeTotalSec < 5 do
|
||||
invokeTotalSec = invokeTotalSec + delta
|
||||
hasYielded = true
|
||||
coroutine.yield()
|
||||
end
|
||||
end)
|
||||
luaunit.assertIsFalse(hasYielded, "Should not have been invoked yet.")
|
||||
for _ = 1, 10 do
|
||||
actionQueue:runWaiting(1)
|
||||
luaunit.assertIsTrue(hasYielded, "Should have been invoked.")
|
||||
end
|
||||
luaunit.assertEquals(5, invokeTotalSec, "Should have run five times and stopped itself")
|
||||
end
|
||||
|
||||
function testActionQueueExpiration()
|
||||
actionQueue.queue = {}
|
||||
local yieldCount = 0
|
||||
actionQueue:upsert("testAction", 2000, function()
|
||||
while true do
|
||||
yieldCount = yieldCount + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
end)
|
||||
|
||||
local skipSec = 60
|
||||
playdate.skipMs(60 * 1000)
|
||||
actionQueue:runWaiting(skipSec)
|
||||
luaunit.assertEquals(1, yieldCount, "Should always be invoked at least once")
|
||||
|
||||
playdate.skipMs(1000)
|
||||
actionQueue:runWaiting(1)
|
||||
luaunit.assertEquals(1, yieldCount, "Should not be invoked again after expiry")
|
||||
end
|
||||
|
||||
function testDuplicateUpsertsShouldOnlyRunOnce()
|
||||
actionQueue.queue = {}
|
||||
local yieldCount = 0
|
||||
local yieldId
|
||||
local action = function(id)
|
||||
return function()
|
||||
while true do
|
||||
yieldCount = yieldCount + 1
|
||||
yieldId = id
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, 10 do
|
||||
actionQueue:upsert("testAction", 9999999999, action(i))
|
||||
end
|
||||
actionQueue:runWaiting(1)
|
||||
luaunit.assertEquals(1, yieldCount, "Duplicate upserts should result in only one invocation.")
|
||||
luaunit.assertEquals(10, yieldId, "Most recent upsert should take precedence.")
|
||||
end
|
||||
|
||||
function testNewOnlyActionsShouldNotReplaceExistingActions()
|
||||
actionQueue.queue = {}
|
||||
local yieldCount = 0
|
||||
local yieldId
|
||||
local action = function(id)
|
||||
return function()
|
||||
while true do
|
||||
yieldCount = yieldCount + 1
|
||||
yieldId = id
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, 10 do
|
||||
actionQueue:newOnly("testAction", 9999999999, action(i))
|
||||
end
|
||||
actionQueue:runWaiting(1)
|
||||
luaunit.assertEquals(1, yieldCount, "Duplicate newOnly should result in only one invocation.")
|
||||
luaunit.assertEquals(1, yieldId, "The first newOnly should take precedence.")
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
|
@ -0,0 +1,14 @@
|
|||
require("test/setup")
|
||||
|
||||
local Ball = require("ball")
|
||||
|
||||
function testMarkUncatchable()
|
||||
local ball = Ball.new(playdate.graphics.animator)
|
||||
luaunit.assertIsTrue(ball.catchable, "Ball should start catchable")
|
||||
ball:markUncatchable()
|
||||
luaunit.assertIsFalse(ball.catchable, "Ball should not be catchable immediately after mark")
|
||||
playdate.timer.lastTimer.mockCompletion()
|
||||
luaunit.assertIsTrue(ball.catchable, "Ball should return to catchability after its timer expires")
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
|
@ -11,7 +11,7 @@ function buildBaserunning()
|
|||
return baserunning, thirdOutCallbackData
|
||||
end
|
||||
|
||||
---@alias BaseIndexOrXyPair (integer | XyPair)
|
||||
---@alias BaseIndexOrXyPair (number | XyPair)
|
||||
|
||||
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
|
||||
---@param runnerLocations BaseIndexOrXyPair[]
|
||||
|
@ -29,7 +29,7 @@ function buildRunnersOn(runnerLocations)
|
|||
for b = 1, location do
|
||||
runner.x = C.Bases[b].x
|
||||
runner.y = C.Bases[b].y
|
||||
baserunning:updateNonBatterRunners(0.001, false, 0.001)
|
||||
baserunning:updateNonBatterRunners(0.001, false, false, 0.001)
|
||||
end
|
||||
else
|
||||
-- Is a raw XyPair
|
||||
|
@ -42,22 +42,21 @@ end
|
|||
|
||||
---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] }
|
||||
|
||||
---@param expected boolean
|
||||
---@param expectedOuts number
|
||||
---@param fielderWithBallAt XyPair
|
||||
---@param when integer[][]
|
||||
function assertRunnerOutCondition(expected, when, fielderWithBallAt)
|
||||
local msg = expected and "out" or "safe"
|
||||
---@param when number[][]
|
||||
function assertRunnerOutCondition(expectedOuts, when, fielderWithBallAt)
|
||||
for _, runnersOn in ipairs(when) do
|
||||
local baserunning = buildRunnersOn(runnersOn)
|
||||
local outedSomeRunner = baserunning:outEligibleRunners(fielderWithBallAt)
|
||||
luaunit.failIf(outedSomeRunner ~= expected, "Runner should have been " .. msg .. ", but was not!")
|
||||
baserunning:outEligibleRunners(fielderWithBallAt)
|
||||
luaunit.assertEquals(expectedOuts, baserunning.outs, "Incorrect number of outs.")
|
||||
end
|
||||
end
|
||||
|
||||
---@param condition Condition
|
||||
function assertRunnerStatuses(condition)
|
||||
assertRunnerOutCondition(true, condition.outWhen, condition.fielderWithBallAt)
|
||||
assertRunnerOutCondition(false, condition.safeWhen, condition.fielderWithBallAt)
|
||||
assertRunnerOutCondition(1, condition.outWhen, condition.fielderWithBallAt)
|
||||
assertRunnerOutCondition(0, condition.safeWhen, condition.fielderWithBallAt)
|
||||
end
|
||||
|
||||
function testForceOutsAtFirst()
|
||||
|
@ -157,4 +156,18 @@ function testTagOutsShouldNotHappenOnBase()
|
|||
})
|
||||
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())
|
||||
|
|
|
@ -1,52 +1,54 @@
|
|||
require("test/setup")
|
||||
require("ball")
|
||||
|
||||
---@return Fielding, number fielderCount
|
||||
---@return Fielding, Fielder someBaseman
|
||||
local function fieldersAtDefaultPositions()
|
||||
local fielding = Fielding.new()
|
||||
fielding:resetFielderPositions()
|
||||
|
||||
local fielderCount = 0
|
||||
for _, fielder in pairs(fielding.fielders) do
|
||||
fielder.x = fielder.target.x
|
||||
fielder.y = fielder.target.y
|
||||
fielder.x = fielder.targets[#fielder.targets].x
|
||||
fielder.y = fielder.targets[#fielder.targets].y
|
||||
fielderCount = fielderCount + 1
|
||||
end
|
||||
|
||||
return fielding, fielderCount
|
||||
return fielding, fielding.fielders.second
|
||||
end
|
||||
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param z number | nil
|
||||
function fakeBall(x, y, z)
|
||||
return {
|
||||
x = x,
|
||||
y = y,
|
||||
z = z or 0,
|
||||
heldBy = nil,
|
||||
caughtBy = function(self, fielder)
|
||||
self.heldBy = fielder
|
||||
end,
|
||||
}
|
||||
local function ballAt(x, y, z)
|
||||
local ball = Ball.new(playdate.graphics.animator)
|
||||
ball.x = x
|
||||
ball.y = y
|
||||
ball.z = z
|
||||
return ball
|
||||
end
|
||||
|
||||
function testBallPickedUpByNearbyFielders()
|
||||
local fielding, fielderCount = fieldersAtDefaultPositions()
|
||||
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
|
||||
local fielding, baseman = fieldersAtDefaultPositions()
|
||||
local ball = ballAt(baseman.x, baseman.y, baseman.z)
|
||||
|
||||
fielding:updateFielderPositions(ball, 0.01)
|
||||
luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder")
|
||||
luaunit.assertIs(baseman, ball.heldBy, "Ball should be held by the nearest fielder")
|
||||
end
|
||||
|
||||
function testBallNotPickedUpByDistantFielders()
|
||||
local fielding = fieldersAtDefaultPositions()
|
||||
local ball = ballAt(-100, -100, -100)
|
||||
fielding:updateFielderPositions(ball, 0.01)
|
||||
luaunit.assertIsNil(ball.heldBy, "Ball should be too far for any fielders to pick up")
|
||||
end
|
||||
|
||||
function testBallNotPickedUpWhenNotCatchable()
|
||||
local fielding, baseman = fieldersAtDefaultPositions()
|
||||
local ball = ballAt(baseman.x, baseman.y, baseman.z)
|
||||
ball:markUncatchable()
|
||||
|
||||
fielding:updateFielderPositions(ball, 0.01)
|
||||
luaunit.assertIsNil(ball.heldBy, "Ball should be held by the nearest fielder")
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
require("test/setup")
|
||||
require("graphics")
|
||||
|
||||
local function assertSmallDifference(previous, current, ballValue, ballLabel)
|
||||
local difference = math.abs(previous - current)
|
||||
local baseError = "Expected a small difference, but received a difference of " .. difference
|
||||
local fullDetails = luaunit.prettystr({ previous = previous, current = current, [ballLabel] = ballValue })
|
||||
luaunit.assertIsTrue(difference < 2, baseError .. ":\n " .. luaunit.prettystr(fullDetails))
|
||||
end
|
||||
|
||||
function testNoJumpsInYOffset()
|
||||
local startY = -240 * 3
|
||||
local atXValues = { -400, 0, 400 }
|
||||
for _, xValue in ipairs(atXValues) do
|
||||
local _, lastYOffset = getDrawOffset(xValue, startY)
|
||||
for ballY = startY, 240 do
|
||||
local _, currentYOffset = getDrawOffset(xValue, ballY)
|
||||
assertSmallDifference(lastYOffset, currentYOffset, ballY, "ballY")
|
||||
lastYOffset = currentYOffset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function testNoJumpsInXOffset()
|
||||
local startX = -800
|
||||
local atYValues = { 240, 0, -240 }
|
||||
for _, yValue in ipairs(atYValues) do
|
||||
local lastXOffset = getDrawOffset(startX, yValue)
|
||||
for ballX = startX, 800 do
|
||||
local currentXOffset = getDrawOffset(ballX, yValue)
|
||||
assertSmallDifference(lastXOffset, currentXOffset, ballX, "ballX")
|
||||
lastXOffset = currentXOffset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
|
@ -0,0 +1,35 @@
|
|||
require("test/setup")
|
||||
require("draw/panner")
|
||||
|
||||
function string.starts(str, start)
|
||||
return string.sub(str, 1, str.len(start)) == start
|
||||
end
|
||||
|
||||
import = function(target)
|
||||
if string.starts(target, "CoreLibs") or string.starts(target, "draw/") then
|
||||
return
|
||||
end
|
||||
-- Remove .lua
|
||||
require(target:sub(1, #target - 4))
|
||||
end
|
||||
|
||||
Glove = {
|
||||
getSize = function()
|
||||
return 10, 10
|
||||
end,
|
||||
}
|
||||
Characters = require("draw/characters")
|
||||
|
||||
local Game = require("main")
|
||||
|
||||
local settings = {
|
||||
homeTeamSpriteGroup = {},
|
||||
awayTeamSpriteGroup = {},
|
||||
}
|
||||
|
||||
function testStandaloneInit()
|
||||
-- Harness should be fleshed-out enough to init without error.
|
||||
Game.new(settings, announcer)
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
|
@ -0,0 +1,19 @@
|
|||
require("test/setup")
|
||||
|
||||
local Statistics = require("statistics")
|
||||
|
||||
function testReportGameOver()
|
||||
---@type Statistics
|
||||
local stats = Statistics.new()
|
||||
stats.innings[1].home.score = 0
|
||||
stats.innings[1].away.score = 0
|
||||
luaunit.assertIsFalse(stats:gameIsOver(9, 9, "home"), "Tie games should not report a game over")
|
||||
|
||||
stats.innings[1].home.score = 1
|
||||
luaunit.assertIsTrue(stats:gameIsOver(9, 9, "home"), "Team in lead should report a game over")
|
||||
|
||||
stats.innings[1].home.score = 1
|
||||
luaunit.assertIsFalse(stats:gameIsOver(1, 9, "home"), "Should not game over with innings left")
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
|
@ -0,0 +1,69 @@
|
|||
---@class UserInput: InputHandler
|
||||
---@field buttonControlledThrow: fun(throwFlyMs: number, forbidThrowHome: boolean): boolean didThrow
|
||||
UserInput = {}
|
||||
|
||||
---@return UserInput
|
||||
function UserInput.new(buttonControlledThrow)
|
||||
return setmetatable({
|
||||
buttonControlledThrow = buttonControlledThrow,
|
||||
}, { __index = UserInput })
|
||||
end
|
||||
|
||||
function UserInput:update()
|
||||
self.crankChange = playdate.getCrankChange()
|
||||
self.crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower)
|
||||
if self.crankChange < 0 then
|
||||
self.crankLimited = self.crankLimited * -1
|
||||
end
|
||||
end
|
||||
|
||||
---@return number batAngleDeg, number batSpeed
|
||||
function UserInput:updateBatAngle()
|
||||
local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
|
||||
local batSpeed = math.abs(self.crankLimited)
|
||||
return batAngleDeg, batSpeed
|
||||
end
|
||||
|
||||
function UserInput:runningSpeed()
|
||||
return self.crankLimited
|
||||
end
|
||||
|
||||
---@param throwFlyMs number
|
||||
---@param accuracy number | nil
|
||||
---@return number | nil pitchFlyTimeMs, number | nil pitchTypeIndex, number | nil accuracy
|
||||
local function userPitch(throwFlyMs, accuracy)
|
||||
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
|
||||
local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
|
||||
if not aPressed and not bPressed then
|
||||
return throwFlyMs, 1, accuracy
|
||||
elseif aPressed and not bPressed then
|
||||
return throwFlyMs, 2, accuracy
|
||||
elseif not aPressed and bPressed then
|
||||
return throwFlyMs, 3, accuracy
|
||||
elseif aPressed and bPressed then
|
||||
return throwFlyMs, 4, accuracy
|
||||
end
|
||||
|
||||
return nil, nil, nil
|
||||
end
|
||||
|
||||
---@return number | nil pitchFlyTimeMs, number | nil pitchTypeIndex, number | nil accuracy
|
||||
function UserInput:pitch()
|
||||
local powerRatio, accuracy = throwMeter:readThrow(self.crankChange)
|
||||
if powerRatio then
|
||||
local throwFly = C.PitchFlyMs / powerRatio
|
||||
if throwFly and not self.buttonControlledThrow(throwFly, true) then
|
||||
return userPitch(throwFly, accuracy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function UserInput:fielderAction()
|
||||
local powerRatio = throwMeter:readThrow(self.crankChange)
|
||||
if powerRatio then
|
||||
local throwFly = C.PitchFlyMs / powerRatio
|
||||
if throwFly then
|
||||
self.buttonControlledThrow(throwFly, false)
|
||||
end
|
||||
end
|
||||
end
|