Compare commits

...

101 Commits

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

This commit *does* introduce some entanglement between files. E.g. the markIneligible() calls, and overriding ball.heldBy from within Fielding
2025-02-12 17:18:18 -05:00
Sage Vaillancourt 1926960c86 NPCs can play each other
Some timing tweaks and TODOs.
Cluster global state tighter together.
2025-02-11 13:50:03 -05:00
Sage Vaillancourt b9d25e18d8 New black away uniforms!
Generate sprites from component images during load.
Should make it easy to swap out logos at runtime.
2025-02-11 08:32:51 -05:00
Sage Vaillancourt 0646663e5e Faster fielders
Adjust left and right field positions.
Bouncy baserunners.
Patched npc nil index issue.
2025-02-11 00:01:36 -05:00
Sage Vaillancourt 90f792ff4e More aggressive NPC running
Less aggressive NPC batting :D
Updated Player sprites - now with belt!
2025-02-10 23:25:19 -05:00
Sage Vaillancourt fbbfc3c2e7 Move fielder-draw iteration and dance to fielding.lua
Move PlayerImageBlipper to graphics.lua
2025-02-10 22:36:17 -05:00
Sage Vaillancourt f67d6262ac Fix some NPC positioning.
Basic foul-ball implementation.
Some balance tweaks. Generally faster play.
Move pitchMeter to utils.
Player -> User when talking about the human player instead of a baseball player.
Slightly delay scoreboard changes.
Animate ball-strike entry and exit.
2025-02-10 21:22:21 -05:00
Sage Vaillancourt 534a16ad67 Fix inning switching.
Add onThirdOut() as a baserunning dependency instead of relying on a wrapper function in main
Reset pitchTracker on side-switch
2025-02-10 12:47:24 -05:00
Sage Vaillancourt fc4e12eddd Updated bg and player sprites.
Player is taller now.
Fixed out handling.
2025-02-10 11:24:53 -05:00
Sage Vaillancourt 90fa692303 Fix linting
Use .styluaignore instead of Makefile hacks to ignore generated files.
2025-02-09 14:47:37 -05:00
Sage Vaillancourt 4d69e77d9f Class-ify npc -> Npc
In the future, may keep a small memory of past pitches/plays, and use it to adjust swings, baserunning, etc.
2025-02-09 12:30:53 -05:00
Sage Vaillancourt 575c9e0a18 Explicitly set minimap draw sizes 2025-02-09 12:15:37 -05:00
Sage Vaillancourt 0f83298086 Only draw in-bounds fielders on minimap.
Inject animator into Ball, instead of entire gfx
2025-02-09 12:09:31 -05:00
Sage Vaillancourt 89c37eaf3d Move Fielder type into fielding.lua 2025-02-09 11:50:31 -05:00
Sage Vaillancourt 476e0d54cb Start extracting ball.lua
PseudoAnimator -> SimpleAnimator
2025-02-09 11:49:15 -05:00
Sage Vaillancourt fed1151179 XYPair -> XyPair, throwBall() -> launchBall()
Add Point3d type.
2025-02-09 11:35:19 -05:00
Sage Vaillancourt d74332f685 Store animators directly on the `ball` object 2025-02-09 11:26:24 -05:00
Sage Vaillancourt 9c0d263a29 Class-ify Announcer, Baserunning, and Fielding
Largely to enable dependency injection.
I am pushing AWAY the paranoia that metatable lookups will slow things down. (You've got like 20 entities, bud, chill.)
Field -> Fielding
2025-02-09 11:19:11 -05:00
Sage Vaillancourt aadaa6e0d6 Use : instead of . in self function definitions 2025-02-09 10:31:32 -05:00
Sage Vaillancourt c56cae6527 batter -> baserunning.batter 2025-02-09 10:12:56 -05:00
Sage Vaillancourt 1a68521bd4 Extract baserunning.lua
field.lua -> fielding.lua
npcFielderAction() -> npc.fielderAction()
Generally, a pinch of additional or stricter typing
2025-02-09 10:06:57 -05:00
Sage Vaillancourt 50ddd67730 Start drawing fielders in minimap 2025-02-09 00:04:59 -05:00
Sage Vaillancourt fedf680626 Small action-queue.lua doc-comment tweaks 2025-02-08 22:08:42 -05:00
Sage Vaillancourt 82d1dac5de Buffer player-controlled fielder throws.
Use new actionQueue to do so.
Move thrower and receiver selection into fielder.lua
Correct SOURCE_FILES listing in Makefile
2025-02-08 22:02:29 -05:00
Sage Vaillancourt b119310859 Even further fielder logic extraction.
Also remove some redundant ball-position mutation.
2025-02-08 20:25:30 -05:00
Sage Vaillancourt 30f2eada72 Extract almost all NPC fielding logic 2025-02-08 19:04:10 -05:00
Sage Vaillancourt 80c15161e3 Move more logic into field.lua 2025-02-08 18:28:17 -05:00
Sage Vaillancourt d85db79e52 Make main.lua functions `local`, where possible 2025-02-08 13:36:09 -05:00
Sage Vaillancourt 66bd97499a Try to cluster global state together.
Start peeling out fielder functions into a new file.
A bit more constant use.
In Makefile, parse main.lua imports for source files.
2025-02-08 11:52:39 -05:00
Sage Vaillancourt 324673ea98 Add music + sound effects to assets.lua2p 2025-02-08 09:39:56 -05:00
Sage Vaillancourt 8dc999fd72 Add LuaPreprocess for asset-processing.
Rename image assets to match var names.
2025-02-08 01:53:26 -05:00
Sage Vaillancourt 4a4049996f Much more fleshed-out constants.lua
* Move scoreboard.lua to draw/overlay.lua
* Move minimap drawing into overlay.lua
* Remove playdate imports from utils.lua
2025-02-08 00:49:03 -05:00
Sage Vaillancourt f07530623f Delete unused ecs.lua toy
Add a quick type assertion to outRunner()
2025-02-07 23:56:39 -05:00
Sage Vaillancourt 881ff0e734 Implementing walks and strike-outs
* Look at limiting batting/throw power with math.log()
* Extract draw/fielder.lua
* Extract some values into constants.lua
* Extract npc.lua for computer batting (and eventually probably more CPU behavior)
* pitchTracker in utils
2025-02-07 20:29:40 -05:00
sage 969de111fe Merge pull request 'Properly display balls and strikes' (#3) from proper-balls-and-strikes-display into main
Reviewed-on: #3
Reviewed-by: NikB0t <culvey.nikalas@gmail.com>
2025-02-07 20:11:54 -05:00
107 changed files with 9469 additions and 1379 deletions

4
.luacheckrc Normal file
View File

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

View File

@ -2,7 +2,6 @@
"Lua.runtime.version": "Lua 5.4",
"Lua.diagnostics.disable": ["undefined-global", "lowercase-global"],
"Lua.diagnostics.globals": ["playdate", "import"],
"Lua.runtime.nonstandardSymbol": ["+=", "-=", "*=", "/="],
"Lua.workspace.library": ["/home/sage/Downloads/PlaydateSDK-2.6.2/CoreLibs"],
"Lua.workspace.preloadFileSize": 1000
}

1
.styluaignore Normal file
View File

@ -0,0 +1 @@
src/assets.lua

View File

@ -1,14 +1,15 @@
SOURCE_FILES := src/utils.lua src/dbg.lua src/announcer.lua src/graphics.lua src/scoreboard.lua src/main.lua
all:
pdc src BatterUp.pdx
pdc --skip-unknown src BatterUp.pdx
check:
assets:
lua lib/preprocess-cl.lua src/assets.lua2p
check: assets
stylua -c --indent-type Spaces src/
cat __stub.ext.lua <(sed 's/^function/-- selene: allow(unused_variable)\nfunction/' ${PLAYDATE_SDK_PATH}/CoreLibs/__types.lua) ${SOURCE_FILES} | grep -v '^import' | sed 's/<const>//g' | selene -
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/

View File

@ -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)

651
lib/preprocess-cl.lua Normal file
View File

@ -0,0 +1,651 @@
#!/bin/sh
_=[[
exec lua "$0" "$@"
]]and nil
--==============================================================
--=
--= LuaPreprocess command line program
--= by Marcus 'ReFreezed' Thunström
--=
--= Requires preprocess.lua to be in the same folder!
--=
--= License: MIT (see the bottom of this file)
--= Website: http://refreezed.com/luapreprocess/
--= Documentation: http://refreezed.com/luapreprocess/docs/command-line/
--=
--= Tested with Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT.
--=
--==============================================================
local help = [[
Script usage:
lua preprocess-cl.lua [options] [--] filepath1 [filepath2 ...]
OR
lua preprocess-cl.lua --outputpaths [options] [--] inputpath1 outputpath1 [inputpath2 outputpath2 ...]
File paths can be "-" for usage of stdin/stdout.
Examples:
lua preprocess-cl.lua --saveinfo=logs/info.lua --silent src/main.lua2p src/network.lua2p
lua preprocess-cl.lua --debug src/main.lua2p src/network.lua2p
lua preprocess-cl.lua --outputpaths --linenumbers src/main.lua2p output/main.lua src/network.lua2p output/network.lua
Options:
--backtickstrings
Enable the backtick (`) to be used as string literal delimiters.
Backtick strings don't interpret any escape sequences and can't
contain other backticks.
--data|-d="Any data."
A string with any data. If this option is present then the value
will be available through the global 'dataFromCommandLine' in the
processed files (and any message handler). Otherwise,
'dataFromCommandLine' is nil.
--faststrings
Force fast serialization of string values. (Non-ASCII characters
will look ugly.)
--handler|-h=pathToMessageHandler
Path to a Lua file that's expected to return a function or a
table of functions. If it returns a function then it will be
called with various messages as it's first argument. If it's
a table, the keys should be the message names and the values
should be functions to handle the respective message.
(See 'Handler messages' and tests/quickTestHandler*.lua)
The file shares the same environment as the processed files.
--help
Show this help.
--jitsyntax
Allow LuaJIT-specific syntax, specifically literals for 64-bit
integers, complex numbers and binary numbers.
(https://luajit.org/ext_ffi_api.html#literals)
--linenumbers
Add comments with line numbers to the output.
--loglevel=levelName
Set maximum log level for the @@LOG() macro. Can be "off",
"error", "warning", "info", "debug" or "trace". The default is
"trace", which enables all logging.
--macroprefix=prefix
String to prepend to macro names.
--macrosuffix=suffix
String to append to macro names.
--meta OR --meta=pathToSaveMetaprogramTo
Output the metaprogram to a temporary file (*.meta.lua). Useful if
an error happens when the metaprogram runs. This file is removed
if there's no error and --debug isn't enabled.
--nogc
Stop the garbage collector. This may speed up the preprocessing.
--nonil
Disallow !(expression) and outputValue() from outputting nil.
--nostrictmacroarguments
Disable checks that macro arguments are valid Lua expressions.
--novalidate
Disable validation of outputted Lua.
--outputextension=fileExtension
Specify what file extension generated files should have. The
default is "lua". If any input files end in .lua then you must
specify another file extension with this option. (It's suggested
that you use .lua2p (as in "Lua To Process") as extension for
unprocessed files.)
--outputpaths|-o
This flag makes every other specified path be the output path
for the previous path.
--release
Enable release mode. Currently only disables the @@ASSERT() macro.
--saveinfo|-i=pathToSaveProcessingInfoTo
Processing information includes what files had any preprocessor
code in them, and things like that. The format of the file is a
lua module that returns a table. Search this file for 'SavedInfo'
to see what information is saved.
--silent
Only print errors to the console. (This flag is automatically
enabled if an output path is stdout.)
--version
Print the version of LuaPreprocess to stdout and exit.
--debug
Enable some preprocessing debug features. Useful if you want
to inspect the generated metaprogram (*.meta.lua). (This also
enables the --meta option.)
--
Stop options from being parsed further. Needed if you have paths
starting with "-" (except for usage of stdin/stdout).
Handler messages:
"init"
Sent before any other message.
Arguments:
inputPaths: Array of file paths to process. Paths can be added or removed freely.
outputPaths: If the --outputpaths option is present this is an array of output paths for the respective path in inputPaths, otherwise it's nil.
"insert"
Sent for each @insert"name" statement. The handler is expected to return a Lua code string.
Arguments:
path: The file being processed.
name: The name of the resource to be inserted (could be a file path or anything).
"beforemeta"
Sent before a file's metaprogram runs, if a metaprogram is generated.
Arguments:
path: The file being processed.
luaString: The generated metaprogram.
"aftermeta"
Sent after a file's metaprogram has produced output (before the output is written to a file).
Arguments:
path: The file being processed.
luaString: The produced Lua code. You can modify this and return the modified string.
"filedone"
Sent after a file has finished processing and the output written to file.
Arguments:
path: The file being processed.
outputPath: Where the output of the metaprogram was written.
info: Info about the processed file. (See 'ProcessInfo' in preprocess.lua)
"fileerror"
Sent if an error happens while processing a file (right before the program exits).
Arguments:
path: The file being processed.
error: The error message.
"alldone"
Sent after all other messages (right before the program exits).
Arguments:
(none)
]]
--==============================================================
local startTime = os.time()
local startClock = os.clock()
local args = arg
if not args[0] then error("Expected to run from the Lua interpreter.") end
local pp = dofile((args[0]:gsub("[^/\\]+$", "preprocess.lua")))
-- From args:
local addLineNumbers = false
local allowBacktickStrings = false
local allowJitSyntax = false
local canOutputNil = true
local customData = nil
local fastStrings = false
local hasOutputExtension = false
local hasOutputPaths = false
local isDebug = false
local outputExtension = "lua"
local outputMeta = false -- flag|path
local processingInfoPath = ""
local silent = false
local validate = true
local macroPrefix = ""
local macroSuffix = ""
local releaseMode = false
local maxLogLevel = "trace"
local strictMacroArguments = true
--==============================================================
--= Local functions ============================================
--==============================================================
local F = string.format
local function formatBytes(n)
if n >= 1024*1024*1024 then
return F("%.2f GiB", n/(1024*1024*1024))
elseif n >= 1024*1024 then
return F("%.2f MiB", n/(1024*1024))
elseif n >= 1024 then
return F("%.2f KiB", n/(1024))
elseif n == 1 then
return F("1 byte", n)
else
return F("%d bytes", n)
end
end
local function printfNoise(s, ...)
print(s:format(...))
end
local function printError(s)
io.stderr:write(s, "\n")
end
local function printfError(s, ...)
io.stderr:write(s:format(...), "\n")
end
local function errorLine(err)
printError(pp.tryToFormatError(err))
os.exit(1)
end
local loadLuaFile = (
(_VERSION >= "Lua 5.2" or jit) and function(path, env)
return loadfile(path, "bt", env)
end
or function(path, env)
local chunk, err = loadfile(path)
if not chunk then return nil, err end
if env then setfenv(chunk, env) end
return chunk
end
)
--==============================================================
--= Preprocessor script ========================================
--==============================================================
io.stdout:setvbuf("no")
io.stderr:setvbuf("no")
math.randomseed(os.time()) -- In case math.random() is used anywhere.
math.random() -- Must kickstart...
local processOptions = true
local messageHandlerPath = ""
local pathsIn = {}
local pathsOut = {}
for _, arg in ipairs(args) do
if processOptions and (arg:find"^%-%-?help$" or arg == "/?" or arg:find"^/[Hh][Ee][Ll][Pp]$") then
print("LuaPreprocess v"..pp.VERSION)
print((help:gsub("\t", " ")))
os.exit()
elseif not (processOptions and arg:find"^%-.") then
local paths = (hasOutputPaths and #pathsOut < #pathsIn) and pathsOut or pathsIn
table.insert(paths, arg)
if arg == "-" and (not hasOutputPaths or paths == pathsOut) then
silent = true
end
elseif arg == "--" then
processOptions = false
elseif arg:find"^%-%-data=" or arg:find"^%-d=" then
customData = arg:gsub("^.-=", "")
elseif arg == "--backtickstrings" then
allowBacktickStrings = true
elseif arg == "--debug" then
isDebug = true
outputMeta = outputMeta or true
elseif arg:find"^%-%-handler=" or arg:find"^%-h=" then
messageHandlerPath = arg:gsub("^.-=", "")
elseif arg == "--jitsyntax" then
allowJitSyntax = true
elseif arg == "--linenumbers" then
addLineNumbers = true
elseif arg == "--meta" then
outputMeta = true
elseif arg:find"^%-%-meta=" then
outputMeta = arg:gsub("^.-=", "")
elseif arg == "--nonil" then
canOutputNil = false
elseif arg == "--novalidate" then
validate = false
elseif arg:find"^%-%-outputextension=" then
if hasOutputPaths then
errorLine("Cannot specify both --outputextension and --outputpaths")
end
hasOutputExtension = true
outputExtension = arg:gsub("^.-=", "")
elseif arg == "--outputpaths" or arg == "-o" then
if hasOutputExtension then
errorLine("Cannot specify both --outputpaths and --outputextension")
elseif pathsIn[1] then
errorLine(arg.." must appear before any input path.")
end
hasOutputPaths = true
elseif arg:find"^%-%-saveinfo=" or arg:find"^%-i=" then
processingInfoPath = arg:gsub("^.-=", "")
elseif arg == "--silent" then
silent = true
elseif arg == "--faststrings" then
fastStrings = true
elseif arg == "--nogc" then
collectgarbage("stop")
elseif arg:find"^%-%-macroprefix=" then
macroPrefix = arg:gsub("^.-=", "")
elseif arg:find"^%-%-macrosuffix=" then
macroSuffix = arg:gsub("^.-=", "")
elseif arg == "--release" then
releaseMode = true
elseif arg:find"^%-%-loglevel=" then
maxLogLevel = arg:gsub("^.-=", "")
elseif arg == "--version" then
io.stdout:write(pp.VERSION)
os.exit()
elseif arg == "--nostrictmacroarguments" then
strictMacroArguments = false
else
errorLine("Unknown option '"..arg:gsub("=.*", "").."'.")
end
end
if silent then
printfNoise = function()end
end
local header = "= LuaPreprocess v"..pp.VERSION..os.date(", %Y-%m-%d %H:%M:%S =", startTime)
printfNoise(("="):rep(#header))
printfNoise("%s", header)
printfNoise(("="):rep(#header))
if hasOutputPaths and #pathsOut < #pathsIn then
errorLine("Missing output path for "..pathsIn[#pathsIn])
end
-- Prepare metaEnvironment.
pp.metaEnvironment.dataFromCommandLine = customData -- May be nil.
-- Load message handler.
local messageHandler = nil
local function hasMessageHandler(message)
if not messageHandler then
return false
elseif type(messageHandler) == "function" then
return true
elseif type(messageHandler) == "table" then
return messageHandler[message] ~= nil
else
assert(false)
end
end
local function sendMessage(message, ...)
if not messageHandler then
return
elseif type(messageHandler) == "function" then
local returnValues = pp.pack(messageHandler(message, ...))
return pp.unpack(returnValues, 1, returnValues.n)
elseif type(messageHandler) == "table" then
local _messageHandler = messageHandler[message]
if not _messageHandler then return end
local returnValues = pp.pack(_messageHandler(...))
return pp.unpack(returnValues, 1, returnValues.n)
else
assert(false)
end
end
if messageHandlerPath ~= "" then
-- Make the message handler and the metaprogram share the same environment.
-- This way the message handler can easily define globals that the metaprogram uses.
local mainChunk, err = loadLuaFile(messageHandlerPath, pp.metaEnvironment)
if not mainChunk then
errorLine("Could not load message handler...\n"..pp.tryToFormatError(err))
end
messageHandler = mainChunk()
if type(messageHandler) == "function" then
-- void
elseif type(messageHandler) == "table" then
for message, _messageHandler in pairs(messageHandler) do
if type(message) ~= "string" then
errorLine(messageHandlerPath..": Table of handlers must only contain messages as keys.")
elseif type(_messageHandler) ~= "function" then
errorLine(messageHandlerPath..": Table of handlers must only contain functions as values.")
end
end
else
errorLine(messageHandlerPath..": File did not return a table or a function.")
end
end
-- Init stuff.
sendMessage("init", pathsIn, (hasOutputPaths and pathsOut or nil)) -- @Incomplete: Use pcall and format error message better?
if not hasOutputPaths then
for i, pathIn in ipairs(pathsIn) do
pathsOut[i] = (pathIn == "-") and "-" or pathIn:gsub("%.%w+$", "").."."..outputExtension
end
end
if not pathsIn[1] then
errorLine("No path(s) specified.")
elseif #pathsIn ~= #pathsOut then
errorLine(F("Number of input and output paths differ. (%d in, %d out)", #pathsIn, #pathsOut))
end
local pathsSetIn = {}
local pathsSetOut = {}
for i = 1, #pathsIn do
if pathsSetIn [pathsIn [i]] then errorLine("Duplicate input path: " ..pathsIn [i]) end
if pathsSetOut[pathsOut[i]] then errorLine("Duplicate output path: "..pathsOut[i]) end
pathsSetIn [pathsIn [i]] = true
pathsSetOut[pathsOut[i]] = true
if pathsIn [i] ~= "-" and pathsSetOut[pathsIn [i]] then errorLine("Path is both input and output: "..pathsIn [i]) end
if pathsOut[i] ~= "-" and pathsSetIn [pathsOut[i]] then errorLine("Path is both input and output: "..pathsOut[i]) end
end
-- Process files.
-- :SavedInfo
local processingInfo = {
date = os.date("%Y-%m-%d %H:%M:%S", startTime),
files = {},
}
local byteCount = 0
local lineCount = 0
local lineCountCode = 0
local tokenCount = 0
for i, pathIn in ipairs(pathsIn) do
local startClockForPath = os.clock()
printfNoise("Processing '%s'...", pathIn)
local pathOut = pathsOut[i]
local pathMeta = (type(outputMeta) == "string") and outputMeta or pathOut:gsub("%.%w+$", "")..".meta.lua"
if not outputMeta or pathOut == "-" then
pathMeta = nil
end
local info, err = pp.processFile{
pathIn = pathIn,
pathMeta = pathMeta,
pathOut = pathOut,
debug = isDebug,
addLineNumbers = addLineNumbers,
backtickStrings = allowBacktickStrings,
jitSyntax = allowJitSyntax,
canOutputNil = canOutputNil,
fastStrings = fastStrings,
validate = validate,
strictMacroArguments = strictMacroArguments,
macroPrefix = macroPrefix,
macroSuffix = macroSuffix,
release = releaseMode,
logLevel = maxLogLevel,
onInsert = (hasMessageHandler("insert") or nil) and function(name)
local lua = sendMessage("insert", pathIn, name)
-- onInsert() is expected to return a Lua code string and so is the message
-- handler. However, if the handler is a single catch-all function we allow
-- the message to not be handled and we fall back to the default behavior of
-- treating 'name' as a path to a file to be inserted. If we didn't allow this
-- then it would be required for the "insert" message to be handled. I think
-- it's better if the user can choose whether to handle a message or not!
--
if lua == nil and type(messageHandler) == "function" then
return assert(pp.readFile(name))
end
return lua
end,
onBeforeMeta = messageHandler and function(lua)
sendMessage("beforemeta", pathIn, lua)
end,
onAfterMeta = messageHandler and function(lua)
local luaModified = sendMessage("aftermeta", pathIn, lua)
if type(luaModified) == "string" then
lua = luaModified
elseif luaModified ~= nil then
error(F(
"%s: Message handler did not return a string for 'aftermeta'. (Got %s)",
messageHandlerPath, type(luaModified)
))
end
return lua
end,
onDone = messageHandler and function(info)
sendMessage("filedone", pathIn, pathOut, info)
end,
onError = function(err)
xpcall(function()
sendMessage("fileerror", pathIn, err)
end, function(err)
printfError("Additional error in 'fileerror' message handler...\n%s", pp.tryToFormatError(err))
end)
os.exit(1)
end,
}
assert(info, err) -- The onError() handler above should have been called and we should have exited already.
byteCount = byteCount + info.processedByteCount
lineCount = lineCount + info.lineCount
lineCountCode = lineCountCode + info.linesOfCode
tokenCount = tokenCount + info.tokenCount
if processingInfoPath ~= "" then
-- :SavedInfo
table.insert(processingInfo.files, info) -- See 'ProcessInfo' in preprocess.lua for what more 'info' contains.
end
printfNoise("Processing '%s' successful! (%.3fs)", pathIn, os.clock()-startClockForPath)
printfNoise(("-"):rep(#header))
end
-- Finalize stuff.
if processingInfoPath ~= "" then
printfNoise("Saving processing info to '%s'.", processingInfoPath)
local luaParts = {"return"}
assert(pp.serialize(luaParts, processingInfo))
local lua = table.concat(luaParts)
local file = assert(io.open(processingInfoPath, "wb"))
file:write(lua)
file:close()
end
printfNoise(
"All done! (%.3fs, %.0f file%s, %.0f LOC, %.0f line%s, %.0f token%s, %s)",
os.clock()-startClock,
#pathsIn, (#pathsIn == 1) and "" or "s",
lineCountCode,
lineCount, (lineCount == 1) and "" or "s",
tokenCount, (tokenCount == 1) and "" or "s",
formatBytes(byteCount)
)
sendMessage("alldone") -- @Incomplete: Use pcall and format error message better?
--[[!===========================================================
Copyright © 2018-2022 Marcus 'ReFreezed' Thunström
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
==============================================================]]

3910
lib/preprocess.lua Normal file

File diff suppressed because it is too large Load Diff

65
src/action-queue.lua Normal file
View File

@ -0,0 +1,65 @@
---@class ActionQueue
---@field queue table<any, { coroutine: thread, expireTimeMs: number }>
actionQueue = {
queue = {},
}
---@alias Action fun(deltaSeconds: number)
local close = coroutine.close
--- Added actions will be called on every runWaiting() update.
--- They will continue to be executed until they return Succeeded or Failed instead of NeedsMoreTime.
---
--- Replaces any existing action with the given id.
--- If the initial call of action() doesn't return NeedsMoreTime, this function will not bother adding it to the queue.
---@param id any
---@param maxTimeMs number
---@param action Action
function actionQueue:upsert(id, maxTimeMs, action)
if self.queue[id] then
close(self.queue[id].coroutine)
end
self.queue[id] = {
coroutine = coroutine.create(action),
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
}
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()
for id, actionObject in pairs(self.queue) do
coroutine.resume(actionObject.coroutine, deltaSeconds)
if currentTimeMs > actionObject.expireTimeMs then
close(actionObject.coroutine)
end
if coroutine.status(actionObject.coroutine) == "dead" then
self.queue[id] = nil
end
end
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return actionQueue
end

View File

@ -1,4 +1,6 @@
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
local gfx = playdate.graphics
local AnnouncementFont <const> = Roobert20Medium
local AnnouncementTransitionMs <const> = 300
local AnnouncerMarginX <const> = 26
@ -7,15 +9,21 @@ local AnnouncerAnimatorInY <const> =
local AnnouncerAnimatorOutY <const> =
playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint)
-- selene: allow(unscoped_variables)
announcer = {
textQueue = {},
animatorY = AnnouncerAnimatorInY,
}
---@class Announcer
---@field textQueue string[]
---@field animatorY pd_animator
Announcer = {}
local DurationMs <const> = 3000
function Announcer.new()
return setmetatable({
textQueue = {},
animatorY = AnnouncerAnimatorInY,
}, { __index = Announcer })
end
function announcer.popIn(self)
local DurationMs <const> = 2000
function Announcer:popIn()
self.animatorY = AnnouncerAnimatorInY
self.animatorY:reset()
@ -36,20 +44,22 @@ function announcer.popIn(self)
end)
end
function announcer.say(self, text)
---@param text string
function Announcer:say(text)
self.textQueue[#self.textQueue + 1] = text
if #self.textQueue == 1 then
self:popIn()
end
end
function announcer.draw(self, x, y)
---@param x number
---@param y number
function Announcer:draw(x, y)
if #self.textQueue == 0 then
return
end
x = x - 5 -- Infield center is slightly offset from screen center
local gfx = playdate.graphics
local originalDrawMode = gfx.getImageDrawMode()
local width = math.max(150, (AnnouncerMarginX * 2) + AnnouncementFont:getTextWidth(self.textQueue[1]))
local animY = self.animatorY:currentValue()

192
src/assets.lua Normal file
View File

@ -0,0 +1,192 @@
-- GENERATED FILE - DO NOT EDIT
-- Instead, edit the source file directly: assets.lua2p.
-- luacheck: ignore
---@type pd_image
BallBackground = playdate.graphics.image.new("assets/images/game/BallBackground.png")
-- luacheck: ignore
---@type pd_image
BigBat = playdate.graphics.image.new("assets/images/game/BigBat.png")
-- luacheck: ignore
---@type pd_image
DarkPlayerAwayBack = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBack.png")
-- luacheck: ignore
---@type pd_image
DarkPlayerAwayBase = playdate.graphics.image.new("assets/images/game/DarkPlayerAwayBase.png")
-- luacheck: ignore
---@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("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") },
}

40
src/assets.lua2p Normal file
View File

@ -0,0 +1,40 @@
!(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
local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f | sort -h')
local assetCode = ""
--Loop through all files
for file in p:lines() do
if file:find(extension) then
local varName = file:gsub(".*/(.*)." .. extension, "%1")
file = file:gsub("src/", "")
assetCode = assetCode .. indent .. '-- luacheck: ignore\n'
assetCode = assetCode .. indent .. '---@type ' .. type ..'\n'
assetCode = assetCode .. indent .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
end
end
return assetCode
end
function generatedFileWarning()
-- Only in a function to make clear that THIS .lua2p is not the generated file!
return "-- GENERATED FILE - DO NOT EDIT\n-- Instead, edit the source file directly: assets.lua2p."
end)!!(generatedFileWarning())
!!(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("assets/images/game/logos/Base.png") },
!!(dirLookup('assets/images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', 'pd_image', ",\n\n", " ", function(varName, value)
return '{ name = "' .. varName .. '", image = ' .. value .. ' }'
end))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

103
src/ball.lua Normal file
View File

@ -0,0 +1,103 @@
---@class Ball
---@field x number
---@field y number
---@field z number
---@field size number
---@field heldBy Fielder | nil
---@field catchable boolean
---@field isFlyBall boolean
---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib
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 = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]],
z = 0,
catchable = true,
size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]],
xAnimator = utils.staticAnimator(C.BallOffscreen),
yAnimator = utils.staticAnimator(C.BallOffscreen),
-- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
floatAnimator = defaultFloatAnimator(animatorLib),
}, { __index = Ball })
end
---@param deltaSeconds number
function Ball:updatePosition(deltaSeconds)
if self.heldBy then
utils.moveAtSpeedZ(self, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ })
self.size = C.SmallestBallRadius
else
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
self.size = C.SmallestBallRadius + math.max(0, (self.floatAnimator:currentValue() - C.GloveZ) / 2)
end
end
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 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
-- Prevent silly insta-catches
self:markUncatchable()
if not flyTimeMs then
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
end
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

298
src/baserunning.lua Normal file
View File

@ -0,0 +1,298 @@
--- @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[]
---@field outRunners Runner[]
---@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.
---@param announcer Announcer
---@param onThirdOutCallback fun()
---@return Baserunning
function Baserunning.new(announcer, onThirdOutCallback)
local o = setmetatable({
runners = {},
outRunners = {},
scoredRunners = {},
batter = nil,
--- Since this object is what ultimately *mutates* the out count,
--- it seems sensible to store the value here.
outs = 0,
announcer = announcer,
onThirdOut = onThirdOutCallback,
}, { __index = Baserunning })
o:pushNewBatter()
return o
end
---@param runner number | Runner
---@param message string | nil
---@return boolean wasThirdOut
function Baserunning:outRunner(runner, message)
self.outs = self.outs + 1
if type(runner) ~= "number" then
for i, maybe in ipairs(self.runners) do
if runner == maybe then
runner = i
end
end
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)
self:updateForcedRunners()
self.announcer:say(message or "YOU'RE OUT!")
if self.outs < 3 then
return false
end
self.onThirdOut()
self.outs = 0
while #self.runners > 0 do
self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
end
return true
end
---@param fielder Fielder
---@return boolean outedSomeRunner
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 = runnerBaseBiMap[runner]
if -- Force out
touchedBase
and runner.prevBase -- Make sure the runner is not standing at home
and runner.forcedTo == touchedBase
and touchedBase ~= runnerOnBase
-- Tag out
or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < C.TagDistance
then
local wasThirdOut = self:outRunner(i)
if wasThirdOut then
return true -- Don't keep running up self.outs after it's been reset
end
didOutRunner = true
end
end
return didOutRunner
end
function Baserunning:updateForcedRunners()
local stillForced = true
for _, base in ipairs(C.Bases) do
local runnerTargetingBase = utils.getRunnerWithNextBase(self.runners, base)
if runnerTargetingBase then
if stillForced then
runnerTargetingBase.forcedTo = base
else
runnerTargetingBase.forcedTo = nil
end
else
stillForced = false
end
end
end
function Baserunning:convertBatterToRunner()
self.batter.nextBase = C.Bases[C.First]
self.batter.prevBase = C.Bases[C.Home]
self:updateForcedRunners()
self.batter.forcedTo = C.Bases[C.First]
self.batter = nil -- Demote batter to a mere runner
end
---@param deltaSeconds number
---@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)
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 theBatterPushed
function Baserunning:pushNewBatter()
local new = {
-- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol.
x = C.RightHandedBattersBox.x - 60,
y = C.RightHandedBattersBox.y + 60,
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
function Baserunning:getNewestRunner()
return self.runners[#self.runners]
end
---@param runnerIndex number
function Baserunning:runnerScored(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 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, isAutoRun, deltaSeconds)
local autoRunSpeed = 20 * deltaSeconds
if not runner or not runner.nextBase then
return false, false
end
local nearestBase, nearestBaseDistance = utils.getNearestOf(C.Bases, runner.x, runner.y)
if
nearestBaseDistance < 5
and runnerIndex ~= nil
and runner ~= self.batter
and runner.nextBase == C.Bases[C.Home]
and nearestBase == C.Bases[C.Home]
then
self:runnerScored(runnerIndex)
return true, true
end
local nb = runner.nextBase
local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y)
-- TODO: Do a better job drifting runners toward their bases when appliedSpeed is low/zero
if distance < 2 then
if runner.prevBase ~= nearestBase then
runner.prevBase = runner.nextBase
runner.nextBase = C.NextBaseMap[runner.nextBase]
end
runner.forcedTo = nil
return false, false
end
local prevX, prevY = runner.x, runner.y
local mult = 1
if appliedSpeed < 0 then
if runner.prevBase then
mult = -1
else
-- Don't allow running backwards when approaching the plate
appliedSpeed = 0
end
end
-- TODO: Make this less "sticky" for the user.
-- Currently it can be a little hard to run *past* a base.
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)
runner.y = runner.y - (y * mult)
return prevX ~= runner.x or prevY ~= runner.y, false
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
---@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"
-- TODO: Filter for the runner closest to the currently-held direction button
for runnerIndex, runner in ipairs(self.runners) do
if runner ~= self.batter and (not forcedOnly or runner.forcedTo) then
local speed = appliedSpeed
if speedIsFunction then
speed = appliedSpeed(runner)
end
local thisRunnerMoved, thisRunnerScored =
self:updateRunner(runner, runnerIndex, speed, isAutoRun, deltaSeconds)
runnersStillMoving = runnersStillMoving or thisRunnerMoved
if thisRunnerScored then
runnersScored = runnersScored + 1
end
end
end
if runnersStillMoving then
self.secondsSinceLastRunnerMove = 0
self:updateForcedRunners()
else
self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds
end
return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return Baserunning
end

68
src/batting.lua Normal file
View File

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

147
src/constants.lua Normal file
View File

@ -0,0 +1,147 @@
C = {}
C.Screen = {
W = playdate and playdate.display.getWidth() or 400,
H = playdate and playdate.display.getHeight() or 240,
}
C.Center = utils.xy(C.Screen.W / 2, C.Screen.H / 2)
C.StrikeZoneStartX = C.Center.x - 16
C.StrikeZoneEndX = C.StrikeZoneStartX + 24
C.StrikeZoneStartY = C.Screen.H - 35
--- @alias Base {
--- x: number,
--- y: number,
--- }
---@type Base[]
C.Bases = {
utils.xy(C.Screen.W * 0.93, C.Screen.H * 0.52),
utils.xy(C.Screen.W * 0.47, C.Screen.H * 0.19),
utils.xy(C.Screen.W * 0.03, C.Screen.H * 0.52),
utils.xy(C.Screen.W * 0.474, C.Screen.H * 0.79),
}
C.First, C.Second, C.Third, C.Home = 1, 2, 3, 4
C.FieldHeight = C.Bases[C.Home].y - C.Bases[C.Second].y
-- Pseudo-base for batter to target
C.RightHandedBattersBox = {
x = C.Bases[C.Home].x - 30,
y = C.Bases[C.Home].y + 10,
}
---@type table<Base, Base | nil>
C.NextBaseMap = {
[C.RightHandedBattersBox] = nil, -- Runner should not escape the box before a hit!
[C.Bases[C.First]] = C.Bases[C.Second],
[C.Bases[C.Second]] = C.Bases[C.Third],
[C.Bases[C.Third]] = C.Bases[C.Home],
}
C.LeftFoulLine = {
x1 = C.Center.x,
y1 = 220,
x2 = -1800,
y2 = -465,
}
C.RightFoulLine = {
x1 = C.Center.x,
y1 = 218,
x2 = 2120,
y2 = -465,
}
--- Angle to align the bat to
C.CrankOffsetDeg = 90
C.DanceBounceMs = 500
C.DanceBounceCount = 4
C.ScoreboardDelayMs = 2000
--- Used to draw the ball well out of bounds, and
--- generally as a check for whether or not it's in play.
C.BallOffscreen = 999
C.PitchAfterSeconds = 6
C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050
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 = 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 = 0 -- 10
C.SmallestBallRadius = 6
C.BatLength = 35
---@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"
C.PitcherStartPos = {
x = C.Screen.W * 0.48,
y = C.Screen.H * 0.40,
}
--- 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
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

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

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

View File

@ -1,7 +1,5 @@
-- selene: allow(unscoped_variables)
dbg = {}
-- selene: allow(unused_variable)
function dbg.label(value, name)
if type(value) == "table" then
print(name .. ":")
@ -18,23 +16,95 @@ 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.
-- selene: allow(unused_variable)
function dbg.loadTheBases(baseConstants, runners)
newRunner()
newRunner()
newRunner()
runners[2].x = baseConstants[1].x
runners[2].y = baseConstants[1].y
runners[2].nextBase = baseConstants[2]
--- 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()
br:pushNewBatter()
runners[3].x = baseConstants[2].x
runners[3].y = baseConstants[2].y
runners[3].nextBase = baseConstants[3]
br.runners[2].x = C.Bases[C.First].x
br.runners[2].y = C.Bases[C.First].y
br.runners[2].nextBase = C.Bases[C.Second]
runners[4].x = baseConstants[3].x
runners[4].y = baseConstants[3].y
runners[4].nextBase = baseConstants[4]
br.runners[3].x = C.Bases[C.Second].x
br.runners[3].y = C.Bases[C.Second].y
br.runners[3].nextBase = C.Bases[C.Third]
br.runners[4].x = C.Bases[C.Third].x
br.runners[4].y = C.Bases[C.Third].y
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
local prev = points[i - 1]
local next = points[i]
playdate.graphics.drawLine(prev.x, prev.y, next.x, next.y)
end
end
if not playdate then

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

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

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

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

View File

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

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

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

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

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

169
src/draw/overlay.lua Normal file
View File

@ -0,0 +1,169 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics
local ScoreFont <const> = FontFullCircle
local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize()
local MinimapPosX, MinimapPosY = C.Screen.W - MinimapSizeX, C.Screen.H - MinimapSizeY
local MinimapBoundX, MinimapBoundY = (MinimapSizeX + MinimapPosX), (MinimapSizeY + MinimapPosY)
local MinimapMultX <const> = 0.75 * MinimapSizeX / C.Screen.W
local MinimapOffsetX <const> = MinimapPosX + 5
local MinimapMultY <const> = 0.70 * MinimapSizeY / C.FieldHeight
local MinimapOffsetY <const> = MinimapPosY - 15
local RunnerSquareWidth = 8
local FielderCircleRadius = 4
local FielderCircleStrokeWidth = 2
function drawMinimap(runners, fielders)
Minimap:draw(MinimapPosX, MinimapPosY)
gfx.setColor(gfx.kColorBlack)
for _, runner in pairs(runners) do
local x = (MinimapMultX * runner.x) + MinimapOffsetX
local y = (MinimapMultY * runner.y) + MinimapOffsetY
gfx.fillRect(x, y, RunnerSquareWidth, RunnerSquareWidth)
end
gfx.setLineWidth(FielderCircleStrokeWidth)
for _, fielder in pairs(fielders) do
local x = (MinimapMultX * fielder.x) + MinimapOffsetX
local y = (MinimapMultY * fielder.y) + MinimapOffsetY
if x > MinimapPosX and x < MinimapBoundX and y > MinimapPosY and y < MinimapBoundY then
gfx.drawCircleAtPoint(x, y, FielderCircleRadius)
end
end
end
local BallStrikeMarginY <const> = 4
local BallStrikeWidth <const> = 60
local BallStrikeHeight <const> = (BallStrikeMarginY * 2) + ScoreFont:getHeight()
local BallStrikeAnimatorIn <const> =
playdate.graphics.animator.new(500, BallStrikeHeight, 0, playdate.easingFunctions.outBounce)
local BallStrikeAnimatorOut <const> =
playdate.graphics.animator.new(500, 0, BallStrikeHeight, playdate.easingFunctions.linear)
-- Start out of frame.
local currentBallStrikeAnimator = utils.staticAnimator(20)
function drawBallsAndStrikes(x, y, balls, strikes)
if balls == 0 and strikes == 0 then
if currentBallStrikeAnimator == BallStrikeAnimatorIn then
currentBallStrikeAnimator = BallStrikeAnimatorOut
currentBallStrikeAnimator:reset()
end
if currentBallStrikeAnimator:ended() then
return
end
end
if balls + strikes == 1 and currentBallStrikeAnimator ~= BallStrikeAnimatorIn then
-- First pitch - should pop in now.
currentBallStrikeAnimator = BallStrikeAnimatorIn
currentBallStrikeAnimator:reset()
end
y = y + currentBallStrikeAnimator:currentValue()
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, BallStrikeWidth, BallStrikeHeight)
local originalDrawMode = gfx.getImageDrawMode()
gfx.setImageDrawMode(gfx.kDrawModeInverted)
local text = tostring(balls) .. " - " .. tostring(strikes)
local textWidth = ScoreFont:getTextWidth(text)
local widthDiff = BallStrikeWidth - textWidth
ScoreFont:drawText(text, x + (widthDiff / 2), y + BallStrikeMarginY)
gfx.setImageDrawMode(originalDrawMode)
end
local OutBubbleRadius <const> = 5
local ScoreboardMarginX <const> = 6
local ScoreboardMarginRight <const> = 4
local ScoreboardHeight <const> = 55
local Indicator = "> "
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
---@param battingTeam any
---@return string, number, string, number
function getIndicators(battingTeam)
if battingTeam == "home" then
return Indicator, 0, "", IndicatorWidth
end
return "", IndicatorWidth, Indicator, 0
end
local stats = {
homeScore = 0,
awayScore = 0,
outs = 0,
inning = 1,
battingTeam = nil,
}
function drawScoreboardImpl(x, y)
local homeScore = stats.homeScore
local awayScore = stats.awayScore
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)
local rectWidth = (ScoreboardMarginX * 2)
+ ScoreboardMarginRight
+ ScoreFont:getTextWidth(homeScoreText)
+ homeOffset
gfx.setLineWidth(1)
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, rectWidth, ScoreboardHeight)
local originalDrawMode = gfx.getImageDrawMode()
gfx.setImageDrawMode(gfx.kDrawModeInverted)
ScoreFont:drawText(homeScoreText, x + ScoreboardMarginX + homeOffset, y + 6)
ScoreFont:drawText(awayScoreText, x + ScoreboardMarginX + awayOffset, y + 22)
local inningOffsetX = (x + ScoreboardMarginX + IndicatorWidth) + (4 * 2.5 * OutBubbleRadius)
ScoreFont:drawText(tostring(stats.inning), inningOffsetX, y + 39)
gfx.setImageDrawMode(originalDrawMode)
gfx.setColor(gfx.kColorWhite)
function circleParams(i)
local circleOffset = i * 2.5 * OutBubbleRadius
return (x + ScoreboardMarginX + OutBubbleRadius + IndicatorWidth) + circleOffset, y + 46, OutBubbleRadius
end
for i = stats.outs, 2 do
gfx.drawCircleAtPoint(circleParams(i))
end
for i = 0, (stats.outs - 1) do
gfx.fillCircleAtPoint(circleParams(i))
end
end
local newStats = stats
function drawScoreboard(x, y, statistics, outs, battingTeam, inning)
local homeScore, awayScore = utils.totalScores(statistics)
if
newStats.homeScore ~= homeScore
or newStats.awayScore ~= awayScore
or newStats.outs ~= outs
or newStats.inning ~= inning
or newStats.battingTeam ~= battingTeam
then
newStats = {
homeScore = homeScore,
awayScore = awayScore,
outs = outs,
inning = inning,
battingTeam = battingTeam,
}
playdate.timer.new(C.ScoreboardDelayMs, function()
stats = newStats
end)
end
drawScoreboardImpl(x, y)
end

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

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

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

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

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

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

View File

@ -1,220 +0,0 @@
ecs = {}
local allEntities <const> = {}
---@alias System { callback: fun(delta: number, entity: any, a: any, b: any, c: any, d: any, e: any, any), shapes: {}, keys: string[], entityCache: nil | table<any, boolean> }
---@type System[]
local systems <const> = {}
-- TODO: Add entity to any existing systems
function ecs.addEntity(entity)
allEntities[entity] = true
for _, system in pairs(systems) do
if entityMatchesShapes(entity, system.shapes) then
system.entityCache[entity] = true
else
system.entityCache[entity] = nil
end
end
end
function ecs.removeEntity(entity)
allEntities[entity] = nil
for _, system in pairs(systems) do
system.entityCache[entity] = nil
end
end
local Placeholder = {}
---@generic T
---@return T
function ecs.field()
return Placeholder
end
function allKeysIncluded(entity, filter)
for k, _ in pairs(filter) do
if not entity[k] then
return false
end
end
return true
end
function entityMatchesShapes(entity, shapes)
for _, shape in pairs(shapes) do
if not allKeysIncluded(entity, shape) then
return false
end
end
return true
end
---@generic T
---@generic U
---@generic V
---@generic W
---@param tShape T
---@param uShape U?
---@param vShape V?
---@param wShape W?
---@return fun(callback: fun(componentT: T, componentU: U, componentV: V, componentW: W))
function ecs.entitiesHavingShapes(tShape, uShape, vShape, wShape)
return function() end
end
-- Print contents of `tbl`, with indentation.
-- `indent` sets the initial level of indentation.
function tprint(tbl, indent)
if not indent then
indent = 0
end
for k, v in pairs(tbl) do
formatting = string.rep(" ", indent) .. k .. ": "
if type(v) == "table" then
print(formatting)
tprint(v, indent + 1)
elseif type(v) == "boolean" then
print(formatting .. tostring(v))
else
print(formatting .. v)
end
end
end
function addSystem(callback, keys, shapes)
systems[#systems + 1] = {
callback = callback,
keys = keys,
shapes = shapes,
entityCache = nil,
}
end
---@return boolean
function is(entity, shape)
return allKeysIncluded(entity, shape)
end
---@param deltaSeconds number
function ecs.update(deltaSeconds)
for _, system in pairs(systems) do
if not system.entityCache then
system.entityCache = {}
for entity, _ in pairs(allEntities) do
if entityMatchesShapes(entity, system.shapes) then
system.entityCache[entity] = true
end
end
end
local keys = system.keys
for entity, _ in pairs(system.entityCache) do
system.callback(
deltaSeconds,
entity,
entity[keys[1]],
entity[keys[2]],
entity[keys[3]],
entity[keys[4]],
entity
)
end
end
end
--- Returns a function that accepts a callback. This callback will receive one argument for each Shape provided.
---
---@generic T
---@generic TKey
---@generic U
---@generic UKey
---@generic V
---@generic VKey
---@generic W
---@generic WKey
---@param tShape { [TKey]: T }
---@param uShape { [UKey]: U } | fun(entity: any, componentT: T, any) | nil
---@param vShape { [VKey]: V } | fun(entity: any, componentT: T, componentU: U, any) | nil
---@param wShape { [WKey]: W } | fun(entity: any, componentT: T, componentU: U, componentV: V, any) | nil
---@param finalFunc fun(entity: any, componentT: T, componentU: U, componentV: V, componentW: W, any) | nil
function ecs.forEntitiesWith(tShape, uShape, vShape, wShape, finalFunc)
local maybeShapes = { tShape, uShape, vShape, wShape, finalFunc }
local shapes = {}
local callback
for _, maybeShape in pairs(maybeShapes) do
if type(maybeShape) == "table" then
shapes[#shapes + 1] = maybeShape
elseif type(maybeShape) == "function" then
callback = maybeShape
end
end
local keys = {}
for _, shape in pairs(shapes) do
for key, _ in pairs(shape) do
keys[#keys + 1] = key
end
end
addSystem(callback, keys, shapes)
end
local f = ecs.field()
local XYPair = { x = f, y = f }
local Position = { position = XYPair }
local Target = { target = XYPair }
local Velocity = { velocity = XYPair }
function ecs.overlayOnto(entity, value)
for key, v in pairs(value) do
entity[key] = v
end
ecs.addEntity(entity)
end
local data = {
position = { x = 0, y = 0 },
velocity = { x = 1, y = 2 },
}
---@generic T
---@param shape T
---@param entity unknown
---@return T
function ecs.get(shape, entity)
return entity
end
---@generic T
---@param entity unknown
---@param shape `T`
---@param value `T`
function ecs.set(entity, shape, value)
for key, v in pairs(shape) do
entity[key] = value[v]
end
end
ecs.addEntity(data)
ecs.forEntitiesWith(Position, Velocity, function(delta, e, pos, vel)
pos.x = pos.x + (delta * vel.x)
pos.y = pos.y + (delta * vel.y)
print("position")
tprint(pos, 1)
ecs.set(Target, e, {
--target = { x = 10, y = 10}
})
end)
ecs.forEntitiesWith(Target, function(delta, e, pos, vel)
pos.x = pos.x + (delta * vel.x)
pos.y = pos.y + (delta * vel.y)
print("position")
tprint(pos, 1)
ecs.set(e, Target, "hallo")
end)
ecs.update(1)

219
src/fielding.lua Normal file
View File

@ -0,0 +1,219 @@
--- @class Fielder {
--- @field name string
--- @field x number
--- @field y number
--- @field targets XyPair[]
--- @field speed number
--- @field spriteIndex number
---@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 Fielders
---@field fielderHoldingBall Fielder | nil
Fielding = {}
FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
---@param name string
---@param speed number
---@return Fielder
local function newFielder(name, speed)
return {
name = name,
speed = speed * C.FielderRunMult,
spriteIndex = math.random(#HomeTeamSpriteGroup),
}
end
function Fielding.new()
return setmetatable({
fielders = {
first = newFielder("First", 40),
second = newFielder("Second", 40),
shortstop = newFielder("Shortstop", 40),
third = newFielder("Third", 40),
pitcher = newFielder("Pitcher", 30),
catcher = newFielder("Catcher", 35),
left = newFielder("Left", 50),
center = newFielder("Center", 50),
right = newFielder("Right", 50),
},
---@type Fielder | nil
fielderHoldingBall = nil,
}, { __index = Fielding })
end
--- Actually only benches the infield, because outfielders are far away!
---@param position XyPair
function Fielding:benchTo(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.
---@param immediate boolean | nil
function Fielding:resetFielderPositions(fromOffTheField, immediate)
if fromOffTheField then
for _, fielder in pairs(self.fielders) do
fielder.x = fromOffTheField.x
fielder.y = fromOffTheField.y
end
end
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 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
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.targets[#fielder.targets]) then
table.remove(fielder.targets, #fielder.targets)
end
end
-- TODO: Clean this up, like, a lot.
-- I'd love to avoid any "real" pathfinding implementation, but these huge target queues are liable to be an issue.
-- The worst case came when a ball was hit far, but not in a way that the game classed as a home run.
-- Maybe this queueing would be fine if that issue was resolved
if #fielder.targets >= 10 then
fielder.targets = { utils.xy(fielder.x, fielder.y + 100) }
end
assert(#fielder.targets < 10, "Fielder " .. fielder.name .. " is accruing too many target positions!")
return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
end
-- TODO: Prevent multiple fielders covering the same base.
-- At least in a how-about-everybody-stand-right-here way.
--- Selects the nearest fielder to move toward the given coordinates.
--- Other fielders should attempt to cover their bases
---@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)
-- 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.targets = { base }
end
end
--- **Also updates `ball.heldby`**
---@param ball Ball
---@param deltaSeconds number
---@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
-- TODO: Base this catch on fielder skill?
local canCatch = updateFielderPosition(deltaSeconds, fielder, ball)
if canCatch then
fielderHoldingBall = fielder
ball.heldBy = fielder -- How much havoc will this wreak?
if ball.isFlyBall then
ball.isFlyBall = false
caughtAFlyBall = true
end
end
end
self.fielderHoldingBall = fielderHoldingBall
return fielderHoldingBall, caughtAFlyBall
end
-- TODO? Start moving target fielders close sooner?
---@param field Fielding
---@param targetBase Base
---@param ball { launch: LaunchBall }
---@param throwFlyMs number
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
while true do
if field.fielderHoldingBall == nil then
coroutine.yield()
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.targets = { targetBase }
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
return
end
end
end
--- Buffer in a fielder throw action.
---@param self table
---@param targetBase Base
---@param ball { launch: LaunchBall }
---@param throwFlyMs number
function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
local maxTryTimeMs = 5000
actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
userThrowToCoroutine(self, targetBase, ball, throwFlyMs)
end)
end
function Fielding.celebrate()
FielderDanceAnimator:reset(C.DanceBounceMs)
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return { Fielding, newFielder }
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,40 +1,63 @@
--- 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(screenW, screenH, ballX, ballY)
function getDrawOffset(ballX, ballY)
local offsetX, offsetY
if ballY > screenH then
if ballY > C.Screen.H or ballX >= C.BallOffscreen then
return 0, 0
end
offsetY = math.max(0, -1 * 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 < screenW then
if ballX >= 0 and ballX <= C.Screen.W then
offsetX = 0
elseif ballX < 0 then
offsetX = math.max(-1 * screenW, ballX * -1)
elseif ballX > screenW then
offsetX = math.min(screenW * 2, (ballX * -1) + screenW)
offsetX = math.max(-1 * C.Screen.W, ballX * -1)
elseif ballX > C.Screen.W then
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
end
return offsetX * 1.3, offsetY * 1.5
return offsetX * 1.3, offsetY
end
-- selene: allow(unscoped_variables)
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
---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number)
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, imagePath1, imagePath2)
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,
image1 = playdate.graphics.image.new(imagePath1),
image2 = playdate.graphics.image.new(imagePath2),
draw = function(self, disableBlipping, x, y)
local currentImage = (disableBlipping or self.blinker.on) and self.image2 or self.image1
currentImage:draw(x, y)
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,
}
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 B

154
src/main-menu.lua Normal file
View File

@ -0,0 +1,154 @@
-- stylua: ignore start
import "control-screen.lua"
-- stylua: ignore end
---@class MainMenu
MainMenu = {
---@type { new: fun(settings: Settings): { update: fun(self) } }
next = nil,
}
local gfx = playdate.graphics
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 = 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,
homeTeamSpriteGroup = HomeTeamSpriteGroup,
awayTeamSpriteGroup = AwayTeamSpriteGroup,
})
playdate.resetElapsedTime()
transitionBetween(MainMenu, next)
MenuMusic:setPaused(true)
end
---@param baseEaser EasingFunc
---@return EasingFunc
local function pausingEaser(baseEaser)
--- t: elapsedTime
--- d: duration
return function(t, b, c, d)
local percDone = t / d
if percDone > 0.9 then
t = d
elseif percDone < 0.1 then
t = 0
else
t = (percDone - 0.1) * 1.25 * d
end
return baseEaser(t, b, c, d)
end
end
local animatorX = gfx.animator.new(2000, 30, 350, pausingEaser(playdate.easingFunctions.linear))
animatorX.repeatCount = -1
animatorX.reverses = true
local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill))
animatorY.repeatCount = -1
animatorY.reverses = true
---@type number
local crankStartPos
---@generic T
---@param array T[]
---@param crankPosition number
---@return T
local function arrayElementFromCrank(array, crankPosition)
local i = math.ceil(#array * (crankPosition + 0.001) / 360)
return array[i]
end
---@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
playdate.timer.updateTimers()
crankStartPos = crankStartPos or playdate.getCrankPosition()
gfx.clear()
if playdate.getCrankChange() ~= 0 then
local crankOffset = (crankStartPos - playdate.getCrankPosition()) % 360
currentLogo = arrayElementFromCrank(Logos, crankOffset).image
replaceAwayLogo(currentLogo)
end
if currentLogo then
currentLogo:drawScaled(20, C.Center.y + 40, 3)
end
if playdate.buttonJustPressed(playdate.kButtonUp) or playdate.buttonJustPressed(playdate.kButtonRight) then
inningCountSelection = math.min(99, inningCountSelection + 1)
end
if playdate.buttonJustPressed(playdate.kButtonDown) or playdate.buttonJustPressed(playdate.kButtonLeft) then
inningCountSelection = math.max(1, inningCountSelection - 1)
end
local logoCenter = 90
GameLogo:drawCentered(C.Center.x, logoCenter)
TinyFont:drawTextAligned("a game by Sage", C.Center.x, logoCenter + 35, 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(),
y = animatorY:currentValue(),
z = 6,
size = 6,
}
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
if not ballIsHeld then
gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(ball.x, ball.y, ball.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(ball.x, ball.y, ball.size)
end
end

File diff suppressed because it is too large Load Diff

177
src/npc.lua Normal file
View File

@ -0,0 +1,177 @@
local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1000
local npcBatSpeed = BaseNpcBatSpeed
---@class Npc: InputHandler
---@field runners Runner[]
---@field fielders Fielder[]
-- selene: allow(unscoped_variables)
Npc = {}
---@param runners Runner[]
---@param fielders Fielder[]
---@return Npc
function Npc.new(runners, fielders)
return setmetatable({
runners = runners,
fielders = fielders,
}, { __index = Npc })
end
function Npc.update() end
-- TODO: FAR more nuanced NPC batting.
-- luacheck: no unused
---@param ball XyPair
---@param pitchIsOver boolean
---@param deltaSec number
---@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 = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360)
end
return npcBatDeg, (self:batSpeed() * deltaSec)
end
---@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
---@param runner Runner
---@param ball Point3d
---@return number
function Npc:runningSpeed(runner, ball)
if #self.runners == 0 then
return 0
end
local distanceFromBall = utils.distanceBetweenZ(ball.x, ball.y, ball.z, runner.x, runner.y, 0)
if distanceFromBall > 400 or runner.forcedTo then
return baseRunningSpeed
end
local touchedBase = utils.isTouchingBase(runner.x, runner.y)
if not touchedBase and runner.nextBase then
local distToNext = utils.distanceBetween(runner.x, runner.y, runner.nextBase.x, runner.nextBase.y)
local distToPrev = utils.distanceBetween(runner.x, runner.y, runner.prevBase.x, runner.prevBase.y)
if distToNext < distToPrev or distanceFromBall > 350 then
return baseRunningSpeed
else
return -1 * baseRunningSpeed
end
end
return 0
end
---@param runners Runner[]
---@return Base[]
local function getForcedOutTargets(runners)
local targets = {}
for _, base in ipairs(C.Bases) do
local runnerTargetingBase = utils.getRunnerWithNextBase(runners, base)
if runnerTargetingBase then
targets[#targets + 1] = base
else
return targets
end
end
return targets
end
--- Returns the position,distance of the base closest to the runner who is *furthest* from a base
---@param runners Runner[]
---@return Base | nil, number | nil
local function getBaseOfStrandedRunner(runners)
local farRunnersBase, farDistance
for _, runner in pairs(runners) do
--if runner ~= batter then
local nearestBase, distance = utils.getNearestOf(C.Bases, runner.x, runner.y)
if farRunnersBase == nil or farDistance < distance then
farRunnersBase = nearestBase
farDistance = distance
end
--end
end
return farRunnersBase, farDistance
end
--- Returns x,y of the out target
---@param runners Runner[]
---@return number|nil, number|nil
local function getNextOutTarget(runners)
-- TODO: Handle missed throws, check for fielders at target, etc.
local targets = getForcedOutTargets(runners)
if #targets ~= 0 then
return targets[#targets].x, targets[#targets].y
end
local baseCloseToStrandedRunner = getBaseOfStrandedRunner(runners)
if baseCloseToStrandedRunner then
return baseCloseToStrandedRunner.x, baseCloseToStrandedRunner.y
end
end
---@param fielders Fielder[]
---@param fielder Fielder
---@param runners Runner[]
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners)
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
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end
end
---@param fielder Fielder
---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
function Npc:fielderAction(fielder, outedSomeRunner, ball)
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)
end
---@return number
function Npc:pitchSpeed()
return 2
end
if not playdate then
return Npc
end

View File

@ -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

222
src/pitching.lua Normal file
View File

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

View File

@ -1,83 +0,0 @@
local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")
local OutBubbleRadius <const> = 5
local ScoreboardMarginX <const> = 6
local ScoreboardMarginRight <const> = 4
local ScoreboardHeight <const> = 55
local Indicator = "> "
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
local BallStrikeMarginY <const> = 4
local BallStrikeWidth <const> = 60
local BallStrikeHeight <const> = (BallStrikeMarginY * 2) + ScoreFont:getHeight()
---@param teams any
---@param battingTeam any
---@return string, number, string, number
function getIndicators(teams, battingTeam)
if teams.home == battingTeam then
return Indicator, 0, "", IndicatorWidth
end
return "", IndicatorWidth, Indicator, 0
end
function drawBallsAndStrikes(x, y, balls, strikes)
if balls == 0 and strikes == 0 then
return
end
local gfx = playdate.graphics
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, BallStrikeWidth, BallStrikeHeight)
local originalDrawMode = gfx.getImageDrawMode()
gfx.setImageDrawMode(gfx.kDrawModeInverted)
local text = tostring(balls) .. " - " .. tostring(strikes)
local textWidth = ScoreFont:getTextWidth(text)
local widthDiff = BallStrikeWidth - textWidth
ScoreFont:drawText(text, x + (widthDiff / 2), y + BallStrikeMarginY)
gfx.setImageDrawMode(originalDrawMode)
end
function drawScoreboard(x, y, teams, outs, battingTeam, inning)
local gfx = playdate.graphics
local homeScore = teams.home.score
local awayScore = teams.away.score
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, battingTeam)
local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore)
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
local rectWidth = (ScoreboardMarginX * 2)
+ ScoreboardMarginRight
+ ScoreFont:getTextWidth(homeScoreText)
+ homeOffset
gfx.setLineWidth(1)
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, rectWidth, ScoreboardHeight)
local originalDrawMode = gfx.getImageDrawMode()
gfx.setImageDrawMode(gfx.kDrawModeInverted)
ScoreFont:drawText(homeScoreText, x + ScoreboardMarginX + homeOffset, y + 6)
ScoreFont:drawText(awayScoreText, x + ScoreboardMarginX + awayOffset, y + 22)
local inningOffsetX = (x + ScoreboardMarginX + IndicatorWidth) + (4 * 2.5 * OutBubbleRadius)
ScoreFont:drawText(inning, inningOffsetX, y + 39)
gfx.setImageDrawMode(originalDrawMode)
gfx.setColor(gfx.kColorWhite)
function circleParams(i)
local circleOffset = i * 2.5 * OutBubbleRadius
return (x + ScoreboardMarginX + OutBubbleRadius + IndicatorWidth) + circleOffset, y + 46, OutBubbleRadius
end
for i = outs, 2 do
gfx.drawCircleAtPoint(circleParams(i))
end
for i = 0, (outs - 1) do
gfx.fillCircleAtPoint(circleParams(i))
end
end

61
src/statistics.lua Normal file
View File

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

76
src/test/mocks.lua Normal file
View File

@ -0,0 +1,76 @@
utils = require("utils")
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,
},
},
}
---@type Announcer
local mockAnnouncer = {
say = function(self, message)
self.lastMessage = message
end,
}
return { mockPlaydate, mockAnnouncer }

38
src/test/setup.lua Normal file
View File

@ -0,0 +1,38 @@
import = function() end
luaunit = require("luaunit")
luaunit.ORDER_ACTUAL_EXPECTED = false
utils = require("utils")
C = require("constants")
local mocks = require("test/mocks")
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

View File

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

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

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

View File

@ -0,0 +1,173 @@
require("test/setup")
local Baserunning = require("baserunning")
---@return Baserunning, { called: boolean }
function buildBaserunning()
local thirdOutCallbackData = { called = false }
local baserunning = Baserunning.new(announcer, function()
thirdOutCallbackData.called = true
end)
return baserunning, thirdOutCallbackData
end
---@alias BaseIndexOrXyPair (number | XyPair)
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
---@param runnerLocations BaseIndexOrXyPair[]
---@return Baserunning
function buildRunnersOn(runnerLocations)
local baserunning = buildBaserunning()
baserunning:convertBatterToRunner()
for _, location in ipairs(runnerLocations) do
baserunning:pushNewBatter()
local runner = baserunning.batter
baserunning:convertBatterToRunner()
if type(location) == "number" then
-- Is a base index
-- Push the runner *through* each base.
for b = 1, location do
runner.x = C.Bases[b].x
runner.y = C.Bases[b].y
baserunning:updateNonBatterRunners(0.001, false, false, 0.001)
end
else
-- Is a raw XyPair
runner.x = location.x
runner.y = location.y
end
end
return baserunning
end
---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] }
---@param expectedOuts number
---@param fielderWithBallAt XyPair
---@param when number[][]
function assertRunnerOutCondition(expectedOuts, when, fielderWithBallAt)
for _, runnersOn in ipairs(when) do
local baserunning = buildRunnersOn(runnersOn)
baserunning:outEligibleRunners(fielderWithBallAt)
luaunit.assertEquals(expectedOuts, baserunning.outs, "Incorrect number of outs.")
end
end
---@param condition Condition
function assertRunnerStatuses(condition)
assertRunnerOutCondition(1, condition.outWhen, condition.fielderWithBallAt)
assertRunnerOutCondition(0, condition.safeWhen, condition.fielderWithBallAt)
end
function testForceOutsAtFirst()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.First],
outWhen = {
{},
{ 1 },
{ 2 },
{ 3 },
{ 1, 2 },
{ 1, 3 },
{ 2, 3 },
{ 1, 2, 3 },
},
safeWhen = {},
})
end
function testForceOutsAtSecond()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Second],
outWhen = {
{ 1 },
{ 1, 2 },
{ 1, 3 },
{ 1, 2, 3 },
},
safeWhen = {
{},
{ 2 },
{ 3 },
{ 2, 3 },
},
})
end
function testForceOutsAtThird()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {
{ 1, 2 },
{ 1, 2, 3 },
},
safeWhen = {
{ 1 },
{ 2 },
{ 3 },
{ 2, 3 },
{ 1, 3 },
},
})
end
function testForceOutsAtHome()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Home],
outWhen = {
{ 1, 2, 3 },
},
safeWhen = {
{},
{ 1 },
{ 2 },
{ 3 },
{ 1, 2 },
{ 1, 3 },
{ 2, 3 },
},
})
end
function testTagOutsShouldHappenOffBase()
local fielderWithBallAt = utils.xy(10, 10) -- Some location not on a base.
local farFromFielder = utils.xy(100, 100)
assertRunnerStatuses({
fielderWithBallAt = fielderWithBallAt,
outWhen = {
{ fielderWithBallAt },
},
safeWhen = {
{ farFromFielder },
},
})
end
function testTagOutsShouldNotHappenOnBase()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {},
safeWhen = {
{ 2 },
{ 3 },
{ 2, 3 },
{ 2, 3, 4 },
},
})
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())

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