From e4f2e07cdc28f5aaf5bbe6e24cc841a72aa757ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 02:17:07 +0000 Subject: [PATCH 01/19] Remove micro-game and the platformer debug-warp; tidy example lists - Delete examples/box2dxt-microgame.livecodescript (focus is the platformer showcase) and drop it from the embedded-Kit sync list. - Remove the platformer's '0'-key DEBUG WARP block (it was marked 'delete before merge' - a swim-pool teleport left over from Wave 4). - Update the README and CLAUDE.md example lists (seven -> six). https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- CLAUDE.md | 6 +- README.md | 3 - examples/box2dxt-microgame.livecodescript | 5822 -------------------- examples/box2dxt-platformer.livecodescript | 10 - tools/sync-embedded-kit.py | 1 - 5 files changed, 3 insertions(+), 5839 deletions(-) delete mode 100644 examples/box2dxt-microgame.livecodescript diff --git a/CLAUDE.md b/CLAUDE.md index 0f19739..471f736 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,9 +36,9 @@ Docs live in `docs/` (`architecture.md`, `building.md`, `getting-started.md`, `a `kit-guide.md`, `kit-reference.md`, `game-engine-spec.md`, `expansion-prep.md`). Drop-in prebuilt binaries are in `prebuilt/`. The **Game Kit** (input/sprites/player/camera/sound modules, plan.md Phases 0-5) is implemented and user-verified on Win32; `plan.md`'s decision log is the as-built -record. Seven examples: demo, contraption builder, spike (Phase-0 harness), **platformer showcase**, -**micro-game** (the "copy this to start a game" file), **slingshot** (angry-birds-style tower -knockdown — the physics core carrying a whole game with zero events and zero assets), and the +record. Six examples: demo, contraption builder, spike (Phase-0 harness), **platformer showcase** +(the Game Kit pushed hard — the focus of this repo's game work), **slingshot** (angry-birds-style +tower knockdown — the physics core carrying a whole game with zero events and zero assets), and the **self-test harness** (below). ## The golden rule: the embedded-Kit sync diff --git a/README.md b/README.md index 4aeef3b..0e6ff9e 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,6 @@ Box2D v3.1.0 (fetched by CMake) [contraption builder](examples/box2dxt-contraption-builder.livecodescript): a full build-and-run physics sandbox with fans, magnets, lasers, bombs, motors, and save/load. Game-minded? The - [micro-game](examples/box2dxt-microgame.livecodescript) is a complete - two-level platformer (menu → levels → win screen, embedded art, - synthesized sound), the [platformer showcase](examples/box2dxt-platformer.livecodescript) is the Game Kit pushed hard — player controller (run/jump/climb/duck/swim), scrolling camera, spritesheets, coin puzzles, a hilltop swim pool — and the diff --git a/examples/box2dxt-microgame.livecodescript b/examples/box2dxt-microgame.livecodescript deleted file mode 100644 index 3aa48d3..0000000 --- a/examples/box2dxt-microgame.livecodescript +++ /dev/null @@ -1,5822 +0,0 @@ --- ===================================================================== --- box2dxt-microgame.livecodescript · "build a game" in one file --- --- The Game Kit Phase 5 exit artifact: a COMPLETE micro-game - start --- screen, two levels, a win screen - in a few hundred lines of card --- logic, with NOTHING to install beyond the box2dxt extension. No --- asset folder: the hero spritesheet is embedded as base64 and every --- sound is synthesized (b2kToneMake). This file is also the teaching --- companion for the kit guide's "Building a whole game" chapter; if --- you are starting your own game, start by copying this. --- --- What it demonstrates that the big platformer demo does not: --- * b2kPlayerMake - the ONE-CALL player (body host + capsule + --- sprite + controller + input), vs the platformer's adopt path. --- * Levels as DATA: each level is a few lines of text ("slab ...", --- "coin ...", "door ..."), interpreted by ~100 lines of mgBuild. --- Add verbs as your game needs them - this is the example-level --- scene pattern the plan's Phase 5 calls for. --- * A game-state machine (menu / play / won) gated with --- b2kPlayerControl, so the world runs behind the menus. --- * Wave 2: OPTIONAL alien SKINS. The game stays ZERO-ASSET - the --- embedded base64 hero always works - but when the repo's --- Spritesheets folder is already known (the same stack property --- the platformer sets; this game never prompts), the menu offers --- five alien colours on keys 1-6. Aliens carry duck/climb/hurt --- frames, so Wave 2's poses show fully on them. --- --- HOW TO RUN --- 1. In OXT, make a new stack. Put this whole file into the STACK --- script. Make sure the box2dxt extension is loaded. --- 2. Close and reopen the stack. Click to start. That is all. --- --- CONTROLS arrows / A-D run · SPACE / UP / W jumps (tap = hop, hold = --- full) · DOWN ducks · DOWN+JUMP on the green LEDGE drops --- through it · UP/DOWN at the L2 ladder climbs (JUMP lets --- go) · R restarts the level · ESC pauses · M mutes · --- menu keys 1-6 pick the skin (when the folder is known) --- --- WHAT TO VERIFY (OXT pass) --- 1. Paste-and-play with NO asset folder: embedded hero art animates, --- synthesized sounds play, click starts level 1 (no skin line on --- the menu, no prompt - silence is the zero-asset contract). --- 2. b2kPlayerMake feel: run/jump/coyote/buffer/jump-cut all behave --- like the platformer demo (same controller, green-field path). --- 3. The door stays GREY until every coin is taken, then turns green --- with a chime; touching it open advances L1 -> L2 -> win screen. --- 4. WAVE 2 SPLIT: spikes and the patrolling SWEEPER now KNOCK YOU --- BACK (hit pose, ~1s mercy, "hits" counts on the HUD) - only --- FALLING OFF THE WORLD respawns you at the spawn ("falls"). --- 5. WAVE 2 MOVES: DOWN ducks (brakes to a crouch); DOWN+JUMP on --- L1's green ledge drops through it; the L2 ladder (drawn rungs --- by the exit pillar) climbs to the door - JUMP exits the climb. --- 6. SKINS (only with the Spritesheets folder known, e.g. after one --- platformer run): the menu lists keys 1-6; each rebuilds level 1 --- with that skin; aliens duck/climb/hurt with REAL frames; key 1 --- returns to the embedded hero. Without the folder nothing asks. --- 7. Level 2 scrolls (1536px world); the camera follows centre-lock. --- 8. Win screen shows time + falls + hits; click restarts at level --- 1; R restarts a level mid-run; ESC pauses; M mutes; reopen = --- clean. --- ===================================================================== - -local gMode -- menu | play | won (the game-state machine) -local gLevel, gCoins, gCoinsTotal, gFalls, gRunStart, gWinSecs -local gHero, gHeroSpr, gDoor, gDoorOpen, gSpawnX, gSpawnY -local gHudLast, gHudNextMS, gHurtLock, gBuilding, gPrevState, gWorldW, gWorldH -local gSwpN, gSwpRef, gSwpC, gSwpA, gSwpP, gSwpY, gSwpW, gSwpH -local gFishN, gFishRef, gFishX, gFishYC, gFishYA, gFishP, gFishW, gFishH -- Wave 4 pit-dwellers -local gOuches -- Wave 2: knockback hits (falls stay falls) -local gSkin -- "hero" (embedded) or an alien colour word -local gSkinsOK -- true when the aliens atlas loaded (folder known) - -constant kMgUIVersion = "1" -constant kMgHeroB64 = "iVBORw0KGgoAAAANSUhEUgAAAYAAAAEACAYAAAC6d6FnAAAI/ElEQVR42u3dPVLjShgFUJlyxCJwTOqVQEDORojYCDkBrMSpY7EIUl4knixjj9vqH7V0TtVUYaZLvhZPffUZ3tA0AAAAAAAAAAAAAAAAAAAAAAAAAABAHquxB3h7ufsZe4zn16+VLwVAJQUQY+OfQhEosLKcf6ioAIYX7NPDdnSI989d9gtagdWfWxFAxgLoX7QxNv5zRZDqYlZgZTfQuZx/WFQBpN78c5SAAiu7gc7h/MPiCiDX5p/yQlZg886uBCDcTcjiHJt/iufJWWD948d8mybHa6g5e+rXAIssgO4iyrX5Dy/kmBexAiu7gdZ6/sEEUCkFVvZ55nT+YVEFUOridRHbQAETQJWbp03U+QcFgA0UmKx1rAPdbj8OHn/vHqOuB2CCBTDczLvPndrUQ9fnoMDKcv6hwgIYXoibzf3vx227P7owQ9eX2HwUmPMPS3AT88Ltb+bd4/6a0PUlNp/N5v73z6mNJmR9yQ001nrnHxTA7N92UGAfzj8oABQYoAAAUABDw2+6te3+6HF/Teh6ACY8Afy1qXd//trMQ9enpMDKcv6h8gL468I89blr1+fehBSY8w9LsE51Icden3oTCvm5+ND1ObP3N/VTG2jIeucfFMDi3o5QYM4/KAAUGDBLfgwUQAEAoAAAUAAAKAAAFAAACgAABQCAAgBAAQCgAACoowCeX79WTdM075+7IuG65+1yhJJf/jH5wQQAwDILoNRdXKy7N/nld5lDhAkg10Wc6nnklx8ILID+XVTqi6t//Fh3b/LLD4yYAHJcxCkvXvnlBw4FXyRvL3c/5/7+6WE7ekRPefHKLz9wZQFcchHHulMstQnJLz8sgR8DBVio6L8U/pI7sJR3gPLLD5gAAFAAACgAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAksop5sBi/q/WS3wmbivzyyy//kvKvphK85BdCfvnll3+J+Vcxgz89bEcHf//cZftCyC+//PIvOf8qRvgYwc+9kBRfBPnll1/+pee/mWL44XFjj0jyyy+//PJfUQA5wqf8Isgvv/zyyz9iAsgRPmdDyi+//PIvMX9QAXQtkiv88EWMbWH55ZdffvkjTAAA1O3iAijVXrFaWH755Zdf/sP8JgAAEwAAS7JOcdDb7cfB4+/dY9T1qckvv/zyLyH/Terwpz537frcJ19++eWXf6751ynDbzb3vx+37f6omULX5z758ssvv/xzzn+TI3z3uL8mdH3Jky+//PLLP8f8vgkMsFAKAEABAKAArjD8hkPb7o8e99eErk9Nfvnll39p+dexX0T/Gw/9UH+FCV2f44sgv/zyy7+U/DcpXsQln7t2fe4mll9++eWfa/51rhcRc32JL4L88ssv/9zy+yYwwEIpAAAFAIACAEABAKAAAFAAACgAABQAAAoAAAUAgAIAoL4CeH79WjVN07x/7ooE7Z63yxFKfvnll1/+w/wmAAATwHRbbGz7yi+//PLLf5z/ZuxBc4Wv5bjyyy+//LXkDy6AfoukfhH9449tX/nll19++SNMADleRIqTL7/88ssv//9GvbC3l7ufc3//9LAdPaLEPvnyyy+//PJHKIBLXkSspiz1RZBffvnln2t+PwYKsFDrlAe/pIFSNqD88ssvv/yFCoDpyzHmgv9+FzgB+A8Q59/5Y7rn3/cAABZKAQAAAAAAAAAAAAAAAABM3CT+OWj/OzpARQWQ4l/RUwQAEy6A4cZ/yW+t+Zfhb7XJWQQmGEABBG6WMTb+c0WQelM1wQAKYCKbf64SmNsEA5C0AHJt/qlLYE4TDMBYQf8cdI7NP9Xz5Cix/nGn/KvmAC4qgG4jy7X5DzfTGBtpzglGCQCznABqV/MEA5C1AErd/cecAuYwwQCYAABQAFO++zcFADVYxzrQ7fbj4PH37jHqegAmOAEMN/NTn7t2PQATnACGG/dmc//7cdvuj+7sQ9fnYoIBTACRNv/ucX9N6PpSm78JBjABLMBcJhiArBPAnDf/miYYAAUAQJ4CGL7d0bb7o8f9NaHrAUhn9PcAvnePB2979Df1vzbz0PUATHAC+NdGH2t9KiYYwASQqARirk9ZAiYYwASwULVOMACTmADmWAI1TDAAJgAAFAAACgAABQCAAgBAAQAoAAAUAAAKAAAFAIACAEABAKAAAFAAACgAABQAAAoAgCkXwPPr16ppmub9c1ckXPe8XY5QtecHMAEAkL8ASt1Fx7p7rj0/wCQmgFybaKrnqT0/QPYC6N/Fpt7c+sePdfdce36AohNAjk005eZZe36A2II3qbeXu59L1j09bM9ukJdu1rHVnh+gWAGEbqTX3qWnVnt+gLGu/jHQlG/R5FB7foCx1jk32BR33fIDZJ4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJZgNfYAby93P2OP8fz6tfKlAKikAGJs/IoAoKICGG78Tw/b0SHeP3fFisAEAyiAwM0yxsZ/rghSb6omGEABTGTzz1UCc5tgAJIWQK7NP3UJzGmCARjrJmRxjs0/1fPkKLH+cVO8xQSQtQC6jSzX5j/cTGNspDknGCUAzHICqF3NEwxA1gIodfcfcwqYwwQDYAIAQAFM+e7fFADUYB3rQLfbj4PH37vHqOsBmOAEMNzMT33u2vUATHACGG7cm83978dtuz+6sw9dn4sJBjABRNr8u8f9NaHrS23+JhjABLAAc5lgALJOAHPe/GuaYAAUAAB5CmD4dkfb7o8e99eErgcgndHfA/jePR687dHf1P/azEPXAzDBCeBfG32s9amYYAATQKISiLk+ZQmYYAATwELVOsEATGICmGMJ1DDBAJgAAFAAACgAABQAAAoAAAUAoAAAUAAALLwAnl+/Vk3TNO+fuyLhuuftcoSqPT+ACQCA/AVQ6i461t1z7fkBJjEB5NpEUz1P7fkBshdA/y429ebWP36su+fa8wMUnQBybKIpN8/a8wPEdvEm9fZy93Pq754etlE2zXOb9li15weILcrvA6j9PW/v2QNL5MdAAUwA1xvzVse5t2ZyqT0/gAkAAAUAgAIAQAEA0PkPK875jF7ESN4AAAAASUVORK5CYII=" - --- ===================================================================== --- Lifecycle --- ===================================================================== -on openCard - if the uMgUIVersionTag of this stack is not kMgUIVersion then buildMgUI - mgBuild 1 - mgShowMenu - pass openCard -end openCard - -on closeCard - b2kStop - pass closeCard -end closeCard - --- ===================================================================== --- Chrome (version-tagged; rebuilds once when a newer file is pasted in) --- ===================================================================== -command buildMgUI - if there is a field "mgTitle" then delete field "mgTitle" - if there is a field "mgHud" then delete field "mgHud" - if there is a field "mgBigText" then delete field "mgBigText" - if there is a graphic "mgShade" then delete graphic "mgShade" - set the width of this stack to 1024 - set the height of this stack to 640 - set the loc of this stack to the screenLoc - try - set the title of this stack to "Box2Dxt - Micro-game" - catch tErr - end try - create field "mgTitle" - set the rect of it to 20, 6, 1004, 26 - set the lockText of it to true - set the traversalOn of it to false - set the textSize of it to 13 - set the text of it to "Box2Dxt micro-game - a whole game on the Kit (b2kPlayerMake + data levels + tones)" - create field "mgHud" - set the rect of it to 20, 612, 1004, 636 - set the lockText of it to true - set the traversalOn of it to false - set the textSize of it to 12 - -- one overlay pair serves the menu AND the win screen - create graphic "mgShade" - set the style of it to "rectangle" - set the rect of it to 0, 0, 1024, 640 - set the filled of it to true - set the backgroundColor of it to "16,18,26" - set the blendLevel of it to 35 - create field "mgBigText" - set the rect of it to 112, 160, 912, 440 - set the lockText of it to true - set the traversalOn of it to false - set the textAlign of it to "center" - set the textSize of it to 20 - set the textStyle of it to "bold" - set the opaque of it to false - set the showBorder of it to false - set the textColor of it to "245,245,250" - set the uMgUIVersionTag of this stack to kMgUIVersion -end buildMgUI - -command mgChromeFront - local tN - put the number of controls of this card into tN - if there is a graphic "mgShade" then set the layer of graphic "mgShade" to tN - 1 - if there is a field "mgBigText" then set the layer of field "mgBigText" to tN - if there is a field "mgTitle" then set the layer of field "mgTitle" to tN - if there is a field "mgHud" then set the layer of field "mgHud" to tN -end mgChromeFront - --- ===================================================================== --- The game-state machine: menu -> play -> won -> (again) --- ===================================================================== -command mgShowMenu - local tMsg - put "menu" into gMode - put "B O X 2 D X T M I C R O - G A M E" & cr & cr & "two levels - collect EVERY coin to open the door" & cr & cr & "arrows / A-D run · SPACE jumps (tap = hop, hold = full)" & cr & "DOWN ducks - and DROPS through the green ledge with JUMP" & cr & "R restarts the level · ESC pauses · M mutes" into tMsg - -- the skin line only exists when the aliens atlas loaded: with no - -- asset folder the menu stays exactly as always (zero-asset) - if gSkinsOK is true then - put cr & cr & "SKIN: 1 classic · 2 beige · 3 blue · 4 green · 5 pink · 6 yellow (now: " & gSkin & ")" after tMsg - end if - put cr & cr & "C L I C K T O S T A R T" after tMsg - set the text of field "mgBigText" to tMsg - set the visible of graphic "mgShade" to true - set the visible of field "mgBigText" to true - mgChromeFront -end mgShowMenu - -command mgBegin - put "play" into gMode - set the visible of graphic "mgShade" to false - set the visible of field "mgBigText" to false - put 0 into gFalls - put 0 into gOuches - put the milliseconds into gRunStart - b2kPlayerControl true -- the world was already running behind the menu - b2kSound "start" -end mgBegin - -command mgAdvance - if gLevel < 3 then - -- rebuild OUTSIDE this physics frame: we are inside the sensor - -- dispatch right now, and tearing the world down mid-frame is - -- asking for trouble. The short beat also lets the door chime ring. - send "mgGoNext" to me in 80 milliseconds - exit mgAdvance - end if - mgShowWin -end mgAdvance - -on mgGoNext - if gMode is not "play" then exit mgGoNext - mgBuild gLevel + 1 - b2kPlayerControl true -- straight into the next level, clocks keep running -end mgGoNext - -command mgShowWin - local tMsg - put "won" into gMode - put (the milliseconds - gRunStart) div 1000 into gWinSecs - b2kPlayerControl false - set the itemDelimiter to comma - b2kSetVelocity gHero, 0, item 2 of b2kVelocity(gHero) - b2kSound "win" - put "Y O U W I N !" & cr & cr into tMsg - put "all three levels, every coin" & cr after tMsg - put "time " & format("%d:%02d", gWinSecs div 60, gWinSecs mod 60) & " falls " & gFalls & " hits " & gOuches & cr & cr after tMsg - if gFalls is 0 and gOuches is 0 then put "flawless - untouched, never fell" & cr & cr after tMsg - put "C L I C K T O P L A Y A G A I N" after tMsg - set the text of field "mgBigText" to tMsg - set the visible of graphic "mgShade" to true - set the visible of field "mgBigText" to true - mgChromeFront -end mgShowWin - --- ===================================================================== --- Sounds: all synthesized, built once (they survive b2kTeardown) --- ===================================================================== -command mgMakeSounds - if b2kSoundIsLoaded("start") then exit mgMakeSounds - b2kToneMake "start", "392,523,659", 70, 55 - b2kToneMake "jump", "392,587", 40, 55 - b2kToneMake "coin", "1319,1760", 36, 45 - b2kToneMake "hurt", "220,156,110", 55, 65 - b2kToneMake "land", "98", 26, 40, "sine" - b2kToneMake "door", "523,784", 80, 55, "sine" - b2kToneMake "win", "523,659,784,1047,1319", 110, 65 -end mgMakeSounds - --- ===================================================================== --- Levels as DATA. One verb per line; coordinates are world pixels. --- Verbs: bounds w,h · spawn x,y · text x,y,label (no commas in label) · --- slab l,t,r,b · ledge x1,x2,y (one-way; DOWN+JUMP drops through) · --- spike l,r,y · coin x,y · --- sweep l,t,r,b,minx,maxx,period (patrolling proximity hazard) · --- ladder x,y1,y2 (a climb zone at x, y1 top to y2 bottom, rungs drawn --- - zero-asset; UP/DOWN climbs, JUMP lets go) · --- water l,t,r,b (Wave 4 SWIM zone: buoyant, UP/DOWN swim, SPACE strokes) · --- fish x,yTop,yBot,period (a pit-dweller bobbing in a pool; knockback) · --- door x,y. Add your own verbs in mgBuild's switch - that IS the --- pattern: levels are text, the interpreter is the engine of YOUR game. --- ===================================================================== -function mgLevelText pLevel - local t - put empty into t - if pLevel is 1 then - put "bounds 1024,640" & cr after t - put "spawn 110,500" & cr after t - put "text 512,140,LEVEL 1 - grab ALL the coins to open the door" & cr after t - put "text 300,300,SPACE jumps: tap = hop / hold = full" & cr after t - put "slab 0,576,1024,640" & cr after t - put "spike 250,330,560" & cr after t - put "slab 380,488,560,512" & cr after t - put "ledge 620,860,420" & cr after t - put "coin 170,520" & cr after t - put "coin 460,448" & cr after t - put "coin 740,372" & cr after t - put "slab 880,512,1010,576" & cr after t - put "door 945,478" & cr after t - else if pLevel is 3 then - -- Wave 4: THE DEEP. A wide pool you DIVE into; the only coins are - -- underwater, so the door makes you swim. UP/DOWN swim, SPACE strokes - -- (repeatable, no ground), and a stroke + hold-right HOPS you out onto - -- the far bank. A pit-dweller fish patrols the middle (knockback). - put "bounds 2000,640" & cr after t - put "spawn 90,320" & cr after t - put "text 1000,104,LEVEL 3 - THE DEEP: dive in and SWIM for every coin" & cr after t - put "text 330,208,UP / DOWN swim - SPACE strokes you up and out" & cr after t - put "slab 0,360,900,640" & cr after t - put "water 900,360,1500,640" & cr after t - put "slab 900,612,1500,640" & cr after t - put "coin 1000,452" & cr after t - put "coin 1140,540" & cr after t - put "coin 1290,452" & cr after t - put "coin 1430,540" & cr after t - put "fish 1210,340,560,1300" & cr after t - put "slab 1500,360,2000,640" & cr after t - put "coin 1700,326" & cr after t - put "door 1900,328" & cr after t - else - put "bounds 1664,640" & cr after t - put "spawn 90,500" & cr after t - put "text 512,140,LEVEL 2 - hop the pillars... and time the SWEEPER" & cr after t - put "text 1330,330,every coin - then the ladder and the door" & cr after t - put "slab 0,576,360,640" & cr after t - put "spike 360,1060,624" & cr after t - put "slab 470,544,560,640" & cr after t - put "slab 670,512,760,640" & cr after t - put "slab 870,544,960,640" & cr after t - put "slab 1060,576,1664,640" & cr after t - put "coin 240,515" & cr after t - put "coin 515,496" & cr after t - put "coin 715,464" & cr after t - put "coin 915,496" & cr after t - put "coin 1180,520" & cr after t - put "sweep 1080,512,1144,544,1080,1420,900" & cr after t - -- the exit PILLAR (Wave 2): a LADDER up its left face - climb it - -- (or jump the 96px) to reach the door. Spacing pass: the pillar - -- moved past the sweeper's reach (its far end used to graze the - -- ladder's base - cramped reads as unfair) - put "slab 1558,480,1658,640" & cr after t - put "ladder 1538,450,576" & cr after t - put "door 1608,446" & cr after t - end if - return t -end mgLevelText - --- ===================================================================== --- The level interpreter: tear down, parse, build, hand over the keys --- ===================================================================== -command mgBuild pLevel - local tLine, tVerb, tArgs, tRef, tName, tPts, tBuildErr - local tY, tFolder, tCh, tSheet, tN - if gBuilding is true then exit mgBuild - put true into gBuilding - put pLevel into gLevel - put false into gHurtLock - put false into gDoorOpen - put empty into gDoor - put empty into gPrevState - put 0 into gCoins - put 0 into gCoinsTotal - put 0 into gSwpN - put empty into gSwpRef - put empty into gSwpC - put empty into gSwpA - put empty into gSwpP - put empty into gSwpY - put empty into gSwpW - put empty into gSwpH - put 0 into gFishN - put empty into gFishRef - put empty into gFishX - put empty into gFishYC - put empty into gFishYA - put empty into gFishP - put empty into gFishW - put empty into gFishH - try - b2kClear - catch tErr - end try - b2kTeardown - mgWipeStage - -- the whole build runs guarded: an error mid-build must NEVER leave - -- the screen locked (a frozen stack reads as "completely broken") -- - -- instead it lands, named, in the HUD so it can be reported verbatim - put empty into tBuildErr - lock screen - try - b2kSetup - b2kSetScale 60 - mgMakeSounds - -- the hero sheet rides INSIDE this file: a hidden image fed from - -- base64, registered as a 48x64 grid (teardown wiped the registry; - -- re-registering is cheap, frames re-slice lazily) - if there is no image "mgHeroSheet" then - create image "mgHeroSheet" - set the visible of it to false - set the text of image "mgHeroSheet" to base64Decode(kMgHeroB64) - set the lockLoc of it to true -- AFTER the content, so it auto-sized - end if - b2kSheetFromImage "hero", the long id of image "mgHeroSheet", 48, 64 - b2kAnimDef "hero", "idle", "1-4", 4, true - b2kAnimDef "hero", "walk", "9-16", 10, true - b2kAnimDef "hero", "jump", "17", 1, true - b2kAnimDef "hero", "hit", "19", 2, false - b2kAnimDef "hero", "hurtpose", "19", 2, true -- LOOPING knockback twin: - -- a non-looping anim finishing would fire - -- b2kSpriteOnFinish -> mgHurtDone (the respawn) - -- OPTIONAL alien skins (Wave 2): only when the platformer's folder - -- property already points at the repo's Spritesheets (this game - -- NEVER prompts - zero-asset means silence without it). One atlas - -- carries all five colours; frame names keep their ".png" suffix. - put false into gSkinsOK - if gSkin is empty then put "hero" into gSkin - put the uSpriteSheetFolderPath of this stack into tFolder - if tFolder is not empty and there is a file (tFolder & "/aliens.png") then - b2kSheetLoadAtlas "aliens", tFolder & "/aliens.png" - put the result into tN - if tN >= 1 then - put true into gSkinsOK - b2kSheetScale "aliens", 0.7 -- 66x92 B-family -> ~46x64 display - end if - end if - if gSkinsOK is not true then put "hero" into gSkin - if gSkin is "hero" then - put "hero" into tSheet - else - put "alien" & gSkin into tCh - b2kAnimDef "aliens", "idle", tCh & "_stand.png", 2, true - b2kAnimDef "aliens", "walk", tCh & "_walk1.png," & tCh & "_walk2.png", 8, true - b2kAnimDef "aliens", "jump", tCh & "_jump.png", 1, true - b2kAnimDef "aliens", "duck", tCh & "_duck.png", 1, true - b2kAnimDef "aliens", "climb", tCh & "_climb1.png," & tCh & "_climb2.png", 6, true - b2kAnimDef "aliens", "swim", tCh & "_swim1.png," & tCh & "_swim2.png", 6, true - b2kAnimDef "aliens", "hit", tCh & "_hurt.png", 2, false - b2kAnimDef "aliens", "hurtpose", tCh & "_hurt.png", 2, true - put "aliens" into tSheet - end if - b2kCamOn - set the itemDelimiter to comma - put 1024 into gWorldW - put 640 into gWorldH - put 110 into gSpawnX - put 500 into gSpawnY - repeat for each line tLine in mgLevelText(pLevel) - if tLine is empty then next repeat - put word 1 of tLine into tVerb - put word 2 to -1 of tLine into tArgs - switch tVerb - case "bounds" - put item 1 of tArgs into gWorldW - put item 2 of tArgs into gWorldH - break - case "spawn" - put item 1 of tArgs into gSpawnX - put item 2 of tArgs into gSpawnY - break - case "text" - put "mg_txt" & the milliseconds & random(100000) into tName - create field tName - set the lockText of it to true - set the traversalOn of it to false - set the opaque of it to false - set the showBorder of it to false - set the textAlign of it to "center" - set the textSize of it to 14 - set the textColor of it to "60,70,90" - set the rect of it to (item 1 of tArgs) - 220, (item 2 of tArgs) - 16, (item 1 of tArgs) + 220, (item 2 of tArgs) + 16 - set the text of it to item 3 to -1 of tArgs - b2kCamAdopt the long id of field tName - break - case "slab" - put "mg_slab" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "rectangle" - set the rect of it to item 1 of tArgs, item 2 of tArgs, item 3 of tArgs, item 4 of tArgs - set the filled of it to true - set the backgroundColor of it to "104,114,130" - set the foregroundColor of it to "70,78,92" - b2kCamAdopt the long id of graphic tName - b2kAddStatic the long id of graphic tName - break - case "ledge" - -- one-way platform: solid from above, jump through from - -- below. GHOST RULE: the chain runs one tile past each end. - put ((item 2 of tArgs) + 64) & comma & (item 3 of tArgs) & cr into tPts - put (item 2 of tArgs) & comma & (item 3 of tArgs) & cr after tPts - put (item 1 of tArgs) & comma & (item 3 of tArgs) & cr after tPts - put ((item 1 of tArgs) - 64) & comma & (item 3 of tArgs) after tPts - b2kSmoothGround tPts - put "mg_ledge" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "line" - set the points of it to (item 1 of tArgs) & comma & (item 3 of tArgs) & cr & (item 2 of tArgs) & comma & (item 3 of tArgs) - set the lineSize of it to 5 - set the foregroundColor of it to "80,180,110" - b2kCamAdopt the long id of graphic tName - break - case "spike" - put "mg_spike" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "rectangle" - set the rect of it to item 1 of tArgs, item 3 of tArgs, item 2 of tArgs, (item 3 of tArgs) + 16 - set the filled of it to true - set the backgroundColor of it to "205,70,70" - put the long id of graphic tName into tRef - set the uMgHazard of tRef to true - b2kCamAdopt tRef - b2kAddSensor tRef, "box" - break - case "coin" - put "mg_coin" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "oval" - set the rect of it to (item 1 of tArgs) - 14, (item 2 of tArgs) - 14, (item 1 of tArgs) + 14, (item 2 of tArgs) + 14 - set the filled of it to true - set the backgroundColor of it to "242,200,70" - set the foregroundColor of it to "180,140,30" - put the long id of graphic tName into tRef - set the uMgCoin of tRef to true - b2kCamAdopt tRef - b2kAddSensor tRef, "ball" - add 1 to gCoinsTotal - break - case "sweep" - -- a patrolling proximity hazard: pure geometry, no body - -- (the platformer's mover pattern with a plain graphic) - put "mg_sweep" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "rectangle" - set the rect of it to item 1 of tArgs, item 2 of tArgs, item 3 of tArgs, item 4 of tArgs - set the filled of it to true - set the backgroundColor of it to "190,70,90" - b2kCamAdopt the long id of graphic tName - add 1 to gSwpN - put the long id of graphic tName into gSwpRef[gSwpN] - put ((item 5 of tArgs) + (item 6 of tArgs)) / 2 into gSwpC[gSwpN] - put ((item 6 of tArgs) - (item 5 of tArgs)) / 2 into gSwpA[gSwpN] - put max(1, item 7 of tArgs) into gSwpP[gSwpN] - put ((item 2 of tArgs) + (item 4 of tArgs)) / 2 into gSwpY[gSwpN] - put ((item 3 of tArgs) - (item 1 of tArgs)) / 2 + 22 into gSwpW[gSwpN] - put ((item 4 of tArgs) - (item 2 of tArgs)) / 2 + 30 into gSwpH[gSwpN] - break - case "ladder" - -- a climb ZONE (pure polled geometry, no body) plus drawn - -- side rails + rungs - the zero-asset ladder - b2kPlayerAddLadder (item 1 of tArgs) - 20, item 2 of tArgs, (item 1 of tArgs) + 20, item 3 of tArgs - put "mg_ladder" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "rectangle" - set the rect of it to (item 1 of tArgs) - 14, item 2 of tArgs, (item 1 of tArgs) + 14, item 3 of tArgs - set the filled of it to false - set the lineSize of it to 4 - set the foregroundColor of it to "170,120,60" - b2kCamAdopt the long id of graphic tName - put (item 2 of tArgs) + 16 into tY - repeat while tY < (item 3 of tArgs) - 6 - put "mg_rung" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "line" - set the points of it to ((item 1 of tArgs) - 14) & comma & tY & cr & ((item 1 of tArgs) + 14) & comma & tY - set the lineSize of it to 3 - set the foregroundColor of it to "170,120,60" - b2kCamAdopt the long id of graphic tName - add 24 to tY - end repeat - break - case "water" - -- Wave 4: a SWIM zone. The Kit's controller polls it (buoyant - -- gravity, capped sink, UP/DOWN swim, SPACE strokes up). Drawn - -- as a blue pool with a surface line; coins/fish placed AFTER - -- it in the level text layer on top (later controls draw over). - b2kPlayerAddWater item 1 of tArgs, item 2 of tArgs, item 3 of tArgs, item 4 of tArgs - put "mg_water" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "rectangle" - set the rect of it to item 1 of tArgs, item 2 of tArgs, item 3 of tArgs, item 4 of tArgs - set the filled of it to true - set the backgroundColor of it to "92,150,205" - set the foregroundColor of it to "60,110,170" - b2kCamAdopt the long id of graphic tName - put "mg_surf" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "line" - set the points of it to (item 1 of tArgs) & comma & (item 2 of tArgs) & cr & (item 3 of tArgs) & comma & (item 2 of tArgs) - set the lineSize of it to 3 - set the foregroundColor of it to "165,210,245" - b2kCamAdopt the long id of graphic tName - break - case "fish" - -- Wave 4: a PIT-DWELLER. A bodiless mover (the sweep pattern, - -- vertical) bobbing between yTop and yBot - it breaches the - -- surface and dives, a knockback graze on contact (never a - -- respawn). Args: x,yTop,yBot,period. - put "mg_fish" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "oval" - set the rect of it to (item 1 of tArgs) - 22, ((item 2 of tArgs) + (item 3 of tArgs)) / 2 - 13, (item 1 of tArgs) + 22, ((item 2 of tArgs) + (item 3 of tArgs)) / 2 + 13 - set the filled of it to true - set the backgroundColor of it to "95,165,150" - set the foregroundColor of it to "40,90,85" - b2kCamAdopt the long id of graphic tName - add 1 to gFishN - put the long id of graphic tName into gFishRef[gFishN] - put item 1 of tArgs into gFishX[gFishN] - put ((item 2 of tArgs) + (item 3 of tArgs)) / 2 into gFishYC[gFishN] - put ((item 3 of tArgs) - (item 2 of tArgs)) / 2 into gFishYA[gFishN] - put max(1, item 4 of tArgs) into gFishP[gFishN] - put 36 into gFishW[gFishN] - put 30 into gFishH[gFishN] - break - case "door" - put "mg_door" & the milliseconds & random(100000) into tName - create graphic tName - set the style of it to "roundrect" - set the rect of it to (item 1 of tArgs) - 24, (item 2 of tArgs) - 32, (item 1 of tArgs) + 24, (item 2 of tArgs) + 32 - set the filled of it to true - set the backgroundColor of it to "120,120,128" -- locked - put the long id of graphic tName into gDoor - set the uMgDoor of gDoor to true - b2kCamAdopt gDoor - b2kAddSensor gDoor, "box" - break - end switch - end repeat - -- world edges: THICK slabs, not thin segments - a velocity-driven - -- capsule can creep past a segment (the platformer learned this); - -- a 48px box is a hard stop. Past-the-door-into-white-space = the - -- old thin right wall giving way. - create graphic "mg_wallL" - set the style of it to "rectangle" - set the rect of it to -48, 0 - gWorldH, 0, gWorldH - set the visible of it to false - b2kAddStatic the long id of graphic "mg_wallL" - create graphic "mg_wallR" - set the style of it to "rectangle" - set the rect of it to gWorldW, 0 - gWorldH, gWorldW + 48, gWorldH - set the visible of it to false - b2kAddStatic the long id of graphic "mg_wallR" - b2kWall 0, 0, gWorldW, 0 - b2kCamBounds 0, 0, gWorldW, gWorldH - b2kCamDeadzone 0, 0 - -- THE player: one call builds the body host, the capsule, the - -- sprite (first frame of the chosen sheet), the controller, and - -- arms input. The capsule stays 32x56 whatever the art (the body - -- and the sprite are independent - the invisible-host pattern). - b2kPlayerMake gSpawnX, gSpawnY, 32, 56, tSheet - put the result into gHero - put b2kPlayerSprite() into gHeroSpr - -- aliens carry real duck/climb frames; the embedded hero leaves - -- those slots empty (the Kit falls back to idle/jump poses). The - -- knockback pose is the LOOPING "hurtpose", never the one-shot - -- "hit" whose finish message means RESPAWN here. - if gSkin is "hero" then - b2kPlayerAnims "idle", "walk", "jump", "jump", "", "", "", "hurtpose", "" - else - b2kPlayerAnims "idle", "walk", "jump", "jump", "", "duck", "climb", "hurtpose", "swim" - end if - b2kPlayerSet "moveSpeed", 250 - b2kPlayerSet "jumpSpeed", 460 - if gHeroSpr is not empty then - b2kSpriteBind gHeroSpr, gHero, 0, -4 -- art baseline sits 4px high - b2kSpriteOnFinish gHeroSpr, "mgHurtDone" - end if - b2kCamFollow gHero, 1 - b2kCamGoto gSpawnX, gSpawnY - b2kPlayerControl false -- menus own the keys; mgBegin hands them over - b2kFrameTarget the long id of me - -- sensor + contact MESSAGES dispatch to the CONTACT target - this - -- line missing meant every coin/spike/door event fired into the - -- void while solids worked fine ("no collisions with sensors") - b2kContactTarget the long id of me - catch tBuildErr - end try - mgChromeFront - unlock screen - put empty into gHudLast - put 0 into gHudNextMS - if tBuildErr is not empty then - set the text of field "mgHud" to "BUILD ERROR (report this): " & tBuildErr - else - b2kStart - end if - put false into gBuilding -end mgBuild - --- Remove every level control (all are "mg_" prefixed; chrome is "mgX"). -command mgWipeStage - local tAgain, i, tName - put true into tAgain - repeat while tAgain - put false into tAgain - repeat with i = 1 to the number of controls of this card - put the short name of control i of this card into tName - if char 1 to 3 of tName is "mg_" then - delete control i of this card - put true into tAgain - exit repeat - end if - end repeat - end repeat -end mgWipeStage - --- ===================================================================== --- The per-frame game logic --- ===================================================================== -on b2kFrame - local tHud, tSecs, tState, tPos, tHX, tHY - if gHero is empty then exit b2kFrame - set the itemDelimiter to comma - -- ONE hero snapshot per frame: the kill plane and both hazard ticks - -- share it (each re-reading would be a needless FFI round-trip) - put b2kPosition(gHero) into tPos - put item 1 of tPos into tHX - put item 2 of tPos into tHY - -- kill plane: off the bottom = a fall - if gHurtLock is not true and tHY > gWorldH + 120 then mgHurt - mgTickSweeps tHX, tHY - mgTickFish tHX, tHY - -- the door unlocks itself the moment the last coin is taken - if gDoorOpen is not true and gDoor is not empty and gCoinsTotal > 0 and gCoins >= gCoinsTotal then - put true into gDoorOpen - try - set the backgroundColor of gDoor to "90,200,110" - catch tErr - end try - b2kSound "door" - end if - put b2kPlayerState() into tState - if tState is "land" then b2kSound "land" - if tState is "jump" and gPrevState is not "jump" and gHurtLock is not true then b2kSound "jump" - put tState into gPrevState - -- HUD at 4 Hz: re-setting field text every frame costs an engine - -- relayout+redraw (the ms readout would force exactly that) - if the milliseconds < gHudNextMS then exit b2kFrame - put the milliseconds + 250 into gHudNextMS - if gMode is "play" then - put (the milliseconds - gRunStart) div 1000 into tSecs - else - put 0 into tSecs - end if - put "Level " & gLevel & " coins " & gCoins & "/" & gCoinsTotal & " " & format("%d:%02d", tSecs div 60, tSecs mod 60) & " falls " & gFalls & " hits " & gOuches & " | " & tState & " " & round(b2kFrameMS() * 10) / 10 & " ms" into tHud - if b2kSoundStatus() is not empty then put " [audio: " & b2kSoundStatus() & "]" after tHud - if b2kSoundMuted() then put " [muted]" after tHud - if tHud is not gHudLast then - put tHud into gHudLast - set the text of field "mgHud" to tHud - end if -end b2kFrame - -command mgTickSweeps pHX, pHY - local i, tMS, tX - if gSwpN is 0 or gSwpN is empty then exit mgTickSweeps - put the milliseconds into tMS - repeat with i = 1 to gSwpN - if gSwpRef[i] is empty then next repeat - put gSwpC[i] + gSwpA[i] * sin(tMS / gSwpP[i]) into tX - b2kSpriteMoveTo gSwpRef[i], tX, gSwpY[i] - if gHurtLock is not true and gMode is "play" then - -- a sweeper graze knocks back, never respawns (Wave 2 split) - if abs(pHX - tX) < gSwpW[i] and abs(pHY - gSwpY[i]) < gSwpH[i] then mgOuch tX - end if - end repeat -end mgTickSweeps - --- Wave 4 pit-dwellers: each fish bobs VERTICALLY between its yTop and yBot --- (the sweep pattern on the y axis), breaching the surface and diving. A --- graze knocks back (mgOuch), never respawns -- the Wave 2 hazard split. -command mgTickFish pHX, pHY - local i, tMS, tY - if gFishN is 0 or gFishN is empty then exit mgTickFish - put the milliseconds into tMS - repeat with i = 1 to gFishN - if gFishRef[i] is empty then next repeat - put gFishYC[i] + gFishYA[i] * sin(tMS / gFishP[i]) into tY - b2kSpriteMoveTo gFishRef[i], gFishX[i], tY - if gHurtLock is not true and gMode is "play" then - if abs(pHX - gFishX[i]) < gFishW[i] and abs(pHY - tY) < gFishH[i] then mgOuch gFishX[i] - end if - end repeat -end mgTickFish - --- ===================================================================== --- Events: pickups, hazards, the door --- ===================================================================== --- The hero is the ONLY dynamic body in this game, so no visitor gate --- is needed (or wanted: gating on long-id string equality is one more --- thing that can silently fail). -on b2kSensorEnter pSensorCtrl, pVisitorCtrl - if pSensorCtrl is empty then exit b2kSensorEnter - if gMode is not "play" then exit b2kSensorEnter - if the uMgCoin of pSensorCtrl is true then - add 1 to gCoins - b2kSound "coin" - try - b2kRemove pSensorCtrl -- frees the sensor body - catch tErr - end try - try - delete pSensorCtrl -- ...and the example-made oval - catch tErr - end try - exit b2kSensorEnter - end if - if the uMgHazard of pSensorCtrl is true then - -- spikes brushed = knockback away from the strip's centre (Wave 2 - -- split); only falling off the world respawns - set the itemDelimiter to comma - mgOuch item 1 of b2kPosition(pSensorCtrl) - end if - if the uMgDoor of pSensorCtrl is true and gDoorOpen is true and gMode is "play" then mgAdvance -end b2kSensorEnter - --- Contact damage -> the Kit's knockback standard (Wave 2): away-pop, --- hurt pose, a beat of no control, then a mercy window (the Kit no-ops --- repeat hurts inside it; this gate just stops the sound/shake spam). -command mgOuch pFromX - if gHurtLock is true or gMode is not "play" then exit mgOuch - if b2kPlayerHurtIs() then exit mgOuch - add 1 to gOuches - b2kSound "hurt" - b2kCamShake 5, 200 - b2kPlayerHurt pFromX -end mgOuch - --- The RESPAWN path -- Wave 2 reserves it for falling off the world. -command mgHurt - if gHurtLock is true or gMode is not "play" then exit mgHurt - put true into gHurtLock - add 1 to gFalls - b2kSound "hurt" - b2kCamShake 6, 240 - -- the explicit control call also cancels any Kit knockback in - -- flight (knocked INTO the pit = the respawn takes over cleanly) - b2kPlayerControl false - set the itemDelimiter to comma - b2kSetVelocity gHero, 0, item 2 of b2kVelocity(gHero) - if gHeroSpr is not empty then - b2kSpritePlay gHeroSpr, "hit", true -- non-loop; OnFinish respawns - else - mgHurtDone empty, empty -- no art: respawn immediately - end if -end mgHurt - -on mgHurtDone pSprite, pAnim - -- only the respawn flow's one-shot "hit" may land here: any other - -- non-looping anim finishing must not teleport the hero - if gHurtLock is not true then exit mgHurtDone - b2kMoveTo gHero, gSpawnX, gSpawnY - b2kSetVelocity gHero, 0, 0 - put false into gHurtLock - b2kPlayerControl true -- re-asserts the state anim over the hit pose -end mgHurtDone - --- ===================================================================== --- Mouse + keys --- ===================================================================== -on mouseUp - if gMode is "menu" then - mgBegin - exit mouseUp - end if - if gMode is "won" then - mgBuild 1 - mgBegin - end if -end mouseUp - --- R restarts the level, ESC pauses, M mutes; RETURN also starts from --- the menu; menu keys 1-6 pick the skin (only offered when the aliens --- atlas loaded). Raw events, so they work even while paused. -on rawKeyDown pKeyCode - local tPick - if gMode is "menu" and gSkinsOK is true and pKeyCode >= 49 and pKeyCode <= 54 then - set the itemDelimiter to comma -- gotcha 5: never assume it - put item (pKeyCode - 48) of "hero,Beige,Blue,Green,Pink,Yellow" into tPick - if tPick is not gSkin then - put tPick into gSkin - mgBuild 1 -- rebuild level 1 wearing the new skin - mgShowMenu -- (the menu line echoes the current pick) - end if - exit rawKeyDown - end if - if pKeyCode is 65293 and gMode is "menu" then - mgBegin - exit rawKeyDown - end if - if pKeyCode is 65307 and gMode is "play" then - if b2kIsRunning() then - b2kPause - else - b2kResume - end if - exit rawKeyDown - end if - if pKeyCode is 114 or pKeyCode is 82 then - mgBuild gLevel - if gMode is "play" then - b2kPlayerControl true - else - if gMode is "menu" then mgShowMenu - if gMode is "won" then mgShowWin - end if - exit rawKeyDown - end if - if pKeyCode is 109 or pKeyCode is 77 then - b2kSoundMute (not b2kSoundMuted()) - exit rawKeyDown - end if - pass rawKeyDown -end rawKeyDown - --- ===================================================================== --- The Kit (embedded verbatim; regenerated by tools/sync-embedded-kit.py --- - do not edit between the sentinels) --- ===================================================================== --- >>> BEGIN EMBEDDED KIT >>> --- ===================================================================== --- box2dxt-kit.livecodescript · "Box2Dxt Kit" (b2k...) --- --- A friendly, pure-xTalk toolkit over the box2dxt extension. You work in --- PIXELS, SCREEN coordinates and DEGREES; the kit converts to Box2D's --- metres / radians / y-up for you, runs the world, and moves your controls. --- Runs in OpenXTalk (OXT); compatible with LiveCode 9.6.3+. --- Requires the box2dxt extension loaded (put b2Version() should return 4). --- --- ===================== 60-SECOND START ============================= --- on openCard --- b2kQuickStart -- world + gravity + walls around the card + go --- b2kSpawnBall 200,80, 50 -- create + drop a ball --- b2kSpawnBox 260,80, 60,40, "orange" -- (read `the result` for the ref) --- b2kContactTarget the long id of me -- (optional) collision messages --- end openCard --- on mouseDown ; get b2kGrab(the mouseH,the mouseV) ; end mouseDown --- on mouseUp ; b2kRelease ; end mouseUp --- on closeCard ; b2kStop ; end closeCard --- --- ===================== CHEAT SHEET ================================ --- WORLD b2kSetup [gx,gy] · b2kQuickStart [gy] · b2kStart · b2kStop · --- b2kTeardown · b2kAddWalls · b2kWall x1,y1,x2,y2 · b2kClear · --- b2kPause · b2kResume · b2kIsRunning() · b2kKillFloor y --- (movers below y are removed; "b2kFell ctrl" precedes each) --- CONFIG b2kSetScale px · b2kSetOrigin x,y · b2kSetGravity gx,gy · --- b2kSetSubsteps n · b2kContactTarget obj · b2kFrameTarget obj · --- b2kEnableSleeping flag · b2kEnableContinuous flag --- ATTACH b2kAddBox ctrl[,dyn] · b2kAddBall ctrl[,dyn] · b2kAddCapsule ctrl[,dyn] --- b2kAddPolygon ctrl[,dyn] · b2kAddStatic ctrl · b2kAddGround [screenY] --- SPAWN b2kSpawnBox x,y,w,h[,color] · b2kSpawnBall x,y,diam[,color] · --- b2kSpawnCapsule x,y,len,thick[,color] (-> control) --- MATERIAL b2kSetBounce ctrl,0..1 · b2kSetFriction ctrl,0..1 · b2kSetDensity ctrl,d --- BODY b2kSetBullet ctrl,flag · b2kSetFixedRotation ctrl,flag · --- b2kSetGravityScale ctrl,s · b2kSetDamping ctrl,lin[,ang] · b2kWake ctrl · --- b2kSetType ctrl,name · b2kSetStatic/Dynamic/Kinematic ctrl · --- b2kEnable/Disable ctrl · b2kSetSleepEnabled ctrl,flag --- ACT b2kPush ctrl,dvx,dvy · b2kImpulse ctrl,ix,iy · b2kForce ctrl,fx,fy · --- b2kSetVelocity ctrl,vx,vy · b2kSpin ctrl,deg/s · b2kSpinBy ctrl,deg/s · --- b2kTorque ctrl,t · b2kAngularImpulse ctrl,imp · b2kMoveTo ctrl,x,y[,deg] · --- b2kExplode x,y[,radius][,power] · b2kRemove ctrl --- GET b2kBodyOf(ctrl) · b2kPosition(ctrl) · b2kWorldCenter(ctrl) · b2kVelocity(ctrl) · --- b2kSpeed(ctrl) · b2kAngle(ctrl) · b2kMass(ctrl) · b2kBodyType(ctrl) · --- b2kGravityScale(ctrl) · b2kDamping(ctrl) · b2kIsAwake(ctrl) · b2kControlAt(x,y) · --- b2kControlContains(ctrl,x,y) · b2kBodyCount() · b2kAwakeCount() · --- b2kToWorldX/Y(px) · b2kToScreenX/Y(m) · --- b2kRayHit(x1,y1,x2,y2) + b2kRayHitX/Y() · b2kRayHitNormalX/Y() · b2kRayDist() --- JOINTS b2kHinge ctrlA,ctrlB,x,y · b2kWeld ctrlA,ctrlB · b2kRope ctrlA,ctrlB[,len] --- b2kSlider ctrlA,ctrlB,axisDeg · b2kWheel chassis,wheel,x,y[,axisDeg] --- b2kMotor j,degPerSec[,maxTorque] · b2kSliderMotor j,pxPerSec[,maxForce] · --- b2kWheelMotor j,degPerSec[,maxTorque] · b2kRemoveJoint j --- JOINT+ b2kHingeLimit j,loDeg,hiDeg · b2kHingeAngle(j) · b2kSliderLimit j,loPx,hiPx · --- b2kSliderPos(j) · b2kRopeRange j,minPx,maxPx · b2kRopeLength(j) · --- b2kSpring j,hz[,damp] · b2kWeldSpring j,hz[,damp] · b2kWheelSpring j,hz[,damp] --- DRAG b2kGrab(x,y) -> control · b2kRelease --- INPUT b2kInputOn/Off · b2kKeyIsDown(k) · b2kKeyPressed/Released(k) · --- b2kInputInject keys / b2kInputInjectOff (scripted keys: tests/replays) · --- b2kKeysHeld() · b2kBindAction name,keys · b2kActionIsDown/Pressed/ --- Released(name) · b2kBindAxis name,negKeys,posKeys · b2kAxis(name) · --- b2kFrameMS() (poll-based: arm b2kInputOn, read from on b2kFrame) --- SPRITE b2kSheetLoad name,path,fw,fh[,n,margin,gap] · b2kSheetLoadAtlas name,png[,xml] · --- b2kSheetFromImage name,img,fw,fh[,n,margin,gap] · b2kSheetFrames(name) · --- b2kSheetAddFrame sheet,frame,x,y,w,h (no-XML packed sheets; fw/fh 0 = no grid) · --- b2kSheetFrameNames(name) · b2kSheetHasFrame(name,frame) · --- b2kSheetScale name,factor · b2kSheetFrameSize(name,frame) · --- b2kAnimDef sheet,anim,frames,fps[,loop] · b2kSpriteNew sheet[,frame,x,y] · --- b2kSpriteFromGIF path[,x,y] · b2kSpritePlay spr,anim[,restart] · --- b2kSpriteStop spr · b2kSpriteSetFrame spr,f · b2kSpriteFlipH spr,flag · --- b2kSpriteFPS spr,fps · b2kSpriteOnFinish spr,msg · --- b2kSpriteBind spr,bodyCtrl[,dx,dy] · b2kSpriteRemove spr --- PLAYER b2kPlayerMake x,y,w,h[,sheet] · b2kPlayerAttach ctrl · --- b2kPlayerAnims idle,run,jump,fall[,land,duck,climb,hurt,swim] · --- b2kPlayerSet key,value · b2kPlayerGet(key) · b2kPlayerOnGround() · --- b2kPlayerState() (idle|run|jump|fall|land|duck|climb|hurt|swim) · --- b2kPlayerFacing() · b2kPlayerJump [speed] · b2kPlayerControl flag · --- b2kPlayerAddLadder x1,y1,x2,y2 · b2kPlayerAddWater x1,y1,x2,y2 · --- b2kPlayerHurt [fromX] · --- b2kPlayerHurtIs() (true through knockback + mercy window) · --- b2kPlayer() · b2kPlayerSprite() · b2kPlayerRemove --- (drives axes "moveX"/"moveY" + action "jump"; rebind to remap. --- DOWN ducks · DOWN+JUMP on a one-way chain drops through · --- UP/DOWN in a ladder zone climbs · JUMP exits a climb · --- in a water zone UP/DOWN swim, JUMP strokes (repeatable)) --- CAMERA b2kCamOn [rect] · b2kCamOff · b2kCamFollow ctrl[,lerp] · b2kCamUnfollow · --- b2kCamDeadzone w,h · b2kCamBounds x1,y1,x2,y2 · b2kCamGoto x,y · --- b2kCamPos() · b2kCamShake amp,ms · b2kCamMouseX/Y() · b2kCamGroup() · --- b2kCamAdopt ctrl (world px == control locs; spawns auto-join the view) --- AUDIO b2kSoundLoad name,path · b2kToneMake name,freqs,msPerNote[,vol,shape] · --- b2kSound name · b2kSoundLoop name · b2kSoundStop · b2kSoundMute flag · --- b2kSoundMuted() · b2kSoundVolume pct · b2kSoundIsLoaded(name) · --- b2kSoundStatus() (audioClips: one at a time; b2kToneMake = no asset files) --- EVENTS on b2kContact pCtrlA,pCtrlB · on b2kEndContact pCtrlA,pCtrlB --- (long ids; empty for walls/ground) · on b2kFrame (once per frame) --- poll instead: b2kContactCount()+b2kContactA/B(i) · b2kEndContactCount()+… --- SENSOR b2kAddSensor ctrl[,shape] · on b2kSensorEnter pSensor,pVisitor · --- on b2kSensorExit pSensor,pVisitor · b2kSensorCount()+EnterSensor/Visitor(i) --- FILTER b2kDefineLayer name · b2kSetCategory ctrl,layers · b2kSetMask ctrl,layers · --- b2kSetCollisionGroup ctrl,n · b2kNoCollide ctrlA,ctrlB --- TERRAIN b2kChain pointList[,loop] · b2kSmoothGround pointList (>=4 screen --- points; chains carry the reserved "oneway" layer, bit 2^31) --- QUERY+ b2kOverlap x1,y1,x2,y2 · b2kOverlapMoving x1,y1,x2,y2 (presence: --- statics filtered out) · b2kOverlapCircle x,y,r · b2kRayHitAll x1,y1,x2,y2 --- MOTOR b2kMotorTo mover,ref,dxPx,dyPx,deg[,maxF,maxT] · b2kExplode (native blast) --- TUNE b2kSetRestitutionThreshold px/s · b2kSetContactTuning hz,damp,pushPx · --- b2kSetJointTuning hz,damp · b2kSetMaxSpeed px/s · b2kEnableWarmStarting f · --- b2kProfile() · b2kAwakeBodyCount() --- --- Tips: pass controls by reference (the long id of ... is safest). Keep moving --- objects a sensible on-screen size (default scale 40 px/m, so ~4-400 px). --- Graphic boxes/polygons and dynamic images rotate; buttons/fields/etc follow --- position upright (their rotation is locked so the sim matches the render). --- ===================================================================== - -local sWorld, sRunning, sPaused, sGen -local sScale, sOriginX, sOriginY -local sAccum, sLast, sSub -local sBody -- ctrlRef -> body handle -local sCtrl -- body handle -> ctrlRef -local sShapeH -- ctrlRef -> shape handle (for material edits) -local sRender -- ctrlRef -> "poly" | "ball" | "image" | "loc" -local sVerts -- ctrlRef -> local polygon verts (metres) for "poly" -local sRad -- ctrlRef -> radius (metres) for "ball" -local sImgAngle -- ctrlRef -> last applied angle (deg) for "image" -local sStatic -- ctrlRef -> true for bodies that never move -local sSpawned -- ctrlRef -> true if the kit created the control -local sSensor -- ctrlRef -> true for sensor (non-solid trigger) bodies -local sLayers -- collision-layer name -> bit value -local sNextBit -- next free collision-layer bit -local sDragJoint, sDragAnchor, sDragging -local sContactObj -- object that receives b2kContact messages -local sFrameObj -- object that receives an on b2kFrame message each frame -local sRayX, sRayY -- last b2kRayHit point (screen pixels) -local sRayNX, sRayNY -- last b2kRayHit surface normal (screen-oriented) -local sRayDist -- last b2kRayHit distance from the ray start (pixels) -local sRayBodyH -- last b2kRayHit BODY HANDLE (already fetched for the - -- control lookup; stashed so the player's ground probe - -- can classify the hit with zero extra FFI calls) -local sOneWayBody -- body handle -> true for one-way chain bodies (b2kChain) -local sNeedFullSync -- true when a script-side transform/type edit bypassed move events -local sEvtCN, sEvtCA, sEvtCB -- begin-contacts buffered THIS FRAME (body handles) -local sEvtEN, sEvtEA, sEvtEB -- end-contacts buffered this frame -local sEvtSN, sEvtSS, sEvtSV -- sensor enters this frame (sensor, visitor) -local sEvtXN, sEvtXS, sEvtXV -- sensor exits this frame -local sKillY -- kill floor: screen y below which dynamic bodies are removed -local sDrawKey -- ctrlRef -> last rendered pixel/angle key; avoids redundant redraws -local sInputOn -- true while the per-frame keyboard sample is armed -local sInjectOn -- true = the sample comes from sInjectKeys, not the keyboard -local sInjectKeys -- injected keycode list (tests, replays, cutscene ghosts) -local sKeysNow -- comma-wrapped keycode set held this frame (",65361,32,") -local sKeysPrev -- last frame's set; pressed/released edges are the diff -local sKeyActions -- action name -> bound key list ("jump" -> "space,up,w") -local sAxisNeg -- axis name -> negative-direction key list -local sAxisPos -- axis name -> positive-direction key list -local sKeyActionsC -- action name -> RESOLVED keycode list (bind-time cache) -local sAxisNegC -- axis name -> resolved negative keycodes -local sAxisPosC -- axis name -> resolved positive keycodes -local sFrameMS -- real elapsed ms folded into the last frame (b2kFrameMS) -local sSheetSrc -- sheet -> long id of its hidden source image -local sSheetOwned -- sheet -> true when the Kit created the source image -local sSheetRegion -- sheet -> frame key -> "x,y,w,h" region (source px) -local sSheetKeys -- sheet -> CR list of frame keys, definition order -local sSheetIcon -- sheet -> frame key -> sliced frame image id (lazy) -local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) -local sSheetData -- sheet -> cached source imageData (freed on teardown) -local sSheetAlpha -- sheet -> cached source alphaData -local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) -local sAnimList -- "sheet|anim" -> CR list of frame keys -local sAnimFPS -- "sheet|anim" -> frames per second -local sAnimLoop -- "sheet|anim" -> true/false -local sSprRefs -- CR list of every live sprite control -local sSprLive -- ...the subset the tick must service (bound/playing) -local sSprLiveDirty -- true -> rebuild sSprLive from sSprRefs next tick -local sSprSheet, sSprKind, sSprAnim, sSprStep, sSprNextMS -local sSprFPS, sSprFPSOver, sSprFlip, sSprMsg, sSprFrameKey, sSprIconNow -local sSprBind, sSprBindDX, sSprBindDY, sSprLastLoc -local sCamGroup -- viewport group long id while the camera is on -local sCamFollow -- control the camera tracks (empty = manual) -local sCamLerp -- follow smoothing 0..1 (1 = snap) -local sCamDZW, sCamDZH -- deadzone box in px (0 = always centre) -local sCamB1X, sCamB1Y, sCamB2X, sCamB2Y -- world bounds (sCamB1X empty = none) -local sCamX, sCamY -- current view centre (world px) -local sCamShakeAmp, sCamShakeEnd -local sCamLastH, sCamLastV -- last written scroll (skip redundant sets) -local sCamNote -- empty = healthy; else why the camera is degraded -local sCamLocVisual -- true when this engine reports grouped locs SCROLL-ADJUSTED -local sCamCurH, sCamCurV -- the scroll actually applied this frame (cached) -local sInCam -- ctrlRef -> true when the Kit placed it inside the viewport -local sPlayRef -- the player's control (empty = no player controller) -local sPlayArt -- the sprite whose anims the controller drives -local sPlayTune -- tuning key -> value (b2kPlayerSet; defaults fill gaps) -local sPlayAnims -- state -> animation name (idle/run/jump/fall/land/duck/climb/hurt) -local sPlayState -- idle | run | jump | fall | land | duck | climb | hurt - -- (land lasts one tick; a drop-through renders as fall) -local sPlayFacing -- 1 right / -1 left (last horizontal intent) -local sPlayGrounded -- this frame's ray-probe verdict -local sPlayGroundMS -- when last grounded (the coyote window) -local sPlayPressMS -- when jump was last pressed (the buffer window) -local sPlayJumping -- a controller jump is still ascending (cut-eligible) -local sPlayControl -- false = observe only: no velocity or anim writes -local sPlayOwnBody -- true when the controller created the body -local sPlayHalfW, sPlayHalfH -- capsule half-extents in px (probe geometry) -local sPlayAnimNow -- last animation written (redundancy suppression) -local sPlayFlipNow -- last flip written -local sPlayHoldMS -- suppress anim switches until then (land flourish) --- hot-path caches (b2kPlayerTuneCache): the tick runs every frame, so --- the knobs resolve at SET time, not per use -local sPlayMoveSpd, sPlayAccelG, sPlayAccelA, sPlayJumpSpd, sPlayJumpCut -local sPlayCoyote, sPlayBuffer, sPlayMaxFall, sPlayCosSlope -local sPlayDropMS, sPlayClimbSpd -- Wave 2 caches... -local sPlayHurtPopX, sPlayHurtPopY, sPlayHurtMS, sPlayInvulnMS -local sPlayProbeOffs -- the 3 probe x-offsets, precomputed at attach -local sPlayReach -- probe ray length (halfH + 4), precomputed -local sPlayAir -- consecutive airborne ticks (landing hysteresis) -local sPlayNormX -- surface normal X at the grounded probe hit (slope test) -local sPlayPX, sPlayPY -- the probe's screen-px body centre, stashed for the - -- ladder zone test and b2kPlayerHurt (no second FFI read) -local sPlayOnOneWay -- this frame's ground is a one-way chain (drop eligible) -local sPlayDropUntil -- sim-clock end of the drop-through window (0 = inactive) -local sPlayDropHard -- hard restore deadline (4x dropMs): a drop that lands on - -- a parked solid under the chain must not stay intangible -local sPlayDropLineY -- surface y the drop started through; the mask is only - -- restored once the capsule's TOP clears it (a straddling - -- restore would snap the player back on top: chain contacts - -- are one-sided, judged by the centroid) -local sPlayDropSeen -- the probe skipped a one-way hit this frame (still inside) -local sPlayDropMask -- the player's mask to restore when the window closes -local sPlayClimb -- true while in the climb state (gravity scale parked at 0) -local sPlayGravSave -- the body's gravity scale to restore on climb exit -local sPlayLadN -- registered ladder zones (flat numeric arrays: the -local sPlayLadL, sPlayLadT, sPlayLadR, sPlayLadB -- ...in-zone test is pure compares) --- Wave 4: water zones + the swim state (a buoyant parallel to the climb) -local sPlaySwim -- true while submerged in a water zone (gravity scaled down) -local sPlaySwimGravSave -- the body's gravity scale to restore on swim exit -local sPlaySwimSpd, sPlaySwimJump, sPlaySwimGrav, sPlaySwimMaxFall -- swim tune caches -local sPlayWatN -- registered water zones (flat numeric arrays, like ladders) -local sPlayWatL, sPlayWatT, sPlayWatR, sPlayWatB -local sPlayHurt -- true while knockback owns the controller -local sPlayHurtEnd -- sim-clock when hurtMs has elapsed -local sPlayHurtHalf -- sim-clock when half of hurtMs has elapsed (landings - -- only count from here; restore = LATER of the two) -local sPlayHurtLand -- sim-clock of the first landing after the half mark (0 = none) -local sPlayInvulnUntil -- sim-clock end of the post-hurt mercy window -local sPlayClock -- the player's SIM-TIME clock: summed frame ms. - -- Wall-clock would shrink the coyote/buffer windows - -- on slow machines (90ms = fewer frames); sim time - -- keeps them frame-coherent everywhere and makes - -- hand-stepped tests deterministic. -local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") -local sSndMute -- true = swallow play calls (a user preference; survives teardown) -local sSndDead -- true after a play failure: degrade to silence, never errors -local sSndNote -- empty = healthy; else why audio is degraded - -constant kPI = 3.14159265358979 - --- ===================================================================== --- World / lifecycle --- ===================================================================== -command b2kSetup pGravityX, pGravityY - b2kTeardown - if pGravityX is empty then put 0 into pGravityX - if pGravityY is empty then put -10 into pGravityY - put b2NewWorld(pGravityX, pGravityY, true, true) into sWorld - put b2NewStaticBody(sWorld, 0, 0) into sDragAnchor - put 40 into sScale - put (the width of this card) div 2 into sOriginX - put (the height of this card) - 40 into sOriginY - put 4 into sSub - b2kResetTables - put empty into sLayers - put 1 into sNextBit - put 0 into sDragJoint - put false into sDragging - put false into sRunning - put false into sPaused - put false into sNeedFullSync - put empty into sKillY - b2kEventsReset -end b2kSetup - -command b2kResetTables - put empty into sBody - put empty into sCtrl - put empty into sShapeH - put empty into sRender - put empty into sVerts - put empty into sRad - put empty into sImgAngle - put empty into sDrawKey - put empty into sStatic - put false into sNeedFullSync - put empty into sSpawned - put empty into sSensor - put empty into sInCam - put empty into sOneWayBody -end b2kResetTables - -command b2kTeardown - put false into sRunning - put the milliseconds & random(9999) into sGen - if sWorld is not empty then b2DestroyWorld sWorld - put empty into sWorld - b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group - -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and - -- deterministic, and re-synthesis cost a fifth of a second on every - -- reset. b2kSoundsWipe purges them when you really want them gone. - b2kCamOff -- ...then dissolve the viewport (survivors go to the card) - b2kResetTables - put 0 into sDragJoint - put false into sDragging -end b2kTeardown - --- One-liner: world with gravity, walls around the card, loop running. -command b2kQuickStart pGravityY - b2kSetup 0, pGravityY - b2kAddWalls - b2kStart -end b2kQuickStart - -command b2kStart - if sWorld is empty then b2kSetup - put true into sRunning - put false into sPaused - put 0 into sAccum - put the milliseconds into sLast - put the milliseconds & "_" & random(1000000) into sGen - send ("b2kStep " & sGen) to me in 16 milliseconds -end b2kStart - -command b2kStop - put false into sRunning - put the milliseconds & random(9999) into sGen -end b2kStop - -command b2kPause - put true into sPaused -end b2kPause - -command b2kResume - put false into sPaused - put the milliseconds into sLast -end b2kResume - -function b2kIsRunning - return (sRunning is true) and (sPaused is not true) -end b2kIsRunning - --- Static, invisible walls along the four card edges. -command b2kAddWalls - local tL, tR, tT, tB - put b2kToWorldX(0) into tL - put b2kToWorldX(the width of this card) into tR - put b2kToWorldY(0) into tT - put b2kToWorldY(the height of this card) into tB - b2kEdge tL, tB, tR, tB - b2kEdge tL, tT, tR, tT - b2kEdge tL, tB, tL, tT - b2kEdge tR, tB, tR, tT -end b2kAddWalls - -command b2kEdge pWx1, pWy1, pWx2, pWy2 - local tBody - if sWorld is empty then exit b2kEdge -- no world yet: a wall is a quiet no-op - put b2NewStaticBody(sWorld, 0, 0) into tBody - get b2AddSegment(tBody, pWx1, pWy1, pWx2, pWy2, 0.6, 0.1) -end b2kEdge - --- A wide invisible ground line at screen height pScreenY (default: near bottom). -command b2kAddGround pScreenY - local tBody, tWy - if sWorld is empty then exit b2kAddGround - if pScreenY is empty then put (the height of this card) - 40 into pScreenY - put b2kToWorldY(pScreenY) into tWy - put b2NewStaticBody(sWorld, 0, tWy) into tBody - get b2AddSegment(tBody, -500, 0, 500, 0, 0.7, 0.1) -end b2kAddGround - --- A static collision segment between two SCREEN points — custom walls, ramps, --- ledges and floors. Like the card-edge walls it is invisible (draw your own --- graphic to match) and lives for the world's lifetime; it isn't tracked as a --- body, so b2kClear leaves it in place. --- Destroy any moving Kit body whose centre falls below this SCREEN y --- (empty = off). Crates shoved into pits, enemies knocked off the level: --- gone instead of falling forever. The PLAYER's body is exempt (the game --- owns its respawn). Each removal first sends "b2kFell " to the --- frame target so the game can clean up companions (bound sprites, table --- slots) -- do not delete the control inside that handler; the Kit is --- mid-removal (Kit-spawned controls are deleted for you). -command b2kKillFloor pScreenY - put pScreenY into sKillY -end b2kKillFloor - -command b2kWall pX1, pY1, pX2, pY2 - b2kEdge b2kToWorldX(pX1), b2kToWorldY(pY1), b2kToWorldX(pX2), b2kToWorldY(pY2) -end b2kWall - --- Remove every dynamic body (b2kRemove deletes kit-spawned controls; attached --- controls are just detached). Sprites are Kit-created controls, so they go --- too; loaded sheets stay (they are assets, not world state). -command b2kClear - local tRef, tList, d - b2kSpritesClear - b2kPlayerForget false -- the player is world state; its tuning is config - put empty into tList - repeat for each key tRef in sBody - if sStatic[tRef] is not true then put tRef & cr after tList - end repeat - repeat for each line d in tList - if d is not empty then b2kRemove d - end repeat -end b2kClear - --- ===================================================================== --- Configuration --- ===================================================================== -function b2kNumberOr pValue, pDefault - if pValue is empty then return pDefault - if pValue is not a number then return pDefault - return pValue -end b2kNumberOr - -function b2kClamp pValue, pLo, pHi - local tValue - put b2kNumberOr(pValue, pLo) into tValue - if tValue < pLo then put pLo into tValue - if tValue > pHi then put pHi into tValue - return tValue -end b2kClamp - -command b2kSetScale pPixelsPerMetre - put b2kClamp(pPixelsPerMetre, 1, 10000) into sScale -end b2kSetScale - -command b2kSetOrigin pScreenX, pScreenY - put b2kNumberOr(pScreenX, sOriginX) into sOriginX - put b2kNumberOr(pScreenY, sOriginY) into sOriginY -end b2kSetOrigin - -command b2kSetGravity pGx, pGy - put b2kNumberOr(pGx, 0) into pGx - put b2kNumberOr(pGy, -10) into pGy - if sWorld is not empty then b2SetGravity sWorld, pGx, pGy -end b2kSetGravity - -command b2kSetSubsteps pN - put round(b2kClamp(pN, 1, 64)) into sSub -end b2kSetSubsteps - --- World toggles: island sleeping (saves CPU) and continuous collision (CCD, --- stops fast bodies tunnelling through thin walls). -command b2kEnableSleeping pFlag - if sWorld is not empty then b2EnableSleeping sWorld, (pFlag is true) -end b2kEnableSleeping - -command b2kEnableContinuous pFlag - if sWorld is not empty then b2EnableContinuous sWorld, (pFlag is true) -end b2kEnableContinuous - -command b2kContactTarget pObject - put pObject into sContactObj -end b2kContactTarget - --- Object that receives an `on b2kFrame` message once per simulated frame (after --- bodies are synced). Use it for per-frame logic: motors, custom drawing, input. -command b2kFrameTarget pObject - put pObject into sFrameObj -end b2kFrameTarget - -function b2kWorld - return sWorld -end b2kWorld - --- Sanity check from kit-only code: returns the native shim's ABI version (4), --- i.e. that the box2dxt extension and its native library are loaded and in sync. -function b2kVersion - return b2Version() -end b2kVersion - --- ===================================================================== --- Coordinate conversion (sim Y up; screen Y down) --- ===================================================================== -function b2kToScreenX pWx - return sOriginX + pWx * sScale -end b2kToScreenX - -function b2kToScreenY pWy - return sOriginY - pWy * sScale -end b2kToScreenY - -function b2kToWorldX pSx - if sScale is empty or sScale = 0 then return 0 -- before b2kSetup: stay safe - return (pSx - sOriginX) / sScale -end b2kToWorldX - -function b2kToWorldY pSy - if sScale is empty or sScale = 0 then return 0 - return (sOriginY - pSy) / sScale -end b2kToWorldY - --- ===================================================================== --- Attaching existing controls (pDynamic defaults to true) --- ===================================================================== -command b2kAddBox pControl, pDynamic - local tRef, tType, tFixed, tBody, tHw, tHh, tWx, tWy, tGraphic, tImage - if sWorld is empty then return 0 -- attach needs a world (b2kSetup first) - put the long id of pControl into tRef - put (pDynamic is not false) into pDynamic - if pDynamic then put 2 into tType - else put 0 into tType - put ((the width of pControl) / 2) / sScale into tHw - put ((the height of pControl) / 2) / sScale into tHh - if tHw <= 0 or tHh <= 0 then return 0 - put b2kToWorldX(item 1 of the loc of pControl) into tWx - put b2kToWorldY(item 2 of the loc of pControl) into tWy - put (word 1 of tRef is "graphic") into tGraphic - put (word 1 of tRef is "image") into tImage - -- Graphics rotate as polygons; dynamic images rotate via `the angle`. Other - -- control types (button, field, ...) can't be shown rotated, so we lock - -- their rotation to keep the simulation consistent with the upright render. - if tGraphic or (tImage and pDynamic) then put false into tFixed - else put true into tFixed - put b2NewBody(sWorld, tType, tWx, tWy, 0, false, tFixed) into tBody - b2ShapeDefEnableSensorEvents true -- so sensors can detect this shape - put b2AddBox(tBody, tHw, tHh, 1.0, 0.4, 0.2) into sShapeH[tRef] - b2kRegister tRef, tBody, (not pDynamic) - if tGraphic and pDynamic then - set the style of pControl to "polygon" - put "poly" into sRender[tRef] - put ("-" & tHw) & "," & ("-" & tHh) & cr & tHw & "," & ("-" & tHh) & cr \ - & tHw & "," & tHh & cr & ("-" & tHw) & "," & tHh into sVerts[tRef] - else if tImage and pDynamic then - put "image" into sRender[tRef] - else - put "loc" into sRender[tRef] - end if - -- Draw the new body NOW. The style switch above left a "poly" graphic with - -- no points (invisible), and the move-event sync can't help: a world that - -- isn't stepping emits no move events, so a part spawned while the loop is - -- stopped would stay invisible until the first Run. Attach = visible. - b2kDrawBody tRef, tWx, tWy, 0 - return tBody -end b2kAddBox - -command b2kAddBall pControl, pDynamic - local tRef, tType, tBody, tRad, tWx, tWy - if sWorld is empty then return 0 - put the long id of pControl into tRef - put (pDynamic is not false) into pDynamic - if pDynamic then put 2 into tType - else put 0 into tType - put ((the width of pControl) / 2) / sScale into tRad - if tRad <= 0 then return 0 - put b2kToWorldX(item 1 of the loc of pControl) into tWx - put b2kToWorldY(item 2 of the loc of pControl) into tWy - put b2NewBody(sWorld, tType, tWx, tWy, 0, false, false) into tBody - b2ShapeDefEnableSensorEvents true -- so sensors can detect this shape - put b2AddCircle(tBody, 0, 0, tRad, 1.0, 0.4, 0.4) into sShapeH[tRef] - b2kRegister tRef, tBody, (not pDynamic) - if word 1 of tRef is "graphic" then put "ball" into sRender[tRef] - else if word 1 of tRef is "image" then put "image" into sRender[tRef] - else put "loc" into sRender[tRef] - put tRad into sRad[tRef] - b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped - return tBody -end b2kAddBall - --- Capsule ("pill"): the long side of the control's rect is the capsule axis, --- the short side its diameter. Graphics render as a rounded outline (and rotate); --- images rotate via `the angle`; other controls follow position upright. -command b2kAddCapsule pControl, pDynamic - local tRef, tType, tFixed, tBody, tWx, tWy, tWm, tHm, tR, tL, tHoriz, tGraphic, tImage - if sWorld is empty then return 0 - put the long id of pControl into tRef - put (pDynamic is not false) into pDynamic - if pDynamic then put 2 into tType - else put 0 into tType - put (the width of pControl) / sScale into tWm - put (the height of pControl) / sScale into tHm - if tWm <= 0 or tHm <= 0 then return 0 - put (tWm >= tHm) into tHoriz - if tHoriz then - put tHm / 2 into tR - put max(0, tWm / 2 - tR) into tL - else - put tWm / 2 into tR - put max(0, tHm / 2 - tR) into tL - end if - put b2kToWorldX(item 1 of the loc of pControl) into tWx - put b2kToWorldY(item 2 of the loc of pControl) into tWy - put (word 1 of tRef is "graphic") into tGraphic - put (word 1 of tRef is "image") into tImage - if tGraphic or (tImage and pDynamic) then put false into tFixed - else put true into tFixed - put b2NewBody(sWorld, tType, tWx, tWy, 0, false, tFixed) into tBody - b2ShapeDefEnableSensorEvents true -- so sensors can detect this shape - if tHoriz then - put b2AddCapsule(tBody, - tL, 0, tL, 0, tR, 1.0, 0.4, 0.2) into sShapeH[tRef] - else - put b2AddCapsule(tBody, 0, - tL, 0, tL, tR, 1.0, 0.4, 0.2) into sShapeH[tRef] - end if - b2kRegister tRef, tBody, (not pDynamic) - if tGraphic and pDynamic then - set the style of pControl to "polygon" - put "poly" into sRender[tRef] - put b2kCapsuleVerts(tL, tR, tHoriz) into sVerts[tRef] - else if tImage and pDynamic then - put "image" into sRender[tRef] - else - put "loc" into sRender[tRef] - end if - -- Same as b2kAddBox: the freshly point-less "poly" graphic must be drawn - -- here, because a stopped world never emits the move event that would. - b2kDrawBody tRef, tWx, tWy, 0 - return tBody -end b2kAddCapsule - --- Build a body from a polygon graphic's own points (<= 8 vertices, convex). -command b2kAddPolygon pControl, pDynamic - local tRef, tType, tBody, tCx, tCy, tWx, tWy, tPoints, p, tLocal, lx, ly, tCount - if sWorld is empty then return 0 - put the long id of pControl into tRef - put (pDynamic is not false) into pDynamic - if pDynamic then put 2 into tType - else put 0 into tType - put item 1 of the loc of pControl into tCx - put item 2 of the loc of pControl into tCy - put b2kToWorldX(tCx) into tWx - put b2kToWorldY(tCy) into tWy - put b2NewBody(sWorld, tType, tWx, tWy, 0, false, false) into tBody - b2PolyBegin - put empty into tLocal - put 0 into tCount - repeat for each line p in the points of pControl - if p is empty then next repeat - if tCount >= 8 then next repeat - put (item 1 of p - tCx) / sScale into lx - put - ((item 2 of p - tCy) / sScale) into ly - b2PolyAddPoint lx, ly - put lx & "," & ly & cr after tLocal - add 1 to tCount - end repeat - if tCount < 3 then - b2DestroyBody tBody - return 0 - end if - b2ShapeDefEnableSensorEvents true -- so sensors can detect this shape - put b2AddPolygon(tBody, 1.0, 0.4, 0.2) into sShapeH[tRef] - b2kRegister tRef, tBody, (not pDynamic) - put "poly" into sRender[tRef] - put tLocal into sVerts[tRef] - b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped - return tBody -end b2kAddPolygon - -command b2kAddStatic pControl - b2kAddBox pControl, false -end b2kAddStatic - -command b2kRegister pRef, pBody, pIsStatic - put pBody into sBody[pRef] - put pRef into sCtrl[pBody] - put (pIsStatic is true) into sStatic[pRef] -end b2kRegister - --- ===================================================================== --- Spawning new controls (create + attach in one call). Returns control ref. --- ===================================================================== -command b2kSpawnBox pScreenX, pScreenY, pW, pH, pColor - local tName, tRef - if sWorld is empty then return empty -- spawn needs a world (b2kSetup first) - if pW is empty then put 40 into pW - put b2kClamp(pW, 1, 10000) into pW - if pH is empty then put pW into pH - put b2kClamp(pH, 1, 10000) into pH - put b2kNumberOr(pScreenX, sOriginX) into pScreenX - put b2kNumberOr(pScreenY, sOriginY) into pScreenY - put "b2kspawn_" & the milliseconds & "_" & random(1000000) into tName - if sCamGroup is empty then - create graphic (tName) - else - create graphic (tName) in group "b2kcam_view" - end if - put the long id of graphic (tName) into tRef - if sCamGroup is not empty then put true into sInCam[tRef] - set the style of tRef to "rectangle" - set the filled of tRef to true - set the foregroundColor of tRef to "20,20,24" - if pColor is not empty then - try - set the backgroundColor of tRef to pColor - catch tErr - -- an unknown colour name (CSS names like "teal" are not LC/X11 - -- names) must not abort the spawn and orphan the control - end try - end if - set the width of tRef to pW - set the height of tRef to pH - set the loc of tRef to round(pScreenX - b2kCamShiftX(tRef)) & "," & round(pScreenY - b2kCamShiftY(tRef)) - b2kAddBox tRef - put true into sSpawned[tRef] - return tRef -end b2kSpawnBox - -command b2kSpawnBall pScreenX, pScreenY, pDiameter, pColor - local tName, tRef - if sWorld is empty then return empty - if pDiameter is empty then put 40 into pDiameter - put b2kClamp(pDiameter, 1, 10000) into pDiameter - put b2kNumberOr(pScreenX, sOriginX) into pScreenX - put b2kNumberOr(pScreenY, sOriginY) into pScreenY - put "b2kspawn_" & the milliseconds & "_" & random(1000000) into tName - if sCamGroup is empty then - create graphic (tName) - else - create graphic (tName) in group "b2kcam_view" - end if - put the long id of graphic (tName) into tRef - if sCamGroup is not empty then put true into sInCam[tRef] - set the style of tRef to "oval" - set the filled of tRef to true - set the foregroundColor of tRef to "20,20,24" - if pColor is not empty then - try - set the backgroundColor of tRef to pColor - catch tErr - -- an unknown colour name (CSS names like "teal" are not LC/X11 - -- names) must not abort the spawn and orphan the control - end try - end if - set the width of tRef to pDiameter - set the height of tRef to pDiameter - set the loc of tRef to round(pScreenX - b2kCamShiftX(tRef)) & "," & round(pScreenY - b2kCamShiftY(tRef)) - b2kAddBall tRef - put true into sSpawned[tRef] - return tRef -end b2kSpawnBall - --- Create a pill-shaped graphic and attach a capsule body. pLength is along the --- pill's long axis, pThickness across it; horizontal if pLength >= pThickness. -command b2kSpawnCapsule pScreenX, pScreenY, pLength, pThickness, pColor - local tName, tRef - if sWorld is empty then return empty - if pLength is empty then put 80 into pLength - if pThickness is empty then put 40 into pThickness - put b2kClamp(pLength, 1, 10000) into pLength - put b2kClamp(pThickness, 1, 10000) into pThickness - put b2kNumberOr(pScreenX, sOriginX) into pScreenX - put b2kNumberOr(pScreenY, sOriginY) into pScreenY - put "b2kspawn_" & the milliseconds & "_" & random(1000000) into tName - if sCamGroup is empty then - create graphic (tName) - else - create graphic (tName) in group "b2kcam_view" - end if - put the long id of graphic (tName) into tRef - if sCamGroup is not empty then put true into sInCam[tRef] - set the style of tRef to "polygon" - set the filled of tRef to true - set the foregroundColor of tRef to "20,20,24" - if pColor is not empty then - try - set the backgroundColor of tRef to pColor - catch tErr - -- an unknown colour name (CSS names like "teal" are not LC/X11 - -- names) must not abort the spawn and orphan the control - end try - end if - if pLength >= pThickness then - set the width of tRef to pLength - set the height of tRef to pThickness - else - set the width of tRef to pThickness - set the height of tRef to pLength - end if - set the loc of tRef to round(pScreenX - b2kCamShiftX(tRef)) & "," & round(pScreenY - b2kCamShiftY(tRef)) - b2kAddCapsule tRef - put true into sSpawned[tRef] - return tRef -end b2kSpawnCapsule - --- ===================================================================== --- Materials & body properties --- ===================================================================== -command b2kSetBounce pControl, pRestitution - local s - put sShapeH[the long id of pControl] into s - put b2kClamp(pRestitution, 0, 2) into pRestitution - if s is not empty then b2SetShapeRestitution s, pRestitution -end b2kSetBounce - -command b2kSetFriction pControl, pFriction - local s - put sShapeH[the long id of pControl] into s - put b2kClamp(pFriction, 0, 10) into pFriction - if s is not empty then b2SetShapeFriction s, pFriction -end b2kSetFriction - -command b2kSetDensity pControl, pDensity - local s - put sShapeH[the long id of pControl] into s - put b2kClamp(pDensity, 0, 10000) into pDensity - if s is not empty then b2SetShapeDensity s, pDensity -end b2kSetDensity - --- Re-fit a body's collision shape to the control's CURRENT size, in place on the --- SAME body so any joints stay valid. pShape is "box" | "ball" | "capsule" | "poly". --- Resize the control's graphic first, then re-apply materials afterwards. -command b2kReshape pControl, pShape - local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa - put the long id of pControl into tRef - if sBody[tRef] is empty then exit b2kReshape - put sBody[tRef] into tBody - -- Build the new shape first and drop the old one only afterwards, so the body - -- is never momentarily shapeless and a rejected shape leaves the original - -- intact (any joints on the body stay valid throughout). - put sShapeH[tRef] into tOld - put empty into sShapeH[tRef] - b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors - if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping - switch pShape - case "ball" - put ((the width of pControl) / 2) / sScale into tR - if tR <= 0 then - put tOld into sShapeH[tRef] - b2ShapeDefReset -- drop the pending one-shot flags set above - exit b2kReshape - end if - put b2AddCircle(tBody, 0, 0, tR, 1.0, 0.4, 0.4) into sShapeH[tRef] - put tR into sRad[tRef] - break - case "capsule" - put (the width of pControl) / sScale into tWm - put (the height of pControl) / sScale into tHm - if tWm <= 0 or tHm <= 0 then - put tOld into sShapeH[tRef] - b2ShapeDefReset -- drop the pending one-shot flags set above - exit b2kReshape - end if - put (tWm >= tHm) into tHoriz - if tHoriz then - put tHm / 2 into tR - put max(0, tWm / 2 - tR) into tL - put b2AddCapsule(tBody, - tL, 0, tL, 0, tR, 1.0, 0.4, 0.2) into sShapeH[tRef] - else - put tWm / 2 into tR - put max(0, tHm / 2 - tR) into tL - put b2AddCapsule(tBody, 0, - tL, 0, tL, tR, 1.0, 0.4, 0.2) into sShapeH[tRef] - end if - put b2kCapsuleVerts(tL, tR, tHoriz) into sVerts[tRef] - break - case "poly" - put item 1 of the loc of pControl into tCx - put item 2 of the loc of pControl into tCy - b2PolyBegin - put empty into tNewVerts - put 0 into tCount - repeat for each line p in the points of pControl - if p is empty then next repeat - if tCount >= 8 then next repeat - put (item 1 of p - tCx) / sScale into lx - put - ((item 2 of p - tCy) / sScale) into ly - b2PolyAddPoint lx, ly - put lx & "," & ly & cr after tNewVerts - add 1 to tCount - end repeat - if tCount < 3 then -- degenerate outline: keep the existing shape - put tOld into sShapeH[tRef] - b2ShapeDefReset -- drop the pending one-shot flags set above - exit b2kReshape - end if - put b2AddPolygon(tBody, 1.0, 0.4, 0.2) into sShapeH[tRef] - put tNewVerts into sVerts[tRef] - break - default - put ((the width of pControl) / 2) / sScale into tHw - put ((the height of pControl) / 2) / sScale into tHh - if tHw <= 0 or tHh <= 0 then - put tOld into sShapeH[tRef] - b2ShapeDefReset -- drop the pending one-shot flags set above - exit b2kReshape - end if - put b2AddBox(tBody, tHw, tHh, 1.0, 0.4, 0.2) into sShapeH[tRef] - if sRender[tRef] is "poly" then - put ("-" & tHw) & "," & ("-" & tHh) & cr & tHw & "," & ("-" & tHh) & cr \ - & tHw & "," & tHh & cr & ("-" & tHw) & "," & tHh into sVerts[tRef] - end if - end switch - if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- The size/outline changed but the body need not have moved, so the move-event - -- sync would skip it (an at-rest body produces no move event). Bust the - -- pose-keyed draw cache and redraw at the body's current pose right now so the - -- new shape is visible immediately, awake or asleep. - delete variable sDrawKey[tRef] - put b2BodyX(tBody) into tWx - put b2BodyY(tBody) into tWy - if sRender[tRef] is "poly" or sRender[tRef] is "image" then put b2BodyAngle(tBody) into tWa - else put 0 into tWa - lock screen - b2kDrawBody tRef, tWx, tWy, tWa - unlock screen -end b2kReshape - --- Every body-targeting wrapper resolves the control's body first and quietly --- no-ops when there is none (unregistered, removed, or pre-setup): the kit's --- contract is that a missing body is never a script error. -command b2kSetBullet pControl, pFlag - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetBullet - b2SetBullet b, (pFlag is true) -end b2kSetBullet - -command b2kSetFixedRotation pControl, pFlag - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetFixedRotation - b2SetFixedRotation b, (pFlag is true) -end b2kSetFixedRotation - -command b2kSetGravityScale pControl, pScale - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetGravityScale - b2SetGravityScale b, pScale -end b2kSetGravityScale - -command b2kSetDamping pControl, pLinear, pAngular - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetDamping - if pLinear is not empty then b2SetLinearDamping b, pLinear - if pAngular is not empty then b2SetAngularDamping b, pAngular -end b2kSetDamping - -command b2kWake pControl - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kWake - b2SetAwake b, true -end b2kWake - --- Send a body to sleep (it stops simulating until something wakes it). -command b2kSleep pControl - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSleep - b2SetAwake b, false -end b2kSleep - --- Allow or forbid THIS body ever falling asleep (vs b2kEnableSleeping, --- which switches the whole world). The player controller forbids it: a --- character must respond to input even after standing still for minutes. -command b2kSetSleepEnabled pControl, pFlag - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetSleepEnabled - b2EnableSleep b, (pFlag is true) - if pFlag is not true then b2SetAwake b, true -end b2kSetSleepEnabled - --- Below this speed (pixels/sec) a body may fall asleep. Lower = stays awake --- longer; higher = sleeps sooner (saves CPU). -command b2kSetSleepThreshold pControl, pPxPerSec - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetSleepThreshold - b2SetSleepThreshold b, pPxPerSec / sScale -end b2kSetSleepThreshold - --- ===================================================================== --- Acting on bodies (velocities/kicks are in PIXELS/sec, screen-oriented) --- ===================================================================== -command b2kSetVelocity pControl, pVxPx, pVyPx - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSetVelocity - b2SetVelocity b, pVxPx / sScale, - pVyPx / sScale - -- setting a velocity means "move": wake the body. Raw SetVelocity - -- does NOT wake (why b2kPush always called SetAwake) - a SLEEPING - -- kinematic (a parked gate) given one cached write stayed frozen, - -- which read as "the pressure plate is flaky". - b2SetAwake b, true -end b2kSetVelocity - -command b2kPush pControl, pDvxPx, pDvyPx - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kPush - b2SetVelocity b, b2BodyVX(b) + pDvxPx / sScale, b2BodyVY(b) - pDvyPx / sScale - b2SetAwake b, true -end b2kPush - -command b2kSpin pControl, pDegPerSec - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSpin - b2SetAngularVelocity b, pDegPerSec * kPI / 180 -end b2kSpin - --- Add to the current spin (vs b2kSpin which sets it absolutely). -command b2kSpinBy pControl, pDegPerSec - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kSpinBy - b2SetAngularVelocity b, b2BodyAngularVelocity(b) + pDegPerSec * kPI / 180 - b2SetAwake b, true -end b2kSpinBy - --- Continuous, screen-oriented force (thrusters, wind). Apply each frame for a --- sustained effect; b2kPush is the one-shot impulse equivalent. -command b2kForce pControl, pFxPx, pFyPx - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kForce - b2ApplyForce b, pFxPx / sScale, - pFyPx / sScale, true -end b2kForce - --- A one-shot impulse (a sharp kick), screen-oriented. Mass-aware: heavier bodies --- move less. b2kPush changes velocity directly; this is the impulse equivalent. -command b2kImpulse pControl, pIxPx, pIyPx - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kImpulse - b2ApplyImpulse b, pIxPx / sScale, - pIyPx / sScale, true -end b2kImpulse - --- A continuous turning force (same units as motor torque). Positive turns one way, --- negative the other. Apply each frame for a sustained twist; pairs with b2kForce. -command b2kTorque pControl, pTorque - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kTorque - b2ApplyTorque b, pTorque, true -end b2kTorque - --- A one-shot twist (a sharp angular kick), mass-aware: bodies with more --- rotational inertia turn less. b2kSpinBy sets the spin directly; this is the --- impulse equivalent, and the angular partner of b2kImpulse. -command b2kAngularImpulse pControl, pImpulse - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kAngularImpulse - b2ApplyAngularImpulse b, pImpulse, true -end b2kAngularImpulse - --- Change a body's type at runtime: "static", "kinematic", or "dynamic". -command b2kSetType pControl, pType - local tRef, tCode - put the long id of pControl into tRef - if sBody[tRef] is empty then exit b2kSetType - switch pType - case "static" - put 0 into tCode - break - case "kinematic" - put 1 into tCode - break - default - put 2 into tCode - end switch - b2SetBodyType sBody[tRef], tCode - put (tCode is 0) into sStatic[tRef] -- only true statics are skipped by sync - if tCode is not 0 then b2SetAwake sBody[tRef], true - put true into sNeedFullSync -end b2kSetType - -command b2kSetStatic pControl - b2kSetType pControl, "static" -end b2kSetStatic - -command b2kSetDynamic pControl - b2kSetType pControl, "dynamic" -end b2kSetDynamic - -command b2kSetKinematic pControl - b2kSetType pControl, "kinematic" -end b2kSetKinematic - --- Temporarily take a body out of / put it back into the simulation. -command b2kDisable pControl - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kDisable - b2DisableBody b -end b2kDisable - -command b2kEnable pControl - local b - put sBody[the long id of pControl] into b - if b is empty then exit b2kEnable - b2EnableBody b - put true into sNeedFullSync -end b2kEnable - -command b2kMoveTo pControl, pScreenX, pScreenY, pAngleDeg - local tRef, tWx, tWy, tWa - if pAngleDeg is empty then put 0 into pAngleDeg - put the long id of pControl into tRef - if sBody[tRef] is empty then exit b2kMoveTo - put b2kToWorldX(pScreenX) into tWx - put b2kToWorldY(pScreenY) into tWy - put pAngleDeg * kPI / 180 into tWa - b2SetTransform sBody[tRef], tWx, tWy, tWa - b2kDrawBody tRef, tWx, tWy, tWa -end b2kMoveTo - -command b2kRemove pControl - local tRef, tBody, tWasSpawned - put the long id of pControl into tRef - put sBody[tRef] into tBody - if tBody is not empty and tBody > 0 then - b2DestroyBody tBody - delete variable sCtrl[tBody] - end if - put (sSpawned[tRef] is true) into tWasSpawned - delete variable sBody[tRef] - delete variable sShapeH[tRef] - delete variable sRender[tRef] - delete variable sVerts[tRef] - delete variable sRad[tRef] - delete variable sImgAngle[tRef] - delete variable sStatic[tRef] - delete variable sSpawned[tRef] - delete variable sSensor[tRef] - delete variable sInCam[tRef] - -- If the kit created this control (b2kSpawn...), delete it too, so removing - -- a body never leaves a dead graphic behind. Attached controls are left alone. - if tWasSpawned then - try - delete tRef - end try - end if -end b2kRemove - --- A radial "blast" that kicks nearby dynamic bodies outward. Uses Box2D's native --- b2World_Explode: it's shape-perimeter aware (so a wide plank catches more blast --- than a small ball) and affects EVERY dynamic body in range, not just kit ones. --- pPowerPx is the impulse per unit length of facing surface; pRadiusPx the reach. -command b2kExplode pScreenX, pScreenY, pRadiusPx, pPowerPx - if pRadiusPx is empty then put 180 into pRadiusPx - if pPowerPx is empty then put 900 into pPowerPx - if sWorld is empty then exit b2kExplode - b2WorldExplode sWorld, b2kToWorldX(pScreenX), b2kToWorldY(pScreenY), \ - pRadiusPx / sScale, (pRadiusPx / sScale) * 0.5, pPowerPx / sScale -end b2kExplode - --- The original velocity-based blast (pre-v3): kicks only kit-tracked dynamic --- bodies, ignoring their size. Kept for callers that relied on the old feel. -command b2kExplodeLegacy pScreenX, pScreenY, pRadiusPx, pPowerPx - local cx, cy, tRadM, tRef, b, dx, dy, d, f - if pRadiusPx is empty then put 180 into pRadiusPx - if pPowerPx is empty then put 900 into pPowerPx - put b2kToWorldX(pScreenX) into cx - put b2kToWorldY(pScreenY) into cy - put pRadiusPx / sScale into tRadM - repeat for each key tRef in sBody - if sStatic[tRef] is true then next repeat - put sBody[tRef] into b - put b2BodyX(b) - cx into dx - put b2BodyY(b) - cy into dy - put sqrt(dx * dx + dy * dy) into d - if d > 0.02 and d < tRadM then - put (1 - d / tRadM) * (pPowerPx / sScale) into f - b2SetVelocity b, b2BodyVX(b) + dx / d * f, b2BodyVY(b) + dy / d * f - b2SetAwake b, true - end if - end repeat -end b2kExplodeLegacy - --- ===================================================================== --- Getters --- ===================================================================== -function b2kBodyOf pControl - return sBody[the long id of pControl] -end b2kBodyOf - --- A body's position in SCREEN pixels as "x,y" (the position partner of --- b2kVelocity). Controls already follow their body, but this reads the exact, --- sub-pixel centre — handy for aiming, distance checks, or mixing with b2… -function b2kPosition pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return "0,0" - return b2kToScreenX(b2BodyX(b)) & "," & b2kToScreenY(b2BodyY(b)) -end b2kPosition - --- A body's centre of mass in SCREEN pixels as "x,y". Differs from b2kPosition --- for off-centre or compound shapes; it's the true pivot for spin and torque. -function b2kWorldCenter pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return "0,0" - return b2kToScreenX(b2BodyWorldCenterX(b)) & "," & b2kToScreenY(b2BodyWorldCenterY(b)) -end b2kWorldCenter - -function b2kVelocity pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return "0,0" - return (b2BodyVX(b) * sScale) & "," & (- b2BodyVY(b) * sScale) -end b2kVelocity - -function b2kSpeed pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return 0 - return sqrt((b2BodyVX(b) * sScale) ^ 2 + (b2BodyVY(b) * sScale) ^ 2) -end b2kSpeed - -function b2kAngle pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return 0 - return b2BodyAngle(b) * 180 / kPI -end b2kAngle - -function b2kIsAwake pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return false - return b2BodyIsAwake(b) -end b2kIsAwake - --- Current rotation speed in DEGREES/sec (the getter for b2kSpin / b2kSpinBy). -function b2kSpinRate pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return 0 - return b2BodyAngularVelocity(b) * 180 / kPI -end b2kSpinRate - -function b2kIsBullet pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return false - return b2BodyIsBullet(b) -end b2kIsBullet - -function b2kIsEnabled pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return false - return b2BodyIsEnabled(b) -end b2kIsEnabled - -function b2kMass pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return 0 - return b2BodyMass(b) -end b2kMass - --- The body's per-body gravity multiplier (the getter for b2kSetGravityScale). -function b2kGravityScale pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return 0 - return b2BodyGravityScale(b) -end b2kGravityScale - --- The body's drag as "linear,angular" (the getter for b2kSetDamping). -function b2kDamping pControl - local b - put sBody[the long id of pControl] into b - if b is empty then return "0,0" - return b2BodyLinearDamping(b) & "," & b2BodyAngularDamping(b) -end b2kDamping - --- Body type as a word: "static", "kinematic", or "dynamic" (mirrors b2kSetType). -function b2kBodyType pControl - local b, tCode - put sBody[the long id of pControl] into b - if b is empty then return "static" - put b2BodyType(b) into tCode - if tCode is 1 then return "kinematic" - if tCode is 2 then return "dynamic" - return "static" -end b2kBodyType - --- How many bodies the kit is currently tracking. -function b2kBodyCount - return the number of lines of the keys of sBody -end b2kBodyCount - --- How many dynamic bodies are currently awake (i.e. actively simulating). -function b2kAwakeCount - local tRef, tCount - put 0 into tCount - repeat for each key tRef in sBody - if sStatic[tRef] is not true and b2BodyIsAwake(sBody[tRef]) then add 1 to tCount - end repeat - return tCount -end b2kAwakeCount - --- Which attached control sits at a screen point (empty if none). -function b2kControlAt pScreenX, pScreenY - if sWorld is empty then return empty - return sCtrl[b2BodyAtPoint(sWorld, b2kToWorldX(pScreenX), b2kToWorldY(pScreenY))] -end b2kControlAt - --- True if SCREEN point (x,y) is inside this control's actual collision shape — --- rotation- and shape-aware, unlike a bounding-box test. b2kControlAt searches --- every body; this tests one control you already have (e.g. a precise click hit). -function b2kControlContains pControl, pScreenX, pScreenY - local tRef - put the long id of pControl into tRef - if sShapeH[tRef] is empty then return false - return b2ShapeTestPoint(sShapeH[tRef], b2kToWorldX(pScreenX), b2kToWorldY(pScreenY)) -end b2kControlContains - --- Cast a ray between two screen points; returns the control hit (or empty). --- Then b2kRayHitX() / b2kRayHitY() give the hit point in screen pixels. -function b2kRayHit pX1, pY1, pX2, pY2 - local dx, dy - if sWorld is not empty and \ - b2CastRayClosest(sWorld, b2kToWorldX(pX1), b2kToWorldY(pY1), b2kToWorldX(pX2), b2kToWorldY(pY2)) then - put round(b2kToScreenX(b2RayX())) into sRayX - put round(b2kToScreenY(b2RayY())) into sRayY - put b2RayNormalX() into sRayNX -- surface normal, screen-oriented - put - b2RayNormalY() into sRayNY - put pX2 - pX1 into dx - put pY2 - pY1 into dy - put b2RayFraction() * sqrt(dx * dx + dy * dy) into sRayDist - -- the handle is fetched for the control lookup anyway; stashing it - -- lets the player's probe classify the ground (one-way chain or - -- solid) without a single extra FFI call - put b2RayBody() into sRayBodyH - return sCtrl[sRayBodyH] - end if - put empty into sRayX - put empty into sRayY - put empty into sRayNX - put empty into sRayNY - put empty into sRayDist - put empty into sRayBodyH - return empty -end b2kRayHit - -function b2kRayHitX - return sRayX -end b2kRayHitX - -function b2kRayHitY - return sRayY -end b2kRayHitY - --- Surface normal (a unit direction, screen-oriented) at the last ray hit. -function b2kRayHitNormalX - return sRayNX -end b2kRayHitNormalX - -function b2kRayHitNormalY - return sRayNY -end b2kRayHitNormalY - --- Distance in pixels from the ray's start to the last hit point. -function b2kRayDist - return sRayDist -end b2kRayDist - --- ---- contacts, polling style ---------------------------------------- --- The kit also SENDS `on b2kContact` / `on b2kEndContact` to your --- b2kContactTarget. These functions instead let you POLL the same snapshot --- (e.g. from `on b2kFrame`) without registering a target. The snapshot --- covers EVERY fixed step the frame ran (nothing lost on a 2-step frame, --- nothing repeated on a 0-step one); indices are 1-based. Each accessor --- returns the touching control's long id (empty for a wall, the ground, --- or any body the kit isn't tracking). -function b2kContactCount - if sEvtCN is empty then return 0 - return sEvtCN -end b2kContactCount - -function b2kContactA pIndex - return sCtrl[sEvtCA[pIndex]] -end b2kContactA - -function b2kContactB pIndex - return sCtrl[sEvtCB[pIndex]] -end b2kContactB - -function b2kEndContactCount - if sEvtEN is empty then return 0 - return sEvtEN -end b2kEndContactCount - -function b2kEndContactA pIndex - return sCtrl[sEvtEA[pIndex]] -end b2kEndContactA - -function b2kEndContactB pIndex - return sCtrl[sEvtEB[pIndex]] -end b2kEndContactB - --- ===================================================================== --- Joints (return a joint handle; pass it to b2kMotor / b2kRemoveJoint) --- ===================================================================== --- Revolute pivot at screen point (x,y). If ctrlB is empty, pins ctrlA to the --- world at that point (great for pendulums and swinging doors). -command b2kHinge pCtrlA, pCtrlB, pScreenX, pScreenY - local bA, bB, wx, wy, aA, aB, j - put sBody[the long id of pCtrlA] into bA - if bA is empty or sWorld is empty then return 0 - if pCtrlB is not empty and sBody[the long id of pCtrlB] is empty then return 0 - put b2kToWorldX(pScreenX) into wx - put b2kToWorldY(pScreenY) into wy - put b2kLocalAnchor(bA, wx, wy) into aA - if pCtrlB is empty then - put sDragAnchor into bB - put wx & "," & wy into aB - else - put sBody[the long id of pCtrlB] into bB - put b2kLocalAnchor(bB, wx, wy) into aB - end if - put b2RevoluteJoint(sWorld, bA, bB, item 1 of aA, item 2 of aA, item 1 of aB, item 2 of aB, false) into j - return j -end b2kHinge - -command b2kWeld pCtrlA, pCtrlB - local bA, bB, wx, wy, aA, refA, j - put sBody[the long id of pCtrlA] into bA - put sBody[the long id of pCtrlB] into bB - if bA is empty or bB is empty or sWorld is empty then return 0 - put b2BodyX(bB) into wx - put b2BodyY(bB) into wy - put b2kLocalAnchor(bA, wx, wy) into aA - put b2BodyAngle(bB) - b2BodyAngle(bA) into refA - put b2WeldJoint(sWorld, bA, bB, item 1 of aA, item 2 of aA, 0, 0, refA, false) into j - return j -end b2kWeld - --- Distance link. pLengthPx defaults to the current distance between centres. -command b2kRope pCtrlA, pCtrlB, pLengthPx - local bA, bB, tLen, dx, dy, j - put sBody[the long id of pCtrlA] into bA - put sBody[the long id of pCtrlB] into bB - if bA is empty or bB is empty or sWorld is empty then return 0 - if pLengthPx is empty then - put b2BodyX(bB) - b2BodyX(bA) into dx - put b2BodyY(bB) - b2BodyY(bA) into dy - put sqrt(dx * dx + dy * dy) into tLen - else - put pLengthPx / sScale into tLen - end if - put b2DistanceJoint(sWorld, bA, bB, 0, 0, 0, 0, tLen, false) into j - return j -end b2kRope - -command b2kMotor pJoint, pDegPerSec, pMaxTorque - if pJoint is empty or pJoint <= 0 then exit b2kMotor - if pMaxTorque is empty then put 1000 into pMaxTorque - b2RevoluteEnableMotor pJoint, true, pDegPerSec * kPI / 180, pMaxTorque -end b2kMotor - --- Optional revolute (hinge) limits/readout. Angles are in DEGREES. -command b2kHingeLimit pJoint, pLowerDeg, pUpperDeg - if pJoint is empty or pJoint <= 0 then exit b2kHingeLimit - b2RevoluteEnableLimit pJoint, true, pLowerDeg * kPI / 180, pUpperDeg * kPI / 180 -end b2kHingeLimit - -function b2kHingeAngle pJoint - if pJoint is empty or pJoint <= 0 then return 0 - return b2RevoluteAngle(pJoint) * 180 / kPI -end b2kHingeAngle - --- Rope (distance) extras: read length, set a min/max range, make it springy. -function b2kRopeLength pJoint - if pJoint is empty or pJoint <= 0 then return 0 - return b2DistanceLength(pJoint) * sScale -end b2kRopeLength - --- Set a rope (distance) joint's exact rest length, in pixels. -command b2kRopeSetLength pJoint, pLengthPx - if pJoint is empty or pJoint <= 0 then exit b2kRopeSetLength - b2DistanceSetLength pJoint, pLengthPx / sScale -end b2kRopeSetLength - -command b2kRopeRange pJoint, pMinPx, pMaxPx - if pJoint is empty or pJoint <= 0 then exit b2kRopeRange - b2DistanceSetLengthRange pJoint, pMinPx / sScale, pMaxPx / sScale -end b2kRopeRange - -command b2kSpring pJoint, pHertz, pDamping - if pJoint is empty or pJoint <= 0 then exit b2kSpring - if pDamping is empty then put 0.5 into pDamping - b2DistanceEnableSpring pJoint, true, pHertz, pDamping -end b2kSpring - --- Make a weld springy (0 hertz = rigid again). -command b2kWeldSpring pJoint, pHertz, pDamping - if pJoint is empty or pJoint <= 0 then exit b2kWeldSpring - if pDamping is empty then put 0.5 into pDamping - b2WeldSetStiffness pJoint, pHertz, pDamping, pHertz, pDamping -end b2kWeldSpring - --- Slider (prismatic): constrain pCtrlA to slide along an axis relative to --- pCtrlB (or the world if empty). pAxisDeg is the on-screen slide direction --- (0 = horizontal, 90 = vertical; default vertical). Drive it with b2kSliderMotor. -command b2kSlider pCtrlA, pCtrlB, pAxisDeg - local bMover, bFixed, wax, way, ca, sa, lax, lay, anchX, anchY, aFix, aMov, refA, fixAng, j - put sBody[the long id of pCtrlA] into bMover - if pCtrlB is empty then put sDragAnchor into bFixed - else put sBody[the long id of pCtrlB] into bFixed - if bMover is empty or bFixed is empty or sWorld is empty then return 0 - if pAxisDeg is empty then put 90 into pAxisDeg - put cos(pAxisDeg * kPI / 180) into wax - put - sin(pAxisDeg * kPI / 180) into way - put b2BodyAngle(bFixed) into fixAng - put cos(- fixAng) into ca - put sin(- fixAng) into sa - put wax * ca - way * sa into lax - put wax * sa + way * ca into lay - put b2BodyX(bMover) into anchX - put b2BodyY(bMover) into anchY - put b2kLocalAnchor(bFixed, anchX, anchY) into aFix - put b2kLocalAnchor(bMover, anchX, anchY) into aMov - put b2BodyAngle(bMover) - fixAng into refA - put b2PrismaticJoint(sWorld, bFixed, bMover, item 1 of aFix, item 2 of aFix, \ - item 1 of aMov, item 2 of aMov, lax, lay, refA, false) into j - return j -end b2kSlider - -command b2kSliderMotor pJoint, pPxPerSec, pMaxForce - if pJoint is empty or pJoint <= 0 then exit b2kSliderMotor - if pMaxForce is empty then put 1000 into pMaxForce - b2PrismaticEnableMotor pJoint, true, pPxPerSec / sScale, pMaxForce -end b2kSliderMotor - -command b2kSliderLimit pJoint, pLowerPx, pUpperPx - if pJoint is empty or pJoint <= 0 then exit b2kSliderLimit - b2PrismaticEnableLimit pJoint, true, pLowerPx / sScale, pUpperPx / sScale -end b2kSliderLimit - -function b2kSliderPos pJoint - if pJoint is empty or pJoint <= 0 then return 0 - return b2PrismaticTranslation(pJoint) * sScale -end b2kSliderPos - --- Wheel: a sprung sliding axis + free spin (vehicle wheels). pChassis carries --- pWheel; anchor defaults to the wheel's centre, suspension axis to world "up". --- Drive with b2kWheelMotor, tune ride with b2kWheelSpring. -command b2kWheel pChassis, pWheel, pScreenX, pScreenY, pAxisDeg - local bA, bB, anchX, anchY, aA, aB, wax, way, ca, sa, lax, lay, angA, j - put sBody[the long id of pChassis] into bA - put sBody[the long id of pWheel] into bB - if bA is empty or bB is empty or sWorld is empty then return 0 - if pScreenX is empty then - put b2BodyX(bB) into anchX - put b2BodyY(bB) into anchY - else - put b2kToWorldX(pScreenX) into anchX - put b2kToWorldY(pScreenY) into anchY - end if - if pAxisDeg is empty then - put 0 into wax - put 1 into way - else - put cos(pAxisDeg * kPI / 180) into wax - put - sin(pAxisDeg * kPI / 180) into way - end if - put b2BodyAngle(bA) into angA - put cos(- angA) into ca - put sin(- angA) into sa - put wax * ca - way * sa into lax - put wax * sa + way * ca into lay - put b2kLocalAnchor(bA, anchX, anchY) into aA - put b2kLocalAnchor(bB, anchX, anchY) into aB - put b2WheelJoint(sWorld, bA, bB, item 1 of aA, item 2 of aA, \ - item 1 of aB, item 2 of aB, lax, lay, false) into j - return j -end b2kWheel - -command b2kWheelMotor pJoint, pDegPerSec, pMaxTorque - if pJoint is empty or pJoint <= 0 then exit b2kWheelMotor - if pMaxTorque is empty then put 1000 into pMaxTorque - b2WheelEnableMotor pJoint, true, pDegPerSec * kPI / 180, pMaxTorque -end b2kWheelMotor - -command b2kWheelSpring pJoint, pHertz, pDamping - if pJoint is empty or pJoint <= 0 then exit b2kWheelSpring - if pHertz is empty then put 4 into pHertz - if pDamping is empty then put 0.7 into pDamping - b2WheelEnableSpring pJoint, true, pHertz, pDamping -end b2kWheelSpring - --- OFF switches: the b2k… joint wrappers above only ever turn a feature ON, so --- these let callers disable a motor or limit again (free swing / unbounded slide). -command b2kMotorOff pJoint - if pJoint is not empty and pJoint > 0 then b2RevoluteEnableMotor pJoint, false, 0, 0 -end b2kMotorOff - -command b2kHingeLimitOff pJoint - if pJoint is not empty and pJoint > 0 then b2RevoluteEnableLimit pJoint, false, 0, 0 -end b2kHingeLimitOff - -command b2kSliderMotorOff pJoint - if pJoint is not empty and pJoint > 0 then b2PrismaticEnableMotor pJoint, false, 0, 0 -end b2kSliderMotorOff - -command b2kSliderLimitOff pJoint - if pJoint is not empty and pJoint > 0 then b2PrismaticEnableLimit pJoint, false, 0, 0 -end b2kSliderLimitOff - -command b2kWheelMotorOff pJoint - if pJoint is not empty and pJoint > 0 then b2WheelEnableMotor pJoint, false, 0, 0 -end b2kWheelMotorOff - -command b2kRemoveJoint pJoint - if pJoint is not empty and pJoint > 0 then b2DestroyJoint pJoint -end b2kRemoveJoint - -function b2kLocalAnchor pBody, pWx, pWy - local bcx, bcy, ba, dx, dy, c, s - put b2BodyX(pBody) into bcx - put b2BodyY(pBody) into bcy - put b2BodyAngle(pBody) into ba - put pWx - bcx into dx - put pWy - bcy into dy - put cos(- ba) into c - put sin(- ba) into s - return (dx * c - dy * s) & "," & (dx * s + dy * c) -end b2kLocalAnchor - --- ===================================================================== --- Sensors (non-solid trigger zones) --- ===================================================================== --- Attach a SENSOR fixture to a control: it reports overlaps but never blocks. --- Static by default (a fixed trigger zone); pShape is "box" (default), "ball" or --- "capsule". While the loop runs, overlaps are delivered to your b2kContactTarget --- as `on b2kSensorEnter pSensorCtrl, pVisitorCtrl` / `on b2kSensorExit ...`. The --- kit enables sensor events on every body it creates, so sensors detect them. -command b2kAddSensor pControl, pShape - local tRef - if sWorld is empty then return 0 - put the long id of pControl into tRef - b2ShapeDefSensor true - b2ShapeDefEnableSensorEvents true - switch pShape - case "ball" - b2kAddBall pControl, false - break - case "capsule" - b2kAddCapsule pControl, false - break - default - b2kAddBox pControl, false - end switch - if sBody[tRef] is empty then - -- the attach bailed (zero-sized control): clear the pending one-shot - -- sensor flags so they cannot leak onto the next shape someone creates - b2ShapeDefReset - return 0 - end if - put true into sSensor[tRef] - return sBody[tRef] -end b2kAddSensor - --- Flip a control's collision between solid and SENSOR (a non-solid trigger that --- reports overlaps but never blocks). The flag lives on the shape, so rebuild the --- shape afterwards with b2kReshape — it re-applies the sensor state from here. -command b2kSetSensor pControl, pFlag - put (pFlag is true) into sSensor[the long id of pControl] -end b2kSetSensor - --- Polling alternative to the on b2kSensorEnter/Exit messages (read in on b2kFrame). --- Counts/indices are 1-based; each returns a control (empty for untracked bodies). -function b2kSensorCount - if sEvtSN is empty then return 0 - return sEvtSN -end b2kSensorCount -function b2kSensorEnterSensor pIndex - return sCtrl[sEvtSS[pIndex]] -end b2kSensorEnterSensor -function b2kSensorEnterVisitor pIndex - return sCtrl[sEvtSV[pIndex]] -end b2kSensorEnterVisitor -function b2kSensorExitCount - if sEvtXN is empty then return 0 - return sEvtXN -end b2kSensorExitCount -function b2kSensorExitSensor pIndex - return sCtrl[sEvtXS[pIndex]] -end b2kSensorExitSensor -function b2kSensorExitVisitor pIndex - return sCtrl[sEvtXV[pIndex]] -end b2kSensorExitVisitor - --- ===================================================================== --- Collision filtering (named layers; up to 32) --- ===================================================================== --- Define (or fetch) a named collision layer, returning its bit value. Then --- b2kSetCategory / b2kSetMask take a comma- or space-separated list of layer --- names (or raw numbers). Two shapes collide only if EACH one's category appears --- in the other's mask (and no shared negative group forbids it). -command b2kDefineLayer pName - if sLayers[pName] is not empty then return sLayers[pName] - if sNextBit is empty or sNextBit < 1 then put 1 into sNextBit - -- xTalk's bitOr is 32-bit, so a wrapped layer would get a category of 0 - -- (= collides with nothing). Refuse instead of corrupting filters. The - -- TOP bit (2^31) is the Kit's reserved "oneway" chain layer, so user - -- layers stop at 2^30: 31 nameable layers. - if sNextBit > 1073741824 then return 0 - put sNextBit into sLayers[pName] - put sNextBit * 2 into sNextBit - return sLayers[pName] -end b2kDefineLayer - -function b2kLayerBits pLayerList - local tBits, tL - put 0 into tBits - replace comma with space in pLayerList - repeat for each word tL in pLayerList - if tL is empty then next repeat - if tL is "oneway" then - -- the reserved chain layer (bit 2^31): nameable in masks so a - -- body can opt in/out of one-way terrain explicitly - put tBits bitOr 2147483648 into tBits - next repeat - end if - if tL is a number then - put tBits bitOr (tL) into tBits - else - -- b2kDefineLayer is a COMMAND: OXT cannot call commands with - -- function syntax, so invoke it as a statement and read the result - b2kDefineLayer tL - put tBits bitOr (the result) into tBits - end if - end repeat - return tBits -end b2kLayerBits - -command b2kSetCategory pControl, pLayerList - local s - put sShapeH[the long id of pControl] into s - if s is empty then exit b2kSetCategory - b2SetShapeFilter s, b2kLayerBits(pLayerList), b2ShapeFilterMask(s), b2ShapeFilterGroup(s) -end b2kSetCategory - -command b2kSetMask pControl, pLayerList - local s - put sShapeH[the long id of pControl] into s - if s is empty then exit b2kSetMask - -- a named-layer mask means OBJECT rules, not "fall through the - -- terrain": the reserved chain bit rides along automatically, so - -- custom-masked bodies still stand on b2kChain ground. To really - -- pass through chains use raw b2SetShapeFilter (the player's - -- drop-through window does exactly that, internally). - b2SetShapeFilter s, b2ShapeFilterCategory(s), b2kLayerBits(pLayerList) bitOr 2147483648, b2ShapeFilterGroup(s) -end b2kSetMask - --- Group index: bodies sharing a NEGATIVE group never collide; a POSITIVE group --- always collide. Overrides category/mask. 0 (default) = use category/mask. -command b2kSetCollisionGroup pControl, pGroup - local s - put sShapeH[the long id of pControl] into s - if s is empty then exit b2kSetCollisionGroup - b2SetShapeFilter s, b2ShapeFilterCategory(s), b2ShapeFilterMask(s), pGroup -end b2kSetCollisionGroup - --- ===================================================================== --- Chains (smooth terrain) and world queries --- ===================================================================== --- A smooth static chain (ground/terrain) from a list of SCREEN points ("x,y" per --- line). Unlike joined box/segment edges it has no inner corners for fast bodies --- to catch on. Needs >= 4 points; pLoop true closes it. Invisible (draw a matching --- graphic). Returns a chain handle (b2DestroyChain to remove). --- THE GHOST RULE (open chains): Box2D uses the FIRST and LAST segments as --- ghost anchors only -- N points collide as N-3 segments. Run the chain one --- segment PAST the surface you need on each side (over solid ground the --- ghost tails can simply continue flat). A chain whose endpoints sit AT the --- platform edges has non-solid ends -- bodies fall straight through them. -command b2kChain pPoints, pLoop - local tBody, p, tChain - if sWorld is empty then return 0 - set the itemDelimiter to comma -- points are "x,y" (gotcha 5) - put b2NewStaticBody(sWorld, 0, 0) into tBody - b2ChainBegin - repeat for each line p in pPoints - if p is empty then next repeat - b2ChainAddPoint b2kToWorldX(item 1 of p), b2kToWorldY(item 2 of p) - end repeat - -- every Kit chain carries the reserved ONE-WAY category (bit 2^31, - -- the "oneway" layer) with an all-bits mask: nothing collides any - -- differently, but the player's drop-through window can now stop - -- colliding with chains ALONE by masking out that single bit. - b2ShapeDefFilter 2147483648, 4294967295, 0 - put b2CreateChain(tBody, (pLoop is true), 0.6, 0.1) into tChain - if tChain > 0 then put true into sOneWayBody[tBody] - return tChain -end b2kChain - -command b2kSmoothGround pPoints - -- statement call + the result: OXT cannot call commands function-style. - -- Mind b2kChain's GHOST RULE: the outer two points anchor, not collide. - b2kChain pPoints, false - return the result -end b2kSmoothGround - --- Attach a smooth static chain to a CONTROL, from a list of SCREEN points --- ("x,y" per line) — the control's own outline. Unlike b2kChain (which makes an --- untracked world body), this registers the body against the control like --- b2kAddBox/Ball/Polygon, so it selects (b2kControlAt), drags (b2kMoveTo) and --- removes (b2kRemove) as a normal static part. The points are taken relative to --- the control's centre, so moving the control moves the terrain with it. pLoop --- true closes the outline into a solid blob; false leaves a one-sided ground --- line. Great for smooth, rolling, concave terrain a fast body won't catch on. -command b2kAddChain pControl, pPoints, pLoop - local tRef, tCx, tCy, tBody, p, lx, ly - if sWorld is empty then return 0 - set the itemDelimiter to comma -- locs and points are "x,y" (gotcha 5) - put the long id of pControl into tRef - put item 1 of the loc of pControl into tCx - put item 2 of the loc of pControl into tCy - put b2NewStaticBody(sWorld, b2kToWorldX(tCx), b2kToWorldY(tCy)) into tBody - b2ChainBegin - repeat for each line p in pPoints - if p is empty then next repeat - put (item 1 of p - tCx) / sScale into lx -- local to the control's centre - put - ((item 2 of p - tCy) / sScale) into ly - b2ChainAddPoint lx, ly - end repeat - get b2CreateChain(tBody, (pLoop is true), 0.6, 0.1) - b2kRegister tRef, tBody, true -- static, kit-tracked - put "loc" into sRender[tRef] -- static: registered but never synced - return tBody -end b2kAddChain - --- Controls whose body overlaps a screen RECT (newline-separated long ids). --- NOTE: this asks the BROADPHASE, whose boxes are fattened (~0.1m = a few --- px) -- a region touching the floor reports the floor. For presence --- checks (plates, buttons) use b2kOverlapMoving. -function b2kOverlap pX1, pY1, pX2, pY2 - if sWorld is empty then return empty - return b2kQueryToControls(b2OverlapAABB(sWorld, b2kToWorldX(pX1), b2kToWorldY(pY1), b2kToWorldX(pX2), b2kToWorldY(pY2))) -end b2kOverlap - --- Controls of MOVING (non-static) bodies overlapping a screen RECT -- --- THE presence poll for pressure plates and buttons. A pad region must --- sit on its floor, and the broadphase's fattened boxes make the floor --- itself overlap it forever; filtering statics asks what a plate means: --- "is some THING on me?" (Dynamic and kinematic bodies count; sleeping --- ones still register.) -function b2kOverlapMoving pX1, pY1, pX2, pY2 - local tLine, tOut - put empty into tOut - repeat for each line tLine in b2kOverlap(pX1, pY1, pX2, pY2) - if tLine is empty then next repeat - if sStatic[tLine] is true then next repeat - put tLine & cr after tOut - end repeat - return tOut -end b2kOverlapMoving - --- Controls whose body overlaps a screen circle. -function b2kOverlapCircle pX, pY, pRadiusPx - if sWorld is empty then return empty - return b2kQueryToControls(b2OverlapCircle(sWorld, b2kToWorldX(pX), b2kToWorldY(pY), pRadiusPx / sScale)) -end b2kOverlapCircle - --- Every control a ray crosses, nearest-first (vs b2kRayHit's single closest). -function b2kRayHitAll pX1, pY1, pX2, pY2 - if sWorld is empty then return empty - return b2kQueryToControls(b2RayCastAll(sWorld, b2kToWorldX(pX1), b2kToWorldY(pY1), b2kToWorldX(pX2), b2kToWorldY(pY2))) -end b2kRayHitAll - --- Shared: turn the current query result (count) into a newline list of controls. -function b2kQueryToControls pCount - local i, c, tList - put empty into tList - repeat with i = 1 to pCount - put sCtrl[b2QueryBody(i)] into c - if c is not empty then put c & cr after tList - end repeat - return tList -end b2kQueryToControls - --- ===================================================================== --- Motor & filter joints --- ===================================================================== --- Drive pMover toward a target pose relative to pRef (a moving anchor; empty = --- the world). Offsets are in screen pixels / degrees from pRef's centre. Great for --- followers, lifts and character motion. Returns a joint handle (b2kRemoveJoint). -command b2kMotorTo pMover, pRef, pOffsetXPx, pOffsetYPx, pAngleDeg, pMaxForce, pMaxTorque - local bMover, bRef - put sBody[the long id of pMover] into bMover - if pRef is empty then put sDragAnchor into bRef - else put sBody[the long id of pRef] into bRef - if bMover is empty or bRef is empty or sWorld is empty then return 0 - if pAngleDeg is empty then put 0 into pAngleDeg - if pMaxForce is empty then put 1000 into pMaxForce - if pMaxTorque is empty then put 1000 into pMaxTorque - return b2MotorJoint(sWorld, bRef, bMover, pOffsetXPx / sScale, - pOffsetYPx / sScale, \ - pAngleDeg * kPI / 180, pMaxForce, pMaxTorque, 0.3, false) -end b2kMotorTo - --- Stop two specific controls from colliding with each other (without changing how --- either collides with anything else). Returns a joint handle (b2kRemoveJoint). -command b2kNoCollide pCtrlA, pCtrlB - local bA, bB - put sBody[the long id of pCtrlA] into bA - put sBody[the long id of pCtrlB] into bB - if bA is empty or bB is empty or sWorld is empty then return 0 - return b2FilterJoint(sWorld, bA, bB) -end b2kNoCollide - --- ===================================================================== --- World tuning (advanced) + perf readout --- ===================================================================== --- Below this approach speed (px/sec) collisions stop bouncing, even if bouncy. -command b2kSetRestitutionThreshold pPxPerSec - if sWorld is not empty then b2SetRestitutionThreshold sWorld, pPxPerSec / sScale -end b2kSetRestitutionThreshold - --- Contact softness: stiffness (hertz), damping ratio, max separation-fix speed (px/sec). -command b2kSetContactTuning pHertz, pDamping, pPushPx - if sWorld is not empty then b2SetContactTuning sWorld, pHertz, pDamping, pPushPx / sScale -end b2kSetContactTuning - -command b2kSetJointTuning pHertz, pDamping - if sWorld is not empty then b2SetJointTuning sWorld, pHertz, pDamping -end b2kSetJointTuning - --- Clamp how fast any body can move (px/sec) — stops runaway speeds. -command b2kSetMaxSpeed pPxPerSec - if sWorld is not empty then b2SetMaximumLinearSpeed sWorld, pPxPerSec / sScale -end b2kSetMaxSpeed - -command b2kEnableWarmStarting pFlag - if sWorld is not empty then b2EnableWarmStarting sWorld, (pFlag is true) -end b2kEnableWarmStarting - --- A quick perf readout in milliseconds: "totalStep,collide,solve" for the last step. -function b2kProfile - if sWorld is empty then return empty - b2WorldProfileUpdate sWorld - return b2WorldProfileStep() & "," & b2WorldProfileCollide() & "," & b2WorldProfileSolve() -end b2kProfile - --- How many dynamic bodies are awake right now (native count, vs b2kAwakeCount). -function b2kAwakeBodyCount - if sWorld is empty then return 0 - return b2AwakeBodyCount(sWorld) -end b2kAwakeBodyCount - --- ===================================================================== --- Dragging: call b2kGrab from mouseDown and b2kRelease from mouseUp. --- ===================================================================== -function b2kGrab pScreenX, pScreenY - local tWx, tWy, tBody - if sWorld is empty then return empty -- grab before setup: nothing to hit - put b2kToWorldX(pScreenX) into tWx - put b2kToWorldY(pScreenY) into tWy - put b2BodyAtPoint(sWorld, tWx, tWy) into tBody - if tBody > 0 and b2BodyType(tBody) is 2 then - b2SetAwake tBody, true - put b2MouseJoint(sWorld, sDragAnchor, tBody, tWx, tWy, 6, 0.7, 4000) into sDragJoint - put true into sDragging - return sCtrl[tBody] - end if - return empty -end b2kGrab - -command b2kRelease - if sDragJoint is not empty and sDragJoint > 0 then b2DestroyJoint sDragJoint - put 0 into sDragJoint - put false into sDragging -end b2kRelease - --- ===================================================================== --- Input (keyboard): poll-and-diff held keys, once per frame --- ===================================================================== --- Model: one `the keysDown` sample per frame (taken inside the loop, just --- before b2kFrame dispatches), diffed against the previous frame's sample. --- Held = in the current set; pressed/released = the diff. This sidesteps --- every event-path problem at once: OS auto-repeat (Win32 re-sends --- rawKeyDown with no paired ups), focus stealing by fields/buttons, and --- frontscript plumbing. Letter keys bind BOTH keysym cases ("w" = 119 and --- 87) because the reported code shifts with the Shift key. -command b2kInputOn - put true into sInputOn - put comma into sKeysNow - put comma into sKeysPrev - -- starter bindings; rebind freely (these only fill empty slots) - if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" - if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" - if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" -end b2kInputOn - -command b2kInputOff - put false into sInputOn - put comma into sKeysNow - put comma into sKeysPrev -end b2kInputOff - -function b2kInputIsOn - return (sInputOn is true) -end b2kInputIsOn - --- Internal, called by the loop each frame while armed. The sets are stored --- comma-wrapped (",65361,32,") so membership is one `contains` test. -command b2kInputTick - if sInputOn is not true then exit b2kInputTick - put sKeysNow into sKeysPrev - if sInjectOn is true then - put comma & sInjectKeys & comma into sKeysNow - else - put comma & the keysDown & comma into sKeysNow - end if -end b2kInputTick - --- Replace the keyboard with a SCRIPTED key set (friendly names or codes; --- empty = nothing held) until b2kInputInjectOff. Deterministic input for --- the self-test harness, input replays, and cutscene "ghost" players -- --- edges fall out of the normal frame diff exactly as with real keys. -command b2kInputInject pKeys - put true into sInjectOn - put b2kKeyListCodes(pKeys) into sInjectKeys -end b2kInputInject - -command b2kInputInjectOff - put false into sInjectOn - put empty into sInjectKeys -end b2kInputInjectOff - --- A friendly key name (or raw keycode) -> the keycode candidates it matches, --- comma-separated. Numbers >= 32 pass through as raw keycodes; single --- characters resolve to their code (letters to both cases, per the --- Shift-keysym behaviour the Phase 0 spike confirmed). -function b2kKeyCodes pKey - local tLower, tUpper - if pKey is a number and pKey >= 32 then return pKey - switch pKey - case "left" - return 65361 - case "up" - return 65362 - case "right" - return 65363 - case "down" - return 65364 - case "space" - return 32 - case "return" - case "enter" - return "65293,65421" - case "escape" - case "esc" - return 65307 - case "tab" - return 65289 - case "shift" - return "65505,65506" - case "control" - case "ctrl" - return "65507,65508" - case "alt" - case "option" - return "65513,65514" - case "backspace" - return 65288 - case "delete" - return 65535 - end switch - if the number of chars of pKey is 1 then - put charToNum(toLower(pKey)) into tLower - put charToNum(toUpper(pKey)) into tUpper - if tLower >= 97 and tLower <= 122 then return tLower & comma & tUpper - return charToNum(pKey) - end if - return 0 -end b2kKeyCodes - --- The reverse map, for readouts: a keycode -> its friendly name (or itself). -function b2kKeyName pCode - switch pCode - case 65361 - return "left" - case 65362 - return "up" - case 65363 - return "right" - case 65364 - return "down" - case 32 - return "space" - case 65293 - case 65421 - return "return" - case 65307 - return "escape" - case 65289 - return "tab" - case 65505 - case 65506 - return "shift" - case 65507 - case 65508 - return "control" - case 65513 - case 65514 - return "alt" - case 65288 - return "backspace" - case 65535 - return "delete" - end switch - if pCode >= 97 and pCode <= 122 then return numToChar(pCode) - if pCode >= 65 and pCode <= 90 then return numToChar(pCode + 32) - if pCode >= 48 and pCode <= 57 then return numToChar(pCode) - return pCode -end b2kKeyName - --- True when any of pKey's candidate codes is in the given set. -function b2kKeyInSet pSet, pKey - local tC - if pSet is empty or pSet is comma then return false - repeat for each item tC in b2kKeyCodes(pKey) - if pSet contains (comma & tC & comma) then return true - end repeat - return false -end b2kKeyInSet - -function b2kKeyIsDown pKey - return b2kKeyInSet(sKeysNow, pKey) -end b2kKeyIsDown - --- Edge queries: true only on the single frame the state changed. -function b2kKeyPressed pKey - return b2kKeyInSet(sKeysNow, pKey) and not b2kKeyInSet(sKeysPrev, pKey) -end b2kKeyPressed - -function b2kKeyReleased pKey - return b2kKeyInSet(sKeysPrev, pKey) and not b2kKeyInSet(sKeysNow, pKey) -end b2kKeyReleased - --- Everything held right now, as friendly names (debug HUDs). -function b2kKeysHeld - local tOut, tC - put empty into tOut - repeat for each item tC in char 2 to -2 of sKeysNow - put b2kKeyName(tC) & space after tOut - end repeat - return word 1 to -1 of tOut -end b2kKeysHeld - --- Actions: a named set of keys ("jump" = "space,up,w"). Any bound key --- counts; the action's edges treat the whole set as one logical key. --- The name->keycode resolution happens ONCE, at bind time (the cached --- code list is what the per-frame queries scan) -- these run inside the --- player tick and game code every frame, so they must stay cheap. -command b2kBindAction pName, pKeyList - put pKeyList into sKeyActions[pName] - put b2kKeyListCodes(pKeyList) into sKeyActionsC[pName] -end b2kBindAction - --- Internal: a friendly key list -> every matching keycode, comma list. -function b2kKeyListCodes pKeyList - local tK, tOut - put empty into tOut - set the itemDelimiter to comma - repeat for each item tK in pKeyList - if tOut is empty then - put b2kKeyCodes(word 1 of tK) into tOut - else - put comma & b2kKeyCodes(word 1 of tK) after tOut - end if - end repeat - return tOut -end b2kKeyListCodes - --- Internal: is any of these (pre-resolved) keycodes in the given set? -function b2kCodesInSet pSet, pCodes - local tC - if pSet is empty or pSet is comma then return false - repeat for each item tC in pCodes - if pSet contains (comma & tC & comma) then return true - end repeat - return false -end b2kCodesInSet - -function b2kActionIsDown pName - return b2kCodesInSet(sKeysNow, sKeyActionsC[pName]) -end b2kActionIsDown - -function b2kActionPressed pName - if not b2kCodesInSet(sKeysNow, sKeyActionsC[pName]) then return false - return not b2kCodesInSet(sKeysPrev, sKeyActionsC[pName]) -end b2kActionPressed - -function b2kActionReleased pName - if not b2kCodesInSet(sKeysPrev, sKeyActionsC[pName]) then return false - return not b2kCodesInSet(sKeysNow, sKeyActionsC[pName]) -end b2kActionReleased - --- Axes: -1 / 0 / +1 from two key sets ("moveX" defaults to left,a / right,d). --- Both directions held = 0, which is what character movement wants. -command b2kBindAxis pName, pNegKeys, pPosKeys - put pNegKeys into sAxisNeg[pName] - put pPosKeys into sAxisPos[pName] - put b2kKeyListCodes(pNegKeys) into sAxisNegC[pName] - put b2kKeyListCodes(pPosKeys) into sAxisPosC[pName] -end b2kBindAxis - -function b2kAxis pName - local tNeg, tPos - put b2kCodesInSet(sKeysNow, sAxisNegC[pName]) into tNeg - put b2kCodesInSet(sKeysNow, sAxisPosC[pName]) into tPos - if tPos and not tNeg then return 1 - if tNeg and not tPos then return -1 - return 0 -end b2kAxis - --- Real elapsed ms folded into the last frame -- drive animations and user --- timers from this, not the step count (a frame may run 0..n fixed steps). -function b2kFrameMS - if sFrameMS is empty then return 0 - return sFrameMS -end b2kFrameMS - --- ===================================================================== --- Sprites: sheets, atlases, named animations (icon-button backend) --- ===================================================================== --- Model (decided by the Phase 0 spike, S12): a sheet registers named or --- numbered frame REGIONS of one hidden source image; each region is sliced --- into its own hidden image LAZILY, on first use. A sprite is a transparent --- BUTTON whose icon is the current frame's image -- a frame switch is one --- property set, and every sprite of a sheet shares the same frame images. --- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. - --- Register an image FILE as a uniform grid of pFW x pFH frames, numbered --- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that --- are not edge-to-edge take pMargin (outer border px) and pSpacing (gap --- between cells px). Pass pFW/pFH 0 (or empty) to register the image with --- NO grid and name regions yourself with b2kSheetAddFrame -- the path for --- packed sheets that have no Kenney-style XML. -command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - put b2kSheetSourceFromFile(pName, pPath) into tRef - if tRef is empty then return 0 - b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - return the number of lines of sSheetKeys[pName] -end b2kSheetLoad - --- Register an image already in the stack (e.g. base64-embedded art) as a --- grid sheet. The image is used in place and never deleted by the Kit. --- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). -command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] - put false into sSheetOwned[pName] - b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - return the number of lines of sSheetKeys[pName] -end b2kSheetFromImage - --- Name one region of a loaded sheet yourself: the no-XML path for packed --- sheets in any layout. Load the source first (b2kSheetLoad/FromImage with --- frame size 0 for no grid), then add each frame by name; regions may be --- any size and position and can also be added on top of a grid or atlas. --- Redefining a name re-bakes its slice on next use. -command b2kSheetAddFrame pSheet, pFrame, pX, pY, pW, pH - local tId - if sSheetSrc[pSheet] is empty then exit b2kSheetAddFrame - if pFrame is empty then exit b2kSheetAddFrame - if pX is not a number or pY is not a number then exit b2kSheetAddFrame - if pW is not a number or pH is not a number then exit b2kSheetAddFrame - if pW < 1 or pH < 1 then exit b2kSheetAddFrame - if sSheetRegion[pSheet][pFrame] is empty then - if sSheetKeys[pSheet] is empty then - put pFrame into sSheetKeys[pSheet] - else - put cr & pFrame after sSheetKeys[pSheet] - end if - else - -- redefined: drop any stale slices so the new region re-bakes lazily - put sSheetIcon[pSheet][pFrame] into tId - if tId is not empty then - try - delete image id tId - catch tErr - end try - end if - delete variable sSheetIcon[pSheet][pFrame] - put sSheetFlip[pSheet][pFrame] into tId - if tId is not empty then - try - delete image id tId - catch tErr - end try - end if - delete variable sSheetFlip[pSheet][pFrame] - end if - put round(pX) & comma & round(pY) & comma & round(pW) & comma & round(pH) into sSheetRegion[pSheet][pFrame] -end b2kSheetAddFrame - --- Register a packed atlas: a PNG plus the TextureAtlas XML that names its --- regions ( -- --- the Kenney pack format, like Spritesheets/ in this repo). Frames are --- addressed BY NAME. pXmlPath defaults to the png path with ".xml". -command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if pXmlPath is empty then - put pPngPath into pXmlPath - set the itemDelimiter to "." - put "xml" into item -1 of pXmlPath - end if - put URL ("file:" & pXmlPath) into tXml - if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml - if tXml is empty then return 0 - put b2kSheetSourceFromFile(pName, pPngPath) into tRef - if tRef is empty then return 0 - set the itemDelimiter to comma - repeat for each line tLine in tXml - if tLine contains " 0 then put pFPS into sSprFPS[tRef] -end b2kSpriteFPS - --- Face left (true) or right (false). The mirrored frame image is created --- the first time it is needed, by flip-cloning the sliced frame. -command b2kSpriteFlipH pCtrl, pFlag - local tRef - put the long id of pCtrl into tRef - if sSprSheet[tRef] is empty then exit b2kSpriteFlipH - put (pFlag is true) into pFlag - if sSprFlip[tRef] is pFlag then exit b2kSpriteFlipH - put pFlag into sSprFlip[tRef] - if sSprFrameKey[tRef] is not empty then b2kSpriteShowFrame tRef, sSprFrameKey[tRef] -end b2kSpriteFlipH - -function b2kSpriteFlipped pCtrl - try - return (sSprFlip[the long id of pCtrl] is true) - catch tErr - return false - end try -end b2kSpriteFlipped - --- When a NON-looping animation finishes, send pMessage to the frame target --- with the sprite and the animation name (attack/death/effect chaining). -command b2kSpriteOnFinish pCtrl, pMessage - put pMessage into sSprMsg[the long id of pCtrl] -end b2kSpriteOnFinish - --- Pin this sprite to another control's position each frame (offset in px) -- --- the standard pattern for "art bigger than the collision shape": give an --- invisible control the body, bind the sprite to it. -command b2kSpriteBind pCtrl, pBodyCtrl, pDX, pDY - local tRef - put the long id of pCtrl into tRef - put the long id of pBodyCtrl into sSprBind[tRef] - put b2kNumberOr(pDX, 0) into sSprBindDX[tRef] - put b2kNumberOr(pDY, 0) into sSprBindDY[tRef] - put true into sSprLiveDirty -- it now needs per-frame service -end b2kSpriteBind - -command b2kSpriteUnbind pCtrl - delete variable sSprBind[the long id of pCtrl] - put true into sSprLiveDirty -end b2kSpriteUnbind - --- Remove a sprite (and its body, if it has one). -command b2kSpriteRemove pCtrl - local tRef - try - put the long id of pCtrl into tRef - catch tErr - put pCtrl into tRef -- stale path (group gone): still clear the registry - end try - if sBody[tRef] is not empty then - try - b2kRemove tRef -- destroys the body and deletes the control - catch tErr - end try - else - try - delete tRef - catch tErr - end try - end if - b2kSpriteForget tRef -end b2kSpriteRemove - --- Internal: drop a sprite's registry entries (the control is already gone). -command b2kSpriteForget pRef - local tLn - delete variable sSprSheet[pRef] - delete variable sSprKind[pRef] - delete variable sSprAnim[pRef] - delete variable sSprStep[pRef] - delete variable sSprNextMS[pRef] - delete variable sSprFPS[pRef] - delete variable sSprFPSOver[pRef] - delete variable sSprFlip[pRef] - delete variable sSprMsg[pRef] - delete variable sSprBind[pRef] - delete variable sSprBindDX[pRef] - delete variable sSprBindDY[pRef] - delete variable sSprFrameKey[pRef] - delete variable sSprIconNow[pRef] - delete variable sSprLastLoc[pRef] - delete variable sInCam[pRef] - set the wholeMatches to true - put lineOffset(pRef, sSprRefs) into tLn - if tLn > 0 then delete line tLn of sSprRefs - put true into sSprLiveDirty -end b2kSpriteForget - --- Internal: remove every live sprite (b2kClear / b2kTeardown path). -command b2kSpritesClear - local tRef, tList - put sSprRefs into tList - repeat for each line tRef in tList - if tRef is not empty then b2kSpriteRemove tRef - end repeat - put empty into sSprRefs - put empty into sSprLive - put false into sSprLiveDirty -end b2kSpritesClear - --- Internal: forget one sheet -- its regions, anims, sliced/mirrored frame --- images, and (if the Kit loaded it) the hidden source image. -command b2kSheetForget pName - local tKey, tId - if sSheetSrc[pName] is empty and sSheetKeys[pName] is empty then exit b2kSheetForget - repeat for each key tKey in sSheetIcon[pName] - put sSheetIcon[pName][tKey] into tId - if tId is not empty then - try - delete image id tId - catch tErr - end try - end if - end repeat - repeat for each key tKey in sSheetFlip[pName] - put sSheetFlip[pName][tKey] into tId - if tId is not empty and tId is not sSheetIcon[pName][tKey] then - try - delete image id tId - catch tErr - end try - end if - end repeat - if sSheetOwned[pName] is not false and sSheetSrc[pName] is not empty then - try - delete sSheetSrc[pName] - catch tErr - end try - end if - delete variable sSheetSrc[pName] - delete variable sSheetOwned[pName] - delete variable sSheetRegion[pName] - delete variable sSheetKeys[pName] - delete variable sSheetIcon[pName] - delete variable sSheetFlip[pName] - delete variable sSheetData[pName] - delete variable sSheetAlpha[pName] - delete variable sSheetScale[pName] - repeat for each key tKey in sAnimList - if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then - delete variable sAnimList[tKey] - delete variable sAnimFPS[tKey] - delete variable sAnimLoop[tKey] - end if - end repeat -end b2kSheetForget - --- Internal: every sheet + every sprite (b2kTeardown path). -command b2kSheetsWipe - local tName - b2kSpritesClear - repeat for each key tName in sSheetSrc - b2kSheetForget tName - end repeat - put empty into sSheetSrc - put empty into sSheetRegion - put empty into sSheetKeys - put empty into sSheetIcon - put empty into sSheetFlip - put empty into sSheetData - put empty into sSheetAlpha - put empty into sSheetScale - put empty into sAnimList - put empty into sAnimFPS - put empty into sAnimLoop - b2kSpriteSweepOrphans -end b2kSheetsWipe - --- Internal: delete Kit-generated sprite/sheet controls left over from a --- PREVIOUS session. Script-locals reset when a stack reopens (or a new --- script is pasted in), but the controls persist -- registry cleanup can't --- see them, so a reopened stack would show ghost sprites frozen on their --- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans - local tAgain, i, tName, tHit - put true into tAgain - repeat while tAgain - put false into tAgain - repeat with i = 1 to the number of controls of this card - put the short name of control i of this card into tName - if char 1 to 7 of tName is "b2kcam_" then - -- a dead session's viewport: free the children, then the shell - try - ungroup control i of this card - catch tErr - delete control i of this card - end try - put true into tAgain - exit repeat - end if - put false into tHit - if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit - if tHit then - delete control i of this card - put true into tAgain - exit repeat - end if - end repeat - end repeat -end b2kSpriteSweepOrphans - --- ---- sprite internals ------------------------------------------------ - --- Internal: load an image file into a hidden, Kit-owned source image. --- The bytes are loaded as CONTENT (binfile -> set the text), not as a --- filename reference: referenced images size and load lazily for hidden --- controls, which broke the slicer's stride; content images adopt their --- pixel size immediately -- the exact path b2kSheetFromImage users (and --- the platformer's embedded placeholder) already exercise successfully. --- lockLoc only AFTER the content, so the control has auto-sized first. -function b2kSheetSourceFromFile pName, pPath - local tImg, tData - b2kSheetForget pName - if there is no file pPath then return empty - put URL ("binfile:" & pPath) into tData - if tData is empty then return empty - put "b2ksheet_" & pName into tImg - if there is an image tImg then delete image tImg - create image tImg - set the visible of it to false - try - set the text of image tImg to tData - catch tErr - delete image tImg - return empty - end try - if the width of image tImg < 2 then - delete image tImg - return empty - end if - set the lockLoc of image tImg to true - put the long id of image tImg into sSheetSrc[pName] - put true into sSheetOwned[pName] - return sSheetSrc[pName] -end b2kSheetSourceFromFile - --- Internal: register grid regions over the current source image. Margin = --- outer border (left AND top, assumed symmetric), spacing = gap between --- cells; both default 0 (edge-to-edge, the common case). A frame size of --- 0/empty registers NO grid -- the caller names regions with --- b2kSheetAddFrame instead. -command b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - local tCols, tRows, tMax, i, tC, tR - if sSheetSrc[pName] is empty then exit b2kSheetGridRegions - if b2kNumberOr(pFW, 0) < 1 or b2kNumberOr(pFH, 0) < 1 then exit b2kSheetGridRegions - put b2kClamp(pFW, 1, 4096) into pFW - put b2kClamp(pFH, 1, 4096) into pFH - put round(b2kClamp(pMargin, 0, 2048)) into pMargin - put round(b2kClamp(pSpacing, 0, 2048)) into pSpacing - put ((the width of sSheetSrc[pName]) - 2 * pMargin + pSpacing) div (pFW + pSpacing) into tCols - put ((the height of sSheetSrc[pName]) - 2 * pMargin + pSpacing) div (pFH + pSpacing) into tRows - if tCols < 1 or tRows < 1 then exit b2kSheetGridRegions - put tCols * tRows into tMax - if pCount is a number and pCount > 0 and pCount < tMax then put pCount into tMax - repeat with i = 1 to tMax - put (i - 1) mod tCols into tC - put (i - 1) div tCols into tR - put (pMargin + tC * (pFW + pSpacing)) & comma & (pMargin + tR * (pFH + pSpacing)) & comma & pFW & comma & pFH into sSheetRegion[pName][i] - put i & cr after sSheetKeys[pName] - end repeat - if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] -end b2kSheetGridRegions - --- Internal: one attribute value out of an XML tag line (attr="value"). -function b2kXmlAttr pLine, pAttr - local tFrom, tLen - put offset(pAttr & "=" & quote, pLine) into tFrom - if tFrom is 0 then return empty - add the number of chars of pAttr + 2 to tFrom - put offset(quote, pLine, tFrom - 1) into tLen - if tLen is 0 then return empty - return char tFrom to tFrom + tLen - 2 of pLine -end b2kXmlAttr - --- Internal: make sure a frame's sliced image exists (lazy, cached). The --- source pixels are fetched once per sheet and kept until teardown. -command b2kSheetEnsureIcon pSheet, pKey - local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 - if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon - put sSheetRegion[pSheet][pKey] into tRegion - if tRegion is empty then exit b2kSheetEnsureIcon - if sSheetData[pSheet] is empty then - put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] - put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] - end if - set the itemDelimiter to comma - put item 1 of tRegion into tX - put item 2 of tRegion into tY - put item 3 of tRegion into tW - put item 4 of tRegion into tH - put the width of sSheetSrc[pSheet] into tSW - -- refuse to slice from inconsistent pixels: a stride mismatch must yield - -- an empty (invisible) frame, never garbage on screen - if the number of bytes in sSheetData[pSheet] is not tSW * (the height of sSheetSrc[pSheet]) * 4 then exit b2kSheetEnsureIcon - if tX + tW > tSW then exit b2kSheetEnsureIcon - put empty into tFD - put empty into tFA - repeat with y = 0 to tH - 1 - put (tY + y) * tSW + tX into tRowPx - put byte (tRowPx * 4 + 1) to (tRowPx * 4 + tW * 4) of sSheetData[pSheet] after tFD - put byte (tRowPx + 1) to (tRowPx + tW) of sSheetAlpha[pSheet] after tFA - end repeat - if the number of bytes in tFD is not tW * tH * 4 then exit b2kSheetEnsureIcon - if the number of bytes in tFA is not tW * tH then - put empty into tFA -- no usable alpha: ship the frame fully opaque - end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName - create image tName - set the visible of it to false - set the lockLoc of it to true - set the width of it to tW - set the height of it to tH - set the imageData of image tName to tFD - if tFA is not empty then set the alphaData of image tName to tFA - -- sheet scale: resize the control (the engine resamples the render), - -- then bake that render back in as the frame's real pixels - put b2kNumberOr(sSheetScale[pSheet], 1) into tScale - if tScale is not 1 then - put max(1, round(tW * tScale)) into tW2 - put max(1, round(tH * tScale)) into tH2 - set the width of image tName to tW2 - set the height of image tName to tH2 - put the imageData of image tName into tFD - put the alphaData of image tName into tFA - set the imageData of image tName to tFD - if tFA is not empty then set the alphaData of image tName to tFA - end if - put the id of image tName into sSheetIcon[pSheet][pKey] -end b2kSheetEnsureIcon - --- Internal: the mirrored copy of a frame (falls back to the unmirrored --- frame if this engine's flip throws, so facing left never errors). -command b2kSheetEnsureFlip pSheet, pKey - local tName - if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - b2kSheetEnsureIcon pSheet, pKey - if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip - try - clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName - set the name of it to tName - set the visible of it to false - flip image tName horizontal - put the id of image tName into sSheetFlip[pSheet][pKey] - catch tErr - put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] - end try -end b2kSheetEnsureFlip - --- Internal: point a sprite's icon at a frame, honouring its facing. Skips --- the property set when the icon is already right (the sDrawKey idea). -command b2kSpriteShowFrame pRef, pKey - local tSheet, tIcon - put sSprSheet[pRef] into tSheet - if tSheet is empty then exit b2kSpriteShowFrame - if sSprFlip[pRef] is true then - b2kSheetEnsureFlip tSheet, pKey - put sSheetFlip[tSheet][pKey] into tIcon - else - b2kSheetEnsureIcon tSheet, pKey - put sSheetIcon[tSheet][pKey] into tIcon - end if - if tIcon is empty then exit b2kSpriteShowFrame - put pKey into sSprFrameKey[pRef] - if sSprIconNow[pRef] is tIcon then exit b2kSpriteShowFrame - set the icon of pRef to tIcon - put tIcon into sSprIconNow[pRef] -end b2kSpriteShowFrame - --- Internal: per-frame service -- bound sprites follow their body's control, --- playing animations advance on wall-clock time. Vanished controls are --- forgotten, like the body sync does. -command b2kSpritesTick - local tNow, tRef, tDead, tSheet, tAnim, tAKey, tList, tStep, tN, tLoc, tBX, tBY - if sSprRefs is empty then exit b2kSpritesTick - -- the tick walks only the LIVE subset (bound and/or playing); the - -- list rebuilds lazily when membership changed. Tile-heavy scenes - -- are ~90% inert sprites, and per-frame cost must not scale with - -- them (a platformer level: ~100 tiles skipped, ~25 live serviced). - if sSprLiveDirty is true then - put empty into sSprLive - repeat for each line tRef in sSprRefs - if tRef is empty then next repeat - if sSprBind[tRef] is not empty or sSprAnim[tRef] is not empty then - put tRef & cr after sSprLive - end if - end repeat - put false into sSprLiveDirty - end if - if sSprLive is empty then exit b2kSpritesTick - put the milliseconds into tNow - put empty into tDead - set the itemDelimiter to comma - repeat for each line tRef in sSprLive - if tRef is empty then next repeat - -- still-inert guard: membership changed mid-frame retires next tick - if sSprBind[tRef] is empty and sSprAnim[tRef] is empty then next repeat - try - if sSprBind[tRef] is not empty then - put the loc of sSprBind[tRef] into tLoc - put (item 1 of tLoc) + b2kCamShiftX(sSprBind[tRef]) + sSprBindDX[tRef] into tBX - put (item 2 of tLoc) + b2kCamShiftY(sSprBind[tRef]) + sSprBindDY[tRef] into tBY - put tBX & comma & tBY into tLoc - if sSprLastLoc[tRef] is not tLoc then - set the loc of tRef to (tBX - b2kCamShiftX(tRef)) & comma & (tBY - b2kCamShiftY(tRef)) - put tLoc into sSprLastLoc[tRef] - end if - end if - if sSprKind[tRef] is "sheet" and sSprAnim[tRef] is not empty then - if tNow >= sSprNextMS[tRef] then - put sSprSheet[tRef] into tSheet - put sSprAnim[tRef] into tAnim - put tSheet & "|" & tAnim into tAKey - put sAnimList[tAKey] into tList - put the number of lines of tList into tN - put sSprStep[tRef] + 1 into tStep - if tStep > tN then - if sAnimLoop[tAKey] is false then - put empty into sSprAnim[tRef] - put true into sSprLiveDirty -- (if also unbound) retire it - if sSprMsg[tRef] is not empty and sFrameObj is not empty then - dispatch sSprMsg[tRef] to sFrameObj with tRef, tAnim - end if - next repeat - end if - put 1 into tStep - end if - put tStep into sSprStep[tRef] - add (1000 / sSprFPS[tRef]) to sSprNextMS[tRef] - if sSprNextMS[tRef] < tNow then put tNow + (1000 / sSprFPS[tRef]) into sSprNextMS[tRef] - b2kSpriteShowFrame tRef, (line tStep of tList) - end if - end if - catch tErr - put tRef & cr after tDead -- control vanished; clean up below - end try - end repeat - repeat for each line tRef in tDead - if tRef is not empty then b2kSpriteForget tRef - end repeat -end b2kSpritesTick - --- ===================================================================== --- Player: a platformer character controller (input -> motion + anims) --- ===================================================================== --- ONE keyboard-driven player (spec section 6): a vertical capsule with --- fixed rotation, sleep disabled and low friction, steered by a per-frame --- tick that reads the Input module's axes "moveX"/"moveY" and action --- "jump" (rebind those names to remap the controls). Horizontal motion is --- velocity-driven -- vx is accelerated toward axis * moveSpeed, so --- stopping needs no friction and walls can't glue the character. --- Grounding is decided by short downward rays whose surface normal must --- point sufficiently up, which makes walkable-slope vs wall one cosine --- compare. The feel features platformers expect are built in: coyote --- time, jump buffering, release jump-cut (tap = hop, hold = full jump), --- a terminal fall speed, and a one-tick "land" state for dust and sound. --- Wave 2 actions: DOWN ducks (brake to a stop; the hitbox keeps its --- size this wave); DOWN+JUMP on a ONE-WAY CHAIN drops through it (a --- dropMs window without chain collision); UP (or DOWN in the air) in a --- b2kPlayerAddLadder zone climbs (gravity 0, velocity-driven, JUMP --- exits); b2kPlayerHurt is the contact-damage knockback standard. --- With b2kPlayerAnims set, the controller drives b2kSpritePlay/FlipH on --- the player's art -- the player control itself when it IS a sprite, or --- the first sprite b2kSpriteBind-pinned to it (the invisible-body --- pattern). Tuning lives in b2kPlayerSet/Get; b2kClear keeps it (it is --- config, like input bindings); b2kTeardown and b2kPlayerRemove wipe it. - --- One call: an invisible capsule host (or a visible capsule graphic when --- no sheet is given), the body, the controller, input armed. With pSheet, --- a sprite showing the sheet's first frame is created and bound on top -- --- so the collision size (pW x pH) is independent of the art size. --- Reports the player control (give that to b2kCamFollow, sensors, etc). -command b2kPlayerMake pX, pY, pW, pH, pSheet - local tName, tRef, tArt - if sWorld is empty then return empty - if pW is empty then put 32 into pW - if pH is empty then put 48 into pH - put b2kClamp(pW, 4, 10000) into pW - put b2kClamp(pH, 4, 10000) into pH - put b2kNumberOr(pX, sOriginX) into pX - put b2kNumberOr(pY, sOriginY) into pY - put "b2kspawn_" & the milliseconds & "_" & random(1000000) into tName - if sCamGroup is empty then - create graphic (tName) - else - create graphic (tName) in group "b2kcam_view" - end if - put the long id of graphic (tName) into tRef - if sCamGroup is not empty then put true into sInCam[tRef] - set the style of tRef to "rectangle" - set the filled of tRef to true - set the foregroundColor of tRef to "20,20,24" - set the backgroundColor of tRef to "70,130,220" - set the width of tRef to pW - set the height of tRef to pH - set the loc of tRef to round(pX - b2kCamShiftX(tRef)) & "," & round(pY - b2kCamShiftY(tRef)) - put true into sSpawned[tRef] - if pSheet is not empty and sSheetKeys[pSheet] is not empty then - set the visible of tRef to false -- the graphic is only the body host - b2kSpriteNew pSheet, empty, pX, pY - put the result into tArt - if tArt is not empty then - b2kSpriteBind tArt, tRef - else - set the visible of tRef to true -- sprite failed: stay visible - end if - end if - b2kPlayerAttach tRef -- adds the capsule (tall rect -> vertical pill) - return tRef -end b2kPlayerMake - --- Adopt an existing control (or sprite) as the player. A capsule body is --- added if it has none -- then the controller also sets its material (low --- friction); a body you made yourself is left exactly as you tuned it. -command b2kPlayerAttach pCtrl - local tRef - if sWorld is empty then exit b2kPlayerAttach - put the long id of pCtrl into tRef - put false into sPlayOwnBody - if sBody[tRef] is empty then - b2kAddCapsule tRef - if sBody[tRef] is empty then exit b2kPlayerAttach -- zero-sized control - put true into sPlayOwnBody - b2kSetFriction tRef, 0.08 -- low: walls must not glue the capsule - b2kSetBounce tRef, 0 -- characters land DEAD: the default 0.2 - -- restitution rebounded every landing - -- (~13px hop = double land ticks/sounds) - end if - put tRef into sPlayRef - b2kSetFixedRotation tRef, true - b2kSetSleepEnabled tRef, false -- a player must always respond - put (the width of tRef) / 2 into sPlayHalfW - put (the height of tRef) / 2 into sPlayHalfH - put "idle" into sPlayState - put 1 into sPlayFacing - put false into sPlayGrounded - put 0 into sPlayGroundMS - put 0 into sPlayPressMS - put false into sPlayJumping - put true into sPlayControl - put 0 into sPlayHoldMS - put 0 into sPlayClock - put 0 into sPlayAir - put empty into sPlayAnimNow - put empty into sPlayFlipNow - -- Wave 2 state starts clean (ladder ZONES survive an attach: they are - -- world geometry, registered before or after the player exists) - put 0 into sPlayPX - put 0 into sPlayPY - put false into sPlayOnOneWay - put 0 into sPlayDropUntil - put 0 into sPlayDropHard - put empty into sPlayDropMask - put false into sPlayClimb - put empty into sPlayGravSave - put false into sPlaySwim - put empty into sPlaySwimGravSave - put false into sPlayHurt - put 0 into sPlayHurtEnd - put 0 into sPlayHurtHalf - put 0 into sPlayHurtLand - put 0 into sPlayInvulnUntil - if sPlayLadN is empty then put 0 into sPlayLadN - b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick - b2kPlayerResolveArt - b2kInputOn -- arms the sampler; starter bindings fill empty slots only -end b2kPlayerAttach - --- Internal: which control's animations the controller drives -- the --- player itself when it is a sprite, else the first sprite bound to it. -command b2kPlayerResolveArt - local tRef - put empty into sPlayArt - if sPlayRef is empty then exit b2kPlayerResolveArt - if sSprSheet[sPlayRef] is not empty then - put sPlayRef into sPlayArt - exit b2kPlayerResolveArt - end if - repeat for each line tRef in sSprRefs - if sSprBind[tRef] is sPlayRef then - put tRef into sPlayArt - exit b2kPlayerResolveArt - end if - end repeat -end b2kPlayerResolveArt - --- Tuning knobs (pixels, px/s, ms, degrees). Keys: moveSpeed, accel, --- airAccel, jumpSpeed, jumpCut, coyoteMs, bufferMs, maxFall, maxSlopeDeg, --- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ --- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), --- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the --- player exists; unknown keys are stored verbatim for your own use. -command b2kPlayerSet pKey, pValue - put pValue into sPlayTune[toLower(pKey)] - b2kPlayerTuneCache -end b2kPlayerSet - --- Internal: resolve the knobs into flat locals ONCE per change -- the --- tick reads these every frame and must not pay name lookups for them. -command b2kPlayerTuneCache - put b2kPlayerGet("moveSpeed") into sPlayMoveSpd - put b2kPlayerGet("accel") into sPlayAccelG - put b2kPlayerGet("airAccel") into sPlayAccelA - put b2kPlayerGet("jumpSpeed") into sPlayJumpSpd - put b2kPlayerGet("jumpCut") into sPlayJumpCut - put b2kPlayerGet("coyoteMs") into sPlayCoyote - put b2kPlayerGet("bufferMs") into sPlayBuffer - put b2kPlayerGet("maxFall") into sPlayMaxFall - put b2kPlayerGet("dropMs") into sPlayDropMS - put b2kPlayerGet("climbSpeed") into sPlayClimbSpd - put b2kPlayerGet("swimSpeed") into sPlaySwimSpd - put b2kPlayerGet("swimJump") into sPlaySwimJump - put b2kPlayerGet("swimGravity") into sPlaySwimGrav - put b2kPlayerGet("swimMaxFall") into sPlaySwimMaxFall - put b2kPlayerGet("hurtPopX") into sPlayHurtPopX - put b2kPlayerGet("hurtPopY") into sPlayHurtPopY - put b2kPlayerGet("hurtMs") into sPlayHurtMS - put b2kPlayerGet("invulnMs") into sPlayInvulnMS - put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope - if sPlayHalfW is not empty and sPlayHalfW > 0 then - put sPlayHalfH + 4 into sPlayReach - put (0 - max(2, sPlayHalfW * 0.6)) & comma & 0 & comma & max(2, sPlayHalfW * 0.6) into sPlayProbeOffs - end if -end b2kPlayerTuneCache - -function b2kPlayerGet pKey - local tDef - put toLower(pKey) into pKey - put b2kPlayerDefault(pKey) into tDef - if sPlayTune[pKey] is empty then return tDef - -- known keys are numeric: a garbage value must not break the tick - if tDef is not empty then return b2kNumberOr(sPlayTune[pKey], tDef) - return sPlayTune[pKey] -end b2kPlayerGet - --- Internal: the defaults (spec section 6 -- a 32x48 px player, scale 40). -function b2kPlayerDefault pKey - switch pKey - case "movespeed" - return 220 - case "accel" - return 1800 - case "airaccel" - return 1100 - case "jumpspeed" - return 460 - case "jumpcut" - return 0.45 - case "coyotems" - return 90 - case "bufferms" - return 110 - case "maxfall" - return 900 - case "maxslopedeg" - return 50 - case "dropms" - return 260 - case "climbspeed" - return 160 - case "swimspeed" - return 150 - case "swimjump" - return 300 - case "swimgravity" - return 0.35 - case "swimmaxfall" - return 150 - case "hurtpopx" - return 220 - case "hurtpopy" - return 320 - case "hurtms" - return 700 - case "invulnms" - return 900 - end switch - return empty -end b2kPlayerDefault - --- Map the controller's states to the art's animations (empty leaves that --- state alone; pFall defaults to pJump). pLand is an optional non-looping --- touch-down flourish: the controller holds it for its own duration, then --- idle/run resume. NOTE: a finishing land animation dispatches the art's --- b2kSpriteOnFinish message like any non-looping animation would. --- Wave 2 slots (all optional, so old five-argument calls keep working): --- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; --- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim - put pIdle into sPlayAnims["idle"] - put pRun into sPlayAnims["run"] - put pJump into sPlayAnims["jump"] - if pFall is empty then - put pJump into sPlayAnims["fall"] - else - put pFall into sPlayAnims["fall"] - end if - put pLand into sPlayAnims["land"] - if pDuck is empty then - put pIdle into sPlayAnims["duck"] - else - put pDuck into sPlayAnims["duck"] - end if - if pClimb is empty then - put pJump into sPlayAnims["climb"] - else - put pClimb into sPlayAnims["climb"] - end if - if pHurt is empty then - put pJump into sPlayAnims["hurt"] - else - put pHurt into sPlayAnims["hurt"] - end if - if pSwim is empty then - put sPlayAnims["fall"] into sPlayAnims["swim"] - else - put pSwim into sPlayAnims["swim"] - end if - put empty into sPlayAnimNow -- re-assert on the next tick - if sPlayArt is empty then b2kPlayerResolveArt -end b2kPlayerAnims - -function b2kPlayer - return sPlayRef -end b2kPlayer - --- The art control the controller animates (empty = none resolved yet). -function b2kPlayerSprite - return sPlayArt -end b2kPlayerSprite - --- Grounded this frame (post-tick; false on the frame a jump launches). -function b2kPlayerOnGround - return (sPlayGrounded is true) -end b2kPlayerOnGround - --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. -function b2kPlayerState - return sPlayState -end b2kPlayerState - -function b2kPlayerFacing - if sPlayFacing is -1 then return -1 - return 1 -end b2kPlayerFacing - --- Programmatic jump (springs, double-jump powerups): the same launch as a --- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides --- when it is allowed. Uses the jumpSpeed knob unless given a speed. -command b2kPlayerJump pSpeed - local tVel - if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerJump - if pSpeed is empty then put b2kPlayerGet("jumpSpeed") into pSpeed - set the itemDelimiter to comma - put b2kVelocity(sPlayRef) into tVel - b2kSetVelocity sPlayRef, item 1 of tVel, 0 - pSpeed - put true into sPlayJumping - put 0 into sPlayGroundMS -- consume coyote: no free second jump - put 0 into sPlayPressMS -- ...and any buffered press -end b2kPlayerJump - --- Register a ladder ZONE (screen-px rect, any corner order). While the --- player's centre is inside one, UP enters the climb state (DOWN too, --- when airborne -- walk off a platform and grab); gravity parks at 0, --- y runs at climbSpeed off the moveY axis (0 = hang), x at half speed. --- JUMP exits with a normal jump; leaving the zone (or climbing down --- onto ground) restores gravity. Zones are pure polled geometry -- no --- physics objects -- and are WORLD state: b2kClear wipes them. --- TIP: run the zone a little above a platform at the ladder's top, so --- walking off that edge while holding DOWN grabs the ladder. -command b2kPlayerAddLadder pX1, pY1, pX2, pY2 - if pX1 is not a number or pY1 is not a number \ - or pX2 is not a number or pY2 is not a number then exit b2kPlayerAddLadder - if sPlayLadN is empty then put 0 into sPlayLadN - add 1 to sPlayLadN - put min(pX1, pX2) into sPlayLadL[sPlayLadN] - put max(pX1, pX2) into sPlayLadR[sPlayLadN] - put min(pY1, pY2) into sPlayLadT[sPlayLadN] - put max(pY1, pY2) into sPlayLadB[sPlayLadN] -end b2kPlayerAddLadder - --- Register a water ZONE (screen-px rect, any corner order). While the --- player's centre is inside one, the controller SWIMS: gravity drops to --- swimGravity (buoyant), the sink caps at swimMaxFall, UP/DOWN swim at --- swimSpeed, and JUMP is a repeatable upward STROKE (swimJump) -- no --- ground needed. Leaving the zone restores gravity. Pure polled geometry, --- WORLD state (b2kClear wipes them), exactly like the ladder zones. --- TIP: top the zone a little ABOVE the water's surface tiles, so the dive --- in and the surface-out break the water where the surface art sits. -command b2kPlayerAddWater pX1, pY1, pX2, pY2 - if pX1 is not a number or pY1 is not a number \ - or pX2 is not a number or pY2 is not a number then exit b2kPlayerAddWater - if sPlayWatN is empty then put 0 into sPlayWatN - add 1 to sPlayWatN - put min(pX1, pX2) into sPlayWatL[sPlayWatN] - put max(pX1, pX2) into sPlayWatR[sPlayWatN] - put min(pY1, pY2) into sPlayWatT[sPlayWatN] - put max(pY1, pY2) into sPlayWatB[sPlayWatN] -end b2kPlayerAddWater - --- The knockback standard (spec section 9.4): control off, an away-pop --- (the sign of pFromX vs the player picks the direction; no/empty --- pFromX pops away from the facing), the hurt anim, control restored --- after hurtMs OR the first landing after half of it -- whichever is --- LATER -- then an invulnMs mercy window during which this command --- no-ops and b2kPlayerHurtIs() stays true (skip your hazard checks). --- Games keep their respawn flow for LETHAL hits (pits, kill planes): --- contact damage knocks back, falling dies -- the Wave 2 split. -command b2kPlayerHurt pFromX - local tDir - if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerHurt - if b2kPlayerHurtIs() then exit b2kPlayerHurt -- mercy window - if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body - if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] - if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] - if pFromX is a number then - if pFromX > sPlayPX then - put -1 into tDir - else - put 1 into tDir - end if - else - put 0 - sPlayFacing into tDir -- pop back off whatever was ahead - end if - -- the pop rides the jump flag, like b2kPlayerJump: a raw upward - -- set-velocity on a grounded player reads as solver rebound and gets - -- ground-snapped to zero (gotcha 16) - b2kSetVelocity sPlayRef, tDir * sPlayHurtPopX, 0 - sPlayHurtPopY - put true into sPlayJumping - put true into sPlayHurt - put 0 into sPlayHurtLand - put sPlayClock + sPlayHurtMS into sPlayHurtEnd - put sPlayClock + sPlayHurtMS / 2 into sPlayHurtHalf - put false into sPlayGrounded - put 0 into sPlayGroundMS -- no coyote freebie off a knockback - put 0 into sPlayPressMS - put "hurt" into sPlayState - put empty into sPlayAnimNow -- assert the hurt anim this tick -end b2kPlayerHurt - --- True through the knockback AND the mercy window that follows it: the --- one gate a game's hazard checks need (poll it before calling --- b2kPlayerHurt -- though a hurt inside the window already no-ops). -function b2kPlayerHurtIs - if sPlayHurt is true then return true - if sPlayInvulnUntil is not empty and sPlayInvulnUntil > 0 and sPlayClock < sPlayInvulnUntil then return true - return false -end b2kPlayerHurtIs - --- false = the controller only OBSERVES: ground/state/facing stay fresh --- but it writes neither velocity nor animations (the maxFall clamp stays --- live -- it is the character's terminal velocity). For cutscenes, --- hit poses, scripted deaths; physics and the Kit run on. -command b2kPlayerControl pFlag - put (pFlag is not false) into sPlayControl - -- an EXPLICIT control call overrides a knockback in flight: the game - -- is taking the body (cutscene, respawn). No mercy window is granted - -- -- the game owns what happens next. - if sPlayHurt is true then - put false into sPlayHurt - put 0 into sPlayHurtLand - end if - -- returning control must re-assert the state anim over any manual pose - if sPlayControl then put empty into sPlayAnimNow -end b2kPlayerControl - --- Tear down the controller, tuning included. The body and sprite remain --- yours: remove them with b2kRemove / b2kSpriteRemove as usual. -command b2kPlayerRemove - b2kPlayerForget true -end b2kPlayerRemove - --- Internal: drop controller state. b2kClear passes false (tuning is --- config and survives, like input bindings); teardown/remove pass true. --- Ladder zones are WORLD state, so they go either way; a mid-climb or --- mid-drop body gets its gravity scale and collision mask back first. -command b2kPlayerForget pFull - if sPlayClimb is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then - b2kPlayerClimbEnd sBody[sPlayRef] - end if - if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then - b2kPlayerSwimEnd sBody[sPlayRef] - end if - if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore - put empty into sPlayRef - put empty into sPlayArt - put empty into sPlayAnims - put empty into sPlayState - put false into sPlayGrounded - put false into sPlayJumping - put true into sPlayControl - put 1 into sPlayFacing - put 0 into sPlayGroundMS - put 0 into sPlayPressMS - put 0 into sPlayHoldMS - put 0 into sPlayClock - put 0 into sPlayAir - put empty into sPlayAnimNow - put empty into sPlayFlipNow - put false into sPlayOnOneWay - put 0 into sPlayDropUntil - put 0 into sPlayDropHard - put empty into sPlayDropMask - put false into sPlayClimb - put empty into sPlayGravSave - put false into sPlaySwim - put empty into sPlaySwimGravSave - put false into sPlayHurt - put 0 into sPlayHurtEnd - put 0 into sPlayHurtHalf - put 0 into sPlayHurtLand - put 0 into sPlayInvulnUntil - put 0 into sPlayLadN - put empty into sPlayLadL - put empty into sPlayLadT - put empty into sPlayLadR - put empty into sPlayLadB - put 0 into sPlayWatN - put empty into sPlayWatL - put empty into sPlayWatT - put empty into sPlayWatR - put empty into sPlayWatB - if pFull is true then put empty into sPlayTune -end b2kPlayerForget - --- Internal: 3 short rays cast down from the capsule's bottom; ground is --- any hit whose surface normal points up within maxSlopeDeg. The rays --- start inside our own capsule, which Box2D structurally never reports --- (Phase 0, S7) -- self-hits are impossible. Sets the grounded flag and --- refreshes the coyote clock. While OUR jump is still ascending the --- probe is skipped outright: it would otherwise re-ground on the launch --- frame and report phantom landings while rising THROUGH a one-way --- chain (the bridge route). Plain upward motion is NOT skipped -- running --- up a slope moves the capsule upward while genuinely grounded. -command b2kPlayerProbe pBody, pVY - local tX, tY, tOff - put false into sPlayGrounded - put false into sPlayOnOneWay - put false into sPlayDropSeen - set the itemDelimiter to comma - -- raw reads with the caller's body handle: the probe runs every - -- frame, so it skips the ref->body lookup and the "x,y" string pack. - -- The centre is stashed even when the rays are skipped -- the tick's - -- ladder zone test and b2kPlayerHurt read it. - put b2kToScreenX(b2BodyX(pBody)) into tX - put b2kToScreenY(b2BodyY(pBody)) into tY - put tX into sPlayPX - put tY into sPlayPY - if sPlayJumping is true and pVY < 0 then exit b2kPlayerProbe - repeat for each item tOff in sPlayProbeOffs - get b2kRayHit(tX + tOff, tY, tX + tOff, tY + sPlayReach) - if sRayNY is not empty and sRayNY <= 0 - sPlayCosSlope then - if sOneWayBody[sRayBodyH] is true then - -- mid-drop, a one-way chain is NOT ground (or the controller - -- would re-ground on the very platform it is leaving -- the - -- phantom-ground lesson in reverse); remember the sighting: - -- the window's restore logic waits for the capsule to clear - if sPlayDropUntil > 0 then - put true into sPlayDropSeen - next repeat - end if - put true into sPlayOnOneWay -- standing on a chain: drop eligible - end if - put true into sPlayGrounded - put sRayNX into sPlayNormX -- flat vs slope, for ground-snap - put sPlayClock into sPlayGroundMS -- the sim clock, not wall time - exit b2kPlayerProbe - end if - end repeat -end b2kPlayerProbe - --- Internal: enter the climb -- gravity parks at 0 (the body's own scale --- is saved and restored, so a game-tuned floatiness survives a ladder). -command b2kPlayerClimbStart pBody - if sPlayClimb is true then exit b2kPlayerClimbStart - put b2BodyGravityScale(pBody) into sPlayGravSave - b2SetGravityScale pBody, 0 - put true into sPlayClimb - put false into sPlayJumping -end b2kPlayerClimbStart - -command b2kPlayerClimbEnd pBody - if sPlayClimb is not true then exit b2kPlayerClimbEnd - b2SetGravityScale pBody, b2kNumberOr(sPlayGravSave, 1) - put empty into sPlayGravSave - put false into sPlayClimb -end b2kPlayerClimbEnd - --- Internal: enter the swim -- gravity drops to swimGravity (buoyant; the --- body's own scale is saved and restored, like the climb). Mutually --- exclusive with the climb: the tick only ever starts one of them. -command b2kPlayerSwimStart pBody - if sPlaySwim is true then exit b2kPlayerSwimStart - put b2BodyGravityScale(pBody) into sPlaySwimGravSave - b2SetGravityScale pBody, b2kNumberOr(sPlaySwimGrav, 0.35) - put true into sPlaySwim - put false into sPlayJumping -end b2kPlayerSwimStart - -command b2kPlayerSwimEnd pBody - if sPlaySwim is not true then exit b2kPlayerSwimEnd - b2SetGravityScale pBody, b2kNumberOr(sPlaySwimGravSave, 1) - put empty into sPlaySwimGravSave - put false into sPlaySwim -end b2kPlayerSwimEnd - --- Internal: open the drop-through window -- take the reserved one-way --- bit out of the player's mask so chains (alone) stop colliding. -command b2kPlayerDropStart - local tS, tM - put sShapeH[sPlayRef] into tS - if tS is empty then exit b2kPlayerDropStart - put b2ShapeFilterMask(tS) into tM - -- Box2D's default mask reads back as 2^64-1, which neither xTalk's - -- 32-bit bit ops nor the shim's 2^53 guard can round-trip: clamp to - -- "all 32 kit bits" -- identical behaviour, every category this Kit - -- can mint lives below 2^32 - if tM > 4294967295 then put 4294967295 into tM - put tM into sPlayDropMask - b2SetShapeFilter tS, b2ShapeFilterCategory(tS), tM bitAnd (bitNot 2147483648), b2ShapeFilterGroup(tS) - put sPlayClock + sPlayDropMS into sPlayDropUntil - put sPlayClock + sPlayDropMS * 4 into sPlayDropHard - put sRayY into sPlayDropLineY -- the grounding ray's hit = the surface line -end b2kPlayerDropStart - -command b2kPlayerDropRestore - local tS - put 0 into sPlayDropUntil - put 0 into sPlayDropHard - put sShapeH[sPlayRef] into tS - if tS is empty or sPlayDropMask is empty then exit b2kPlayerDropRestore - b2SetShapeFilter tS, b2ShapeFilterCategory(tS), sPlayDropMask, b2ShapeFilterGroup(tS) - put empty into sPlayDropMask -end b2kPlayerDropRestore - --- Internal: the per-frame controller. Loop order: input -> PLAYER -> --- sprites -> camera, so it reads this frame's edges and the sprite tick --- applies the anim it picks. Exits in one compare when unused. The --- Wave 2 paths (drop window, ladders, hurt) each cost ONE compare per --- frame while idle -- the tick's steady-state budget is unchanged. -command b2kPlayerTick - local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep - local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater - if sPlayRef is empty then exit b2kPlayerTick - put sBody[sPlayRef] into tB - if tB is empty then exit b2kPlayerTick - -- the controller's clock is SIM time: the coyote/buffer windows must - -- mean the same number of frames on every machine (and stand still - -- while paused/hand-stepped) -- wall-clock would not - put sPlayClock + b2kClamp(sFrameMS, 0, 100) into sPlayClock - put sPlayClock into tNow - -- present velocity, screen-oriented px/s (positive vy = falling). - -- Raw reads with the cached handle: this runs every frame, so skip - -- the wrapper's ref lookup and "vx,vy" string round-trip (same math - -- as b2kVelocity -- mind the y flip, gotcha 9). - set the itemDelimiter to comma - put b2BodyVX(tB) * sScale into tVX - put 0 - (b2BodyVY(tB) * sScale) into tVY - b2kPlayerProbe tB, tVY - -- the drop window's bookkeeping runs UNGATED (a hurt or control-off - -- mid-drop must never strand the mask without its one-way bit). The - -- mask returns when the clock has run AND the capsule has cleared the - -- line it dropped through -- a straddling restore would snap it back - -- on top (chain contacts are one-sided, judged by the centroid) -- - -- with a hard 4x deadline for drops that land on something below. - if sPlayDropUntil > 0 and tNow >= sPlayDropUntil then - if (sPlayDropSeen is not true and sPlayPY - sPlayHalfH > sPlayDropLineY + 2) \ - or tNow >= sPlayDropHard then - b2kPlayerDropRestore - end if - end if - -- the hurt window runs its own clock: control comes back at hurtMs - -- OR the first landing after half of it, whichever is LATER, and the - -- mercy window starts then - if sPlayHurt is true then - if sPlayHurtLand is 0 and sPlayGrounded is true and tNow >= sPlayHurtHalf then - put tNow into sPlayHurtLand - end if - if tNow >= sPlayHurtEnd and sPlayHurtLand > 0 then - put false into sPlayHurt - put tNow + sPlayInvulnMS into sPlayInvulnUntil - put empty into sPlayAnimNow -- re-assert the state anim - end if - end if - put false into tWrite - put false into tDuck - if sPlayControl is true and sPlayHurt is not true then - put sFrameMS / 1000 into tDT - if tDT <= 0 then put 1 / 60 into tDT - if tDT > 0.05 then put 0.05 into tDT - put b2kAxis("moveX") into tAxis - put b2kAxis("moveY") into tAxisY - if tAxis is not 0 then - if tAxis < 0 then - put -1 into sPlayFacing - else - put 1 into sPlayFacing - end if - end if - -- ladder + water presence: pure numeric compares on the probe's - -- stashed centre; zero zones of a kind = one compare and out - put false into tInZone - if sPlayLadN > 0 then - repeat with i = 1 to sPlayLadN - if sPlayPX >= sPlayLadL[i] and sPlayPX <= sPlayLadR[i] \ - and sPlayPY >= sPlayLadT[i] and sPlayPY <= sPlayLadB[i] then - put true into tInZone - exit repeat - end if - end repeat - end if - put false into tInWater - if sPlayWatN > 0 then - repeat with i = 1 to sPlayWatN - if sPlayPX >= sPlayWatL[i] and sPlayPX <= sPlayWatR[i] \ - and sPlayPY >= sPlayWatT[i] and sPlayPY <= sPlayWatB[i] then - put true into tInWater - exit repeat - end if - end repeat - end if - if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then - -- enter: UP any time in-zone; DOWN only while AIRBORNE (a - -- grounded DOWN is a duck -- or a drop-through on a chain) - if tAxisY is -1 or (tAxisY is 1 and sPlayGrounded is not true) then - b2kPlayerClimbStart tB - end if - end if - if sPlaySwim is not true and sPlayClimb is not true and tInWater is true then - b2kPlayerSwimStart tB -- submerged: buoyant gravity, stroke to rise - end if - if sPlayClimb is true then - if b2kActionPressed("jump") then - -- JUMP exits with a normal jump. The same press edge is - -- still "pressed" when the walk branch runs below this - -- tick: tClimbJump keeps it from re-arming the buffer - -- (one press must never pay for two jumps) - b2kPlayerClimbEnd tB - put 0 - sPlayJumpSpd into tVY - put true into sPlayJumping - put false into sPlayGrounded - put 0 into sPlayGroundMS - put 0 into sPlayPressMS - put true into tClimbJump - put true into tWrite - else - if tInZone is not true or (sPlayGrounded is true and tAxisY is 1) then - -- slid out of the zone, or climbed down onto ground: - -- gravity returns and (same tick) normal control resumes - b2kPlayerClimbEnd tB - else - -- the climb drive: y runs at climbSpeed off the moveY - -- axis (0 = hang), x at half speed, gravity parked at 0 - put tAxisY * sPlayClimbSpd into tVY - put tAxis * sPlayMoveSpd / 2 into tTarget - put sPlayAccelG * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX - end if - put true into tWrite - end if - end if - end if - if sPlaySwim is true then - if tInWater is not true then - b2kPlayerSwimEnd tB -- surfaced / left the pool: gravity returns - else - -- horizontal: ease vx toward axis * swimSpeed (sluggish -- the - -- air-accel rate gives the underwater drag) - put tAxis * sPlaySwimSpd into tTarget - put sPlayAccelA * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX - end if - -- vertical: a JUMP press is a repeatable upward STROKE (no - -- ground gate -- the spec's water jump); else UP/DOWN swim at - -- swimSpeed; else the reduced gravity sinks you, capped low - if b2kActionPressed("jump") then - put 0 - sPlaySwimJump into tVY - else - if tAxisY is not 0 then - put tAxisY * sPlaySwimSpd into tVY - end if - end if - put true into tWrite - end if - end if - if sPlayClimb is not true and sPlaySwim is not true then - -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) - put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) - if tAxisY is 1 and sPlayGrounded is true then - put true into tDuck - put 0 into tTarget - end if - if sPlayGrounded then - put sPlayAccelG into tAcc - else - put sPlayAccelA into tAcc - end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX - end if - put true into tWrite - if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS - if tDuck is true then - -- a press while crouched: on a ONE-WAY CHAIN it drops - -- through (dropMs of no chain collision); on solid ground - -- nothing -- and nothing later either, so the buffer is - -- eaten (a crouch-press must not launch on release) - if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayOnOneWay is true and sPlayDropUntil is 0 then - b2kPlayerDropStart - put false into sPlayGrounded -- falling from this frame on - put 0 into sPlayGroundMS -- no coyote out of a drop - end if - put 0 into sPlayPressMS - end if - else - -- jump: a buffered press fires while grounded-or-coyote - if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY - put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer - end if - end if - end if - -- jump-cut: releasing while still rising from OUR jump shortens it - if sPlayJumping and tVY < 0 and b2kActionReleased("jump") then - put tVY * sPlayJumpCut into tVY - put false into sPlayJumping - end if - end if - end if - if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex - -- terminal velocity: the low swimMaxFall is the buoyant sink cap while - -- submerged; the normal maxFall (the character's terminal velocity) else - if sPlaySwim is true then - if tVY > sPlaySwimMaxFall then - put sPlaySwimMaxFall into tVY - put true into tWrite - end if - else - if tVY > sPlayMaxFall then - put sPlayMaxFall into tVY - put true into tWrite - end if - end if - -- GROUND-SNAP: grounded on FLAT ground, drifting upward, and we did - -- not jump = the contact solver's push-out rebound after a hard - -- landing (a ~7px hop: phantom airtime, double land ticks - measured - -- by the self-test at vy 61 up). Kill it at the source. Slopes are - -- exempt (|normalX| >= 0.1): running uphill is real upward motion. - -- Climbs are exempt too: rising up a ladder from its grounded base - -- is exactly the motion this guard exists to kill. External boosts - -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's - -- pop rides the same flag). - if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then - put 0 into tVY - put true into tWrite - end if - if tWrite then b2SetVelocity tB, tVX / sScale, 0 - (tVY / sScale) - -- the state machine (land = exactly one tick on touch-down), with - -- LANDING HYSTERESIS: the contact solver's push-out (and chain seams) - -- can blip the 4px probe off for a tick around an impact -- without - -- hysteresis that read as a second micro-fall and a second land tick - -- (double land sounds; the self-test caught it). A touchdown counts - -- as "land" only after a real airborne stretch (3+ ticks), and a - -- single ungrounded blip does not even show as airborne -- unless it - -- is OUR jump, which must read as "jump" on its launch frame. - -- Hurt and climb OWN the state outright; a knockback's landing shows - -- no "land" tick (the hit pose holds through the touchdown). - put sPlayState into tPrevState - if sPlayHurt is true then - put "hurt" into sPlayState - if sPlayGrounded is true then - put 0 into sPlayAir - else - add 1 to sPlayAir - end if - else - if sPlayClimb is true then - put "climb" into sPlayState - put 0 into sPlayAir - else - if sPlayGrounded is true then - if (tPrevState is "jump" or tPrevState is "fall") and sPlayAir >= 3 then - put "land" into sPlayState - else - if tDuck is true then - put "duck" into sPlayState - else - if abs(tVX) < 8 then - put "idle" into sPlayState - else - put "run" into sPlayState - end if - end if - end if - put 0 into sPlayAir - else - add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState - end if - end if - end if - end if - end if - -- swim OWNS the state while submerged: the machine above ran the - -- grounded/airborne branch (sPlayClimb is false underwater), so override - -- to "swim" and keep the air counter clear -- surfacing must never read - -- as a long fall plus a phantom land tick. - if sPlaySwim is true and sPlayHurt is not true then - put "swim" into sPlayState - put 0 into sPlayAir - end if - -- animations: only while controlling (manual poses own the art when - -- control is off), and never let a vanished art control abort the - -- frame -- the loop's ticks share one try block - if sPlayControl is not true then exit b2kPlayerTick - if sPlayAnims["idle"] is empty and sPlayAnims["run"] is empty then exit b2kPlayerTick - try - if sPlayArt is empty then b2kPlayerResolveArt - if sPlayArt is not empty then b2kPlayerShowState tNow, tVX - catch tErr - put empty into sPlayArt -- art gone; re-resolve next tick - end try -end b2kPlayerTick - --- Internal: drive the art's animation and facing from the current state. -command b2kPlayerShowState pNow, pVX - local tWant, tAnim, tAKey, tFPS, tFlip - put sPlayState into tWant - if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then - put empty into tAnim -- mid land-flourish: leave it playing - else - if tWant is "land" and sPlayAnims["land"] is empty then - -- no flourish mapped: land straight into idle or run, by speed - if abs(pVX) < 8 then - put "idle" into tWant - else - put "run" into tWant - end if - end if - put sPlayAnims[tWant] into tAnim - end if - if tAnim is not empty and tAnim is not sPlayAnimNow then - b2kSpritePlay sPlayArt, tAnim, (tWant is "land") - put tAnim into sPlayAnimNow - if tWant is "land" then - -- hold the flourish for its own length (frames / fps) - put sSprSheet[sPlayArt] & "|" & tAnim into tAKey - put sAnimFPS[tAKey] into tFPS - if tFPS is empty or tFPS <= 0 then put 8 into tFPS - put pNow + (the number of lines of sAnimList[tAKey]) * 1000 / tFPS into sPlayHoldMS - else - put 0 into sPlayHoldMS - end if - end if - put (sPlayFacing < 0) into tFlip - if tFlip is not sPlayFlipNow then - b2kSpriteFlipH sPlayArt, tFlip - put tFlip into sPlayFlipNow - end if -end b2kPlayerShowState - --- ===================================================================== --- Camera: a clipped viewport group the loop scrolls (proven in spike S9) --- ===================================================================== --- While the camera is on, Kit-created controls (spawns and sprites) are --- placed INSIDE the viewport group; their locs stay WORLD pixels, because --- contents of a scrolled group keep their coordinates -- the body sync and --- all physics math are untouched, the scroll is pure presentation. Chrome --- stays on the card; anything behind the (transparent) group reads as an --- infinite-distance parallax backdrop. Map mouse points with --- b2kCamMouseX/Y. Level coordinates should start at 0,0: an invisible --- anchor pins the group's content origin there, so scroll == view corner. -command b2kCamOn pRect - local tG - if sCamGroup is not empty then exit b2kCamOn - put empty into sCamNote - if pRect is empty then put the rect of this card into pRect - try - create group "b2kcam_view" - catch tErr - put "create group failed: " & tErr into sCamNote - exit b2kCamOn - end try - put the long id of group "b2kcam_view" into tG - try - set the margins of tG to 0 - set the showBorder of tG to false - set the hScrollbar of tG to false - set the vScrollbar of tG to false - create graphic "b2kcam_anchor" in group "b2kcam_view" - set the rect of it to 0, 0, 1, 1 - set the visible of it to false - set the lockLoc of tG to true - set the rect of tG to pRect - catch tErr - put "viewport setup failed: " & tErr into sCamNote - try - delete group "b2kcam_view" - catch tErr - end try - exit b2kCamOn - end try - try - set the unboundedHScroll of tG to true - set the unboundedVScroll of tG to true - catch tErr - end try - -- NOTE: deliberately NOT layerMode "scrolling". The compositor caches - -- that mode's content as one surface; past common GPU surface limits - -- (~2048px) the cached world freezes while dynamic-layer sprites keep - -- moving -- the player "runs off" a stopped picture. Plain rendering - -- pans correctly at any level size. - put tG into sCamGroup - set the itemDelimiter to comma - put ((item 1 of pRect) + (item 3 of pRect)) / 2 into sCamX - put ((item 2 of pRect) + (item 4 of pRect)) / 2 into sCamY - put 0.15 into sCamLerp - put 0 into sCamDZW - put 0 into sCamDZH - put empty into sCamFollow - put empty into sCamB1X - put 0 into sCamShakeAmp - put empty into sCamLastH - put empty into sCamLastV - -- self-test: prove the scroll property actually pans this group. The - -- anchor is stretched first so content exceeds the view (engines clamp - -- scroll to content bounds), then restored; failure is reported via - -- b2kCamStatus instead of letting the player walk out of a frozen view. - try - set the rect of graphic "b2kcam_anchor" to 0, 0, 4000, 2000 - set the hScroll of sCamGroup to 7 - if the hScroll of sCamGroup is not 7 then - put "group scroll is ineffective on this engine" into sCamNote - end if - set the hScroll of sCamGroup to 0 - set the rect of graphic "b2kcam_anchor" to 0, 0, 1, 1 - catch tErr - put "setting the group scroll failed: " & tErr into sCamNote - end try - -- probe the engine's coordinate semantics for grouped controls: place a - -- probe at x=100, scroll by 50, and read the loc back. Engines that - -- report 50 give SCROLL-ADJUSTED (visual) locs -- every per-frame - -- position write into the group must then subtract the live scroll, or - -- the write cancels the pan and the control "outruns" the world. - put false into sCamLocVisual - try - set the rect of graphic "b2kcam_anchor" to 0, 0, 4000, 2000 - create graphic "b2kcam_probe" in group "b2kcam_view" - set the rect of it to 96, 96, 104, 104 - set the visible of it to false - set the hScroll of sCamGroup to 50 - put ((item 1 of the loc of graphic "b2kcam_probe") is 50) into sCamLocVisual - set the hScroll of sCamGroup to 0 - delete graphic "b2kcam_probe" - set the rect of graphic "b2kcam_anchor" to 0, 0, 1, 1 - catch tErr - put false into sCamLocVisual - end try - put 0 into sCamCurH - put 0 into sCamCurV - put empty into sInCam - b2kCamApply -end b2kCamOn - --- Dissolve the viewport: children return to the card with their world locs. -command b2kCamOff - if sCamGroup is empty then exit b2kCamOff - try - ungroup group "b2kcam_view" - catch tErr - end try - if there is a graphic "b2kcam_anchor" then delete graphic "b2kcam_anchor" - put empty into sCamGroup - put false into sCamLocVisual - put 0 into sCamCurH - put 0 into sCamCurV - put empty into sInCam -end b2kCamOff - -function b2kCamIsOn - return (sCamGroup is not empty) -end b2kCamIsOn - --- The viewport group's reference (empty when the camera is off) -- create --- your own level controls inside it: create graphic "x" in b2kCamGroup(). -function b2kCamGroup - return sCamGroup -end b2kCamGroup - --- Move an existing control (IDE-designed levels) into the viewport. -command b2kCamAdopt pCtrl - if sCamGroup is empty then exit b2kCamAdopt - try - relayer pCtrl to front of group "b2kcam_view" - catch tErr - -- no script relayer on this engine: the control stays on the card. - -- Physics is unaffected; only its drawing will not scroll. - end try - -- mark AFTER the move: a long id embeds the group path, so the key - -- must be the post-relayer id or the camera's write-compensation - -- (b2kCamShiftX/Y) never recognises adopted controls - try - put true into sInCam[the long id of pCtrl] - catch tErr - end try -end b2kCamAdopt - -command b2kCamFollow pCtrl, pLerp - put the long id of pCtrl into sCamFollow - if pLerp is a number then put b2kClamp(pLerp, 0.01, 1) into sCamLerp -end b2kCamFollow - -command b2kCamUnfollow - put empty into sCamFollow -end b2kCamUnfollow - --- Follow only once the target leaves a centre box (0 = always centre). -command b2kCamDeadzone pW, pH - put b2kNumberOr(pW, 0) into sCamDZW - put b2kNumberOr(pH, 0) into sCamDZH -end b2kCamDeadzone - -command b2kCamBounds pX1, pY1, pX2, pY2 - put pX1 into sCamB1X - put pY1 into sCamB1Y - put pX2 into sCamB2X - put pY2 into sCamB2Y - -- stretch the invisible anchor across the whole level: the group's - -- scrollable content then always spans the bounds, so the engine can - -- never clamp the scroll short (works with or without unbounded scroll) - if sCamGroup is not empty then - try - set the rect of graphic "b2kcam_anchor" to pX1, pY1, pX2, pY2 - catch tErr - end try - end if -end b2kCamBounds - -command b2kCamGoto pX, pY - put b2kNumberOr(pX, sCamX) into sCamX - put b2kNumberOr(pY, sCamY) into sCamY - b2kCamApply -end b2kCamGoto - -function b2kCamPos - return round(sCamX) & comma & round(sCamY) -end b2kCamPos - --- A decaying random view offset on top of the follow (impacts, blasts). -command b2kCamShake pAmpPx, pMs - put b2kClamp(pAmpPx, 0, 200) into sCamShakeAmp - put the milliseconds + b2kClamp(pMs, 0, 5000) into sCamShakeEnd -end b2kCamShake - --- The mouse in WORLD pixels (camera-aware) -- use these for b2kGrab, --- spawning at the pointer, click-picking, instead of the mouseH/V. -function b2kCamMouseX - if sCamGroup is empty then return the mouseH - set the itemDelimiter to comma - return the mouseH - (item 1 of the rect of sCamGroup) + the hScroll of sCamGroup -end b2kCamMouseX - -function b2kCamMouseY - if sCamGroup is empty then return the mouseV - set the itemDelimiter to comma - return the mouseV - (item 2 of the rect of sCamGroup) + the vScroll of sCamGroup -end b2kCamMouseY - --- Health: empty = camera fine (or simply off); otherwise the reason the --- camera could not run properly (create-group/scroll failures). Check it --- after b2kCamOn and degrade your level gracefully. -function b2kCamStatus - return sCamNote -end b2kCamStatus - --- "visual" when this engine reports/sets grouped locs scroll-adjusted, --- else "content". Diagnostic; the Kit compensates automatically. -function b2kCamLocSemantics - if sCamLocVisual is true then return "visual" - return "content" -end b2kCamLocSemantics - --- Internal: the scroll to subtract when WRITING a position into the --- viewport on a visual-semantics engine (0 otherwise / outside it). -function b2kCamShiftX pRef - if sCamLocVisual is not true then return 0 - if sInCam[pRef] is not true then return 0 - return sCamCurH -end b2kCamShiftX - -function b2kCamShiftY pRef - if sCamLocVisual is not true then return 0 - if sInCam[pRef] is not true then return 0 - return sCamCurV -end b2kCamShiftY - --- Move ANY sprite/control to a WORLD position, camera-correct (use this --- instead of `set the loc` for things you animate by hand, like a patrol --- path -- outside a camera it is a plain loc set). -command b2kSpriteMoveTo pCtrl, pX, pY - local tRef - put the long id of pCtrl into tRef - set the loc of tRef to round(pX - b2kCamShiftX(tRef)) & comma & round(pY - b2kCamShiftY(tRef)) -end b2kSpriteMoveTo - --- Internal, called by the loop each frame: chase the followed control. The --- follow eases, but the target is GUARANTEED on screen: if it would leave --- the view (fast falls, teleports, lerp lag), the camera snaps to it. -command b2kCamTick - local tLoc, tTX, tTY, tHalfW, tHalfH - if sCamGroup is empty then exit b2kCamTick - if sCamFollow is not empty then - try - set the itemDelimiter to comma - put the loc of sCamFollow into tLoc - put (item 1 of tLoc) + b2kCamShiftX(sCamFollow) into tTX - put (item 2 of tLoc) + b2kCamShiftY(sCamFollow) into tTY - if abs(tTX - sCamX) > sCamDZW / 2 then - put sCamX + sCamLerp * (tTX - sCamX) into sCamX - end if - if abs(tTY - sCamY) > sCamDZH / 2 then - put sCamY + sCamLerp * (tTY - sCamY) into sCamY - end if - -- the never-offscreen guarantee (32 px inside the view edges) - put ((item 3 of the rect of sCamGroup) - (item 1 of the rect of sCamGroup)) / 2 into tHalfW - put ((item 4 of the rect of sCamGroup) - (item 2 of the rect of sCamGroup)) / 2 into tHalfH - if abs(tTX - sCamX) > tHalfW - 32 then put tTX into sCamX - if abs(tTY - sCamY) > tHalfH - 32 then put tTY into sCamY - catch tErr - put empty into sCamFollow -- target vanished - end try - end if - b2kCamApply - -- reality check: prove the target is inside the view using the ACTUAL - -- scroll properties (not internal state). If anything desynced them -- - -- an engine quirk, a missed write -- force the scroll directly. - if sCamFollow is not empty then - try - set the itemDelimiter to comma - put the loc of sCamFollow into tLoc - put (item 1 of tLoc) + b2kCamShiftX(sCamFollow) - (the hScroll of sCamGroup) into tTX - put ((item 3 of the rect of sCamGroup) - (item 1 of the rect of sCamGroup)) into tHalfW - if tTX < 48 or tTX > tHalfW - 48 then - set the hScroll of sCamGroup to round((item 1 of tLoc) - tHalfW / 2) - put the hScroll of sCamGroup into sCamLastH - put item 1 of tLoc into sCamX - end if - catch tErr - end try - end if -end b2kCamTick - --- Internal: clamp to bounds, add shake, write the scroll (skip when same). -command b2kCamApply - local tVW, tVH, tHalfW, tHalfH, tX, tY, tH, tV, tNow - if sCamGroup is empty then exit b2kCamApply - set the itemDelimiter to comma - put (item 3 of the rect of sCamGroup) - (item 1 of the rect of sCamGroup) into tVW - put (item 4 of the rect of sCamGroup) - (item 2 of the rect of sCamGroup) into tVH - put tVW / 2 into tHalfW - put tVH / 2 into tHalfH - put sCamX into tX - put sCamY into tY - if sCamB1X is not empty then - if tX > sCamB2X - tHalfW then put sCamB2X - tHalfW into tX - if tX < sCamB1X + tHalfW then put sCamB1X + tHalfW into tX - if tY > sCamB2Y - tHalfH then put sCamB2Y - tHalfH into tY - if tY < sCamB1Y + tHalfH then put sCamB1Y + tHalfH into tY - end if - -- write the clamped centre back: the follow math must ease against the - -- view that is actually shown, or the camera lags invisibly at the - -- bounds and the player outruns a scroll that has not started yet - put tX into sCamX - put tY into sCamY - if sCamShakeAmp > 0 then - put the milliseconds into tNow - if tNow >= sCamShakeEnd then - put 0 into sCamShakeAmp - else - put tX + (random(sCamShakeAmp * 2 + 1) - sCamShakeAmp - 1) into tX - put tY + (random(sCamShakeAmp * 2 + 1) - sCamShakeAmp - 1) into tY - end if - end if - put round(tX - tHalfW) into tH -- content origin pinned at 0,0 - put round(tY - tHalfH) into tV - if tH is not sCamLastH then - set the hScroll of sCamGroup to tH - put tH into sCamLastH - end if - if tV is not sCamLastV then - set the vScroll of sCamGroup to tV - put tV into sCamLastV - end if - put tH into sCamCurH - put tV into sCamCurV -end b2kCamApply - --- ===================================================================== --- Audio: named sounds over audioClips + a script-side tone synthesizer --- ===================================================================== --- Model: a sound is an AUDIOCLIP imported into the stack -- the one LC --- sound path with no external media-layer dependency -- and plays by --- name (`play audioClip`). The engine plays ONE clip at a time; a new --- play cuts the previous. That is the classic LC limit, and it suits --- short retro SFX (document it, don't fight it). b2kToneMake SYNTHESIZES --- a clip in pure script (8-bit mono WAV at 22050 Hz, square or sine, a --- comma list of note frequencies, per-note decay so notes never click), --- so a self-contained example ships sound with ZERO asset files. Sounds --- are assets that SURVIVE b2kTeardown -- clips are tiny and resets must --- stay snappy; names are stable, so re-making replaces rather than --- accumulates, and b2kSoundsWipe purges everything (it also sweeps --- b2ksnd_ clips a dead session left in the stack). Failures degrade to --- silence, never to errors: the first play that throws trips a --- dead-flag and b2kSoundStatus() says why. The mute is a user --- preference. There is no per-frame tick -- audio is purely --- event-driven. - --- Import a WAV/AIFF/AU file as a named sound (replaces any same name). -command b2kSoundLoad pName, pPath - local tClip, tCount - if pName is empty then exit b2kSoundLoad - if there is no file pPath then - put "no such file: " & pPath into sSndNote - exit b2kSoundLoad - end if - put "b2ksnd_" & pName into tClip - try - if there is an audioClip tClip then delete audioClip tClip - put the number of audioClips of this stack into tCount - import audioClip from file pPath - if the number of audioClips of this stack > tCount then - set the name of audioClip (tCount + 1) of this stack to tClip - put tClip into sSndClip[pName] - end if - catch tErr - put "import failed: " & tErr into sSndNote - end try -end b2kSoundLoad - --- Synthesize a named sound: pFreqs is a comma list of note frequencies --- in Hz (0 = a rest), each pMsPerNote long; pVolPct 0-100 (default 60); --- pShape "square" (default -- retro/plucky) or "sine" (soft). About --- 22 KB per second of audio, built once -- keep SFX short. --- b2kToneMake "coin", "1319,1760", 36 -- a two-note blip --- b2kToneMake "win", "523,659,784,1047", 110 -- a little fanfare -command b2kToneMake pName, pFreqs, pMsPerNote, pVolPct, pShape - local tClip, tData, tPath, tCount, tF - if pName is empty then exit b2kToneMake - put "b2ksnd_" & pName into tClip - put b2kClamp(pMsPerNote, 5, 2000) into pMsPerNote - put b2kClamp(b2kNumberOr(pVolPct, 60), 0, 100) into pVolPct - put empty into tData - set the itemDelimiter to comma - repeat for each item tF in pFreqs - put b2kToneBytes(b2kNumberOr(word 1 of tF, 440), pMsPerNote, pVolPct, pShape) after tData - end repeat - if tData is empty then exit b2kToneMake - put specialFolderPath("temporary") & "/b2ktone_" & the milliseconds & "_" & random(99999) & ".wav" into tPath - put b2kWavWrap(tData) into URL ("binfile:" & tPath) - try - if there is an audioClip tClip then delete audioClip tClip - put the number of audioClips of this stack into tCount - import audioClip from file tPath - if the number of audioClips of this stack > tCount then - set the name of audioClip (tCount + 1) of this stack to tClip - put tClip into sSndClip[pName] - end if - catch tErr - put "import failed: " & tErr into sSndNote - end try - try - delete file tPath - catch tErr - end try -end b2kToneMake - --- Internal: pMs of one note as 8-bit unsigned mono PCM at 22050 Hz, --- with a linear decay across the note (clickless and plucky). -function b2kToneBytes pFreq, pMs, pVolPct, pShape - local tRate, tN, tOut, i, tPhase, tStep, tAmp, tEnv, tS - put 22050 into tRate - put round(tRate * pMs / 1000) into tN - if tN < 1 then return empty - put pVolPct * 1.27 into tAmp -- 0-100 -> 0-127 sample amplitude - put pFreq / tRate into tStep - put 0 into tPhase - put empty into tOut - repeat with i = 0 to tN - 1 - put tAmp * (1 - i / tN) into tEnv - if pFreq <= 0 then - put 0 into tS -- a rest - else - if pShape is "sine" then - put tEnv * sin(tPhase * 2 * kPI) into tS - else - if tPhase mod 1 < 0.5 then - put tEnv into tS - else - put 0 - tEnv into tS - end if - end if - end if - put numToByte(round(128 + tS)) after tOut - add tStep to tPhase - end repeat - return tOut -end b2kToneBytes - --- Internal: wrap raw 8-bit mono 22050 Hz samples in a WAV container. -function b2kWavWrap pData - local tLen - put the number of bytes in pData into tLen - return "RIFF" & b2kLE32(36 + tLen) & "WAVE" & "fmt " & b2kLE32(16) \ - & b2kLE16(1) & b2kLE16(1) & b2kLE32(22050) & b2kLE32(22050) \ - & b2kLE16(1) & b2kLE16(8) & "data" & b2kLE32(tLen) & pData -end b2kWavWrap - --- Internal: little-endian byte packers (hand-rolled: no binaryEncode --- format-letter portability worries). -function b2kLE16 pN - return numToByte(pN mod 256) & numToByte(pN div 256 mod 256) -end b2kLE16 - -function b2kLE32 pN - return numToByte(pN mod 256) & numToByte(pN div 256 mod 256) & numToByte(pN div 65536 mod 256) & numToByte(pN div 16777216 mod 256) -end b2kLE32 - --- Play a named sound (cuts whatever is playing -- the audioClip model). -command b2kSound pName - local tClip - if sSndMute is true or sSndDead is true then exit b2kSound - put sSndClip[pName] into tClip - if tClip is empty then exit b2kSound - try - play audioClip tClip - catch tErr - put true into sSndDead -- this engine can't play: silence, no spam - put "play failed: " & tErr into sSndNote - end try -end b2kSound - --- Loop a named sound until b2kSoundStop / the next play (ambience). -command b2kSoundLoop pName - local tClip - if sSndMute is true or sSndDead is true then exit b2kSoundLoop - put sSndClip[pName] into tClip - if tClip is empty then exit b2kSoundLoop - try - play audioClip tClip looping - catch tErr - put true into sSndDead - put "play failed: " & tErr into sSndNote - end try -end b2kSoundLoop - -command b2kSoundStop - try - play stop - catch tErr - end try -end b2kSoundStop - --- Mute is a preference: it survives b2kTeardown and stack resets are up --- to the caller (store it in a custom property if you want it saved). -command b2kSoundMute pFlag - put (pFlag is true) into sSndMute - if sSndMute then b2kSoundStop -end b2kSoundMute - -function b2kSoundMuted - return (sSndMute is true) -end b2kSoundMuted - --- The engine-GLOBAL output volume (the playLoudness, 0-100): it affects --- every stack's audio, so games should expose it, not hardcode it. -command b2kSoundVolume pPct - try - set the playLoudness to round(b2kClamp(pPct, 0, 100)) - catch tErr - put "volume failed: " & tErr into sSndNote - end try -end b2kSoundVolume - -function b2kSoundIsLoaded pName - return (sSndClip[pName] is not empty) -end b2kSoundIsLoaded - --- Empty = healthy; otherwise the most recent reason audio degraded --- (missing file, import failure, play failure). Plays never throw. -function b2kSoundStatus - return sSndNote -end b2kSoundStatus - --- Internal: delete every Kit-made audioClip ("b2ksnd_" prefix -- catches --- a dead session's leftovers too) and reset the registry. The dead-flag --- resets (a fresh world may retry); the mute preference survives. -command b2kSoundsWipe - local i, tName - try - play stop - catch tErr - end try - repeat with i = the number of audioClips of this stack down to 1 - put the short name of audioClip i of this stack into tName - if char 1 to 7 of tName is "b2ksnd_" then - try - delete audioClip i of this stack - catch tErr - end try - end if - end repeat - put empty into sSndClip - put false into sSndDead - put empty into sSndNote -end b2kSoundsWipe - --- ===================================================================== --- The loop (fixed timestep, generation-guarded; one screen lock per frame) --- ===================================================================== --- The whole frame -- body sync, contact/sensor dispatch and the app's own --- b2kFrame drawing -- runs inside a single lock screen / unlock screen so it --- composites in ONE redraw (no tearing between bodies and the joints/HUD a user --- draws on top). The try/catch guarantees the unlock even if a user handler --- throws, so a faulty event handler can never leave the screen frozen. -on b2kStep pGen - local tNow, tElapsed, tFixed, tNext - if not sRunning then exit b2kStep - if pGen is not sGen then exit b2kStep - if sPaused is not true then - put the milliseconds into tNow - put (tNow - sLast) / 1000 into tElapsed - put tNow into sLast - add tElapsed to sAccum - if sAccum > 0.20 then put 0.20 into sAccum - put tElapsed * 1000 into sFrameMS - put 1 / 60 into tFixed - -- events are HARVESTED per fixed step into frame buffers: Box2D - -- only exposes the LAST step's events, so a 2-step frame would - -- lose the first step's pickups/stomps, and a 0-step frame would - -- re-read (duplicate!) the previous step's. The buffers make the - -- frame's event view exact either way. - b2kEventsReset - repeat while sAccum >= tFixed - b2Step sWorld, tFixed, sSub - b2kHarvestEvents - subtract tFixed from sAccum - end repeat - if sDragging then - -- camera-aware: b2kCamMouseX/Y fall back to the raw mouse when off - b2MouseSetTarget sDragJoint, b2kToWorldX(b2kCamMouseX()), b2kToWorldY(b2kCamMouseY()) - end if - lock screen - try - b2kSyncBodies -- lock-free; this frame's single lock is held here - b2kInputTick -- keyboard sample, so b2kFrame sees this frame's edges - b2kPlayerTick -- reads the fresh edges, steers, picks the anim - b2kSpritesTick -- bound sprites follow bodies; animations advance - b2kCamTick -- viewport chases its target over the fresh locs - b2kDispatchContacts - b2kDispatchSensors - if sFrameObj is not empty then dispatch "b2kFrame" to sFrameObj - catch tErr -- a user handler error must never freeze the screen - end try - unlock screen - end if - -- Pace the next step against what this frame consumed instead of a flat - -- 16 ms scheduled AFTER the work: the flat delay made the real rate - -- "timer cadence (~16.5 ms) plus frame cost", measured at ~50 fps under - -- load in the Phase 0 spike. Clamp to 1..16 so a heavy frame reschedules - -- immediately and an idle one never busy-loops. - -- Reschedule with pGen (OUR generation), never the live sGen: if a user - -- handler inside this frame tore the world down and restarted it (a - -- level change from a sensor event), sGen is already the NEW loop's -- - -- rescheduling with it would clone the loop and double-step forever. - -- With pGen, this stale instance's next tick fails the guard and dies. - if sPaused is true then - send ("b2kStep " & pGen) to me in 16 milliseconds - else - put 16 - (the milliseconds - tNow) into tNext - if tNext < 1 then put 1 into tNext - if tNext > 16 then put 16 into tNext - send ("b2kStep " & pGen) to me in tNext milliseconds - end if -end b2kStep - --- Advance exactly one fixed step, even while paused (drives a Step button). -command b2kStepOnce - if not sRunning then exit b2kStepOnce - b2kEventsReset - b2Step sWorld, (1 / 60), sSub - b2kHarvestEvents - put 1000 / 60 into sFrameMS -- a hand-stepped frame is one fixed step long - if sDragging then - b2MouseSetTarget sDragJoint, b2kToWorldX(b2kCamMouseX()), b2kToWorldY(b2kCamMouseY()) - end if - lock screen - try - b2kSyncBodies - b2kInputTick - b2kPlayerTick - b2kSpritesTick - b2kCamTick - b2kDispatchContacts - b2kDispatchSensors - if sFrameObj is not empty then dispatch "b2kFrame" to sFrameObj - catch tErr - end try - unlock screen -end b2kStepOnce - --- Public, standalone-safe sync: locks the screen and always unlocks, even if a --- draw throws. This is the "the loop is stopped, redraw by hand" entry point --- (editors and build modes call it after spawning or moving things), so it must --- FULL-SCAN: the move-event fast path only reports bodies a b2Step moved, and a --- stopped world steps never — relying on it here would draw nothing at all. --- The running loop instead holds one lock across the whole frame and calls the --- lock-free, move-event-driven b2kSyncBodies directly. -command b2kSync - lock screen - try - b2kSyncAllBodies - catch tErr - end try - unlock screen -end b2kSync - -command b2kSyncAll - lock screen - try - b2kSyncAllBodies - catch tErr - end try - unlock screen -end b2kSyncAll - --- Lock-free body sync (the caller owns the screen lock). Fast path: Box2D already --- gives us the bodies that moved during the last step, so we use that event list --- instead of scanning every tracked control and calling body-is-awake/x/y/angle --- across the FFI for each one. -command b2kSyncBodies - if sNeedFullSync is true then - b2kSyncAllBodies - put false into sNeedFullSync - exit b2kSyncBodies - end if - - local tN, i, tBody, tRef, tWx, tWy, tWa, tDead, tFell - put empty into tDead - put empty into tFell - put b2BodiesUpdate(sWorld) into tN - if tN <= 0 then exit b2kSyncBodies - - repeat with i = 1 to tN - put b2BodyMoveBody(i) into tBody - if tBody <= 0 then next repeat - put sCtrl[tBody] into tRef - if tRef is empty then next repeat - if sStatic[tRef] is true then next repeat - try - put b2BodyMoveX(i) into tWx - put b2BodyMoveY(i) into tWy - -- the kill floor: a mover that fell below the line is queued for - -- removal instead of drawn (the player is the game's to respawn). - -- Zero extra scanning: falling bodies are, by definition, moving. - if sKillY is not empty and tRef is not sPlayRef and b2kToScreenY(tWy) > sKillY then - put tRef & cr after tFell - next repeat - end if - if sRender[tRef] is "poly" or sRender[tRef] is "image" then - put b2BodyMoveAngle(i) into tWa - else - put 0 into tWa - end if - b2kDrawBody tRef, tWx, tWy, tWa - catch tErr - put tRef & cr after tDead -- control vanished; clean up below - end try - end repeat - b2kPruneDeadRefs tDead - repeat for each line tRef in tFell - if tRef is empty then next repeat - if sFrameObj is not empty then dispatch "b2kFell" to sFrameObj with tRef - b2kRemove tRef - end repeat -end b2kSyncBodies - --- Lock-free full scan (the caller owns the screen lock). Comprehensive fallback --- for script-side edits that do not come from a Box2D step event (for example --- changing a body type while paused). -command b2kSyncAllBodies - local tRef, tBody, tWx, tWy, tWa, tDead - put empty into tDead - repeat for each key tRef in sBody - if sStatic[tRef] is true then next repeat - put sBody[tRef] into tBody - if b2BodyIsAwake(tBody) is false then next repeat -- asleep = at rest, skip redraw - try - put b2BodyX(tBody) into tWx - put b2BodyY(tBody) into tWy - if sRender[tRef] is "poly" or sRender[tRef] is "image" then - put b2BodyAngle(tBody) into tWa - else - put 0 into tWa - end if - b2kDrawBody tRef, tWx, tWy, tWa - catch tErr - put tRef & cr after tDead -- control vanished; clean up below - end try - end repeat - b2kPruneDeadRefs tDead -end b2kSyncAllBodies - -command b2kPruneDeadRefs pDead - local d, tBody - repeat for each line d in pDead - if d is not empty then - put sBody[d] into tBody - if tBody is not empty then - b2DestroyBody tBody - delete variable sCtrl[tBody] - end if - -- drop every parallel entry, exactly like b2kRemove, so a vanished - -- control leaves no stale state behind - delete variable sBody[d] - delete variable sShapeH[d] - delete variable sRender[d] - delete variable sVerts[d] - delete variable sRad[d] - delete variable sImgAngle[d] - delete variable sStatic[d] - delete variable sSpawned[d] - delete variable sSensor[d] - delete variable sDrawKey[d] - delete variable sInCam[d] - end if - end repeat -end b2kPruneDeadRefs - -command b2kDrawBody pRef, pWx, pWy, pWa - local tSx, tSy, tKey - switch sRender[pRef] - case "poly" - b2kDrawPoly pRef, pWx, pWy, pWa - break - case "ball" - b2kDrawBall pRef, pWx, pWy - break - case "image" - b2kDrawImage pRef, pWx, pWy, pWa - break - default - put round(b2kToScreenX(pWx)) into tSx - put round(b2kToScreenY(pWy)) into tSy - put tSx & "," & tSy into tKey - if sDrawKey[pRef] is tKey then exit b2kDrawBody - set the loc of pRef to (tSx - b2kCamShiftX(pRef)) & "," & (tSy - b2kCamShiftY(pRef)) - put tKey into sDrawKey[pRef] - end switch -end b2kDrawBody - -command b2kDrawPoly pRef, pWx, pWy, pWa - local c, s, p, pt, tPts, tFirst, tKey - put round(b2kToScreenX(pWx)) & "," & round(b2kToScreenY(pWy)) & "," & round(pWa * 1800 / kPI) into tKey - if sDrawKey[pRef] is tKey then exit b2kDrawPoly - put cos(pWa) into c - put sin(pWa) into s - put empty into tPts - put empty into tFirst - local tOffX, tOffY - put b2kCamShiftX(pRef) into tOffX - put b2kCamShiftY(pRef) into tOffY - repeat for each line p in sVerts[pRef] - put b2kCorner(pWx, pWy, item 1 of p, item 2 of p, c, s) into pt - if tOffX is not 0 or tOffY is not 0 then - put ((item 1 of pt) - tOffX) & "," & ((item 2 of pt) - tOffY) into pt - end if - if tFirst is empty then put pt into tFirst - put pt & cr after tPts - end repeat - put tFirst after tPts - set the points of pRef to tPts - put tKey into sDrawKey[pRef] -end b2kDrawPoly - -command b2kDrawBall pRef, pWx, pWy - local tRp, tSx, tSy, tKey - put sRad[pRef] * sScale into tRp - put round(b2kToScreenX(pWx)) into tSx - put round(b2kToScreenY(pWy)) into tSy - put tSx & "," & tSy & "," & round(tRp) into tKey - if sDrawKey[pRef] is tKey then exit b2kDrawBall - subtract b2kCamShiftX(pRef) from tSx - subtract b2kCamShiftY(pRef) from tSy - set the rect of pRef to round(tSx - tRp), round(tSy - tRp), round(tSx + tRp), round(tSy + tRp) - put tKey into sDrawKey[pRef] -end b2kDrawBall - --- Position + rotate an image control to follow its body. The body angle is in --- radians, CCW-positive in the sim's y-up frame; with this kit's y-flip that is --- also a CCW screen rotation, which matches `the angle` (CCW-positive). We keep --- the image at its design size and skip redundant re-rotations (each set of the --- angle resamples the image, but always from the original, so there's no drift). -command b2kDrawImage pRef, pWx, pWy, pWa - local tDeg, tSx, tSy, tKey - put ((round(pWa * 180 / kPI) mod 360) + 360) mod 360 into tDeg - put round(b2kToScreenX(pWx)) into tSx - put round(b2kToScreenY(pWy)) into tSy - put tSx & "," & tSy & "," & tDeg into tKey - if sDrawKey[pRef] is tKey then exit b2kDrawImage - if sImgAngle[pRef] is not tDeg then - set the angle of pRef to tDeg - put tDeg into sImgAngle[pRef] - end if - set the loc of pRef to (tSx - b2kCamShiftX(pRef)) & "," & (tSy - b2kCamShiftY(pRef)) - put tKey into sDrawKey[pRef] -end b2kDrawImage - -function b2kCorner pWx, pWy, pLx, pLy, c, s - local rx, ry - put pLx * c - pLy * s into rx - put pLx * s + pLy * c into ry - return round(b2kToScreenX(pWx + rx)) & "," & round(b2kToScreenY(pWy + ry)) -end b2kCorner - --- Local-space outline (metres) approximating a capsule of half-length pL and --- radius pR: two half-circle caps joined by straight edges. Built along local X --- and transposed to local Y when the capsule is vertical. Used only to draw the --- graphic; the collision shape is a true Box2D capsule. -function b2kCapsuleVerts pL, pR, pHoriz - local tV, i, a, lx, ly - local n - put 8 into n - put empty into tV - repeat with i = 0 to n -- right cap: -90 -> +90 degrees - put (-90 + 180 * i / n) * kPI / 180 into a - put pL + pR * cos(a) into lx - put pR * sin(a) into ly - if pHoriz then put lx & "," & ly & cr after tV - else put ly & "," & lx & cr after tV - end repeat - repeat with i = 0 to n -- left cap: +90 -> +270 degrees - put (90 + 180 * i / n) * kPI / 180 into a - put - pL + pR * cos(a) into lx - put pR * sin(a) into ly - if pHoriz then put lx & "," & ly & cr after tV - else put ly & "," & lx & cr after tV - end repeat - return tV -end b2kCapsuleVerts - --- Internal: zero the frame's event buffers (the loop calls this before --- it steps; the buffers then accumulate one harvest per fixed step). -command b2kEventsReset - put 0 into sEvtCN - put 0 into sEvtEN - put 0 into sEvtSN - put 0 into sEvtXN -end b2kEventsReset - --- Internal: pull THIS step's Box2D events into the frame buffers. Box2D --- keeps only the latest step's events, so this must run after every --- b2Step -- the dispatchers and pollers then see the whole frame. -command b2kHarvestEvents - local tN, i - put b2ContactsUpdate(sWorld) into tN - repeat with i = 1 to tN - add 1 to sEvtCN - put b2ContactBeginBodyA(i) into sEvtCA[sEvtCN] - put b2ContactBeginBodyB(i) into sEvtCB[sEvtCN] - end repeat - repeat with i = 1 to b2ContactEndCount() - add 1 to sEvtEN - put b2ContactEndBodyA(i) into sEvtEA[sEvtEN] - put b2ContactEndBodyB(i) into sEvtEB[sEvtEN] - end repeat - put b2SensorsUpdate(sWorld) into tN - repeat with i = 1 to tN - add 1 to sEvtSN - put b2ShapeBody(b2SensorBeginSensorShape(i)) into sEvtSS[sEvtSN] - put b2ShapeBody(b2SensorBeginVisitorShape(i)) into sEvtSV[sEvtSN] - end repeat - repeat with i = 1 to b2SensorEndCount() - add 1 to sEvtXN - put b2ShapeBody(b2SensorEndSensorShape(i)) into sEvtXS[sEvtXN] - put b2ShapeBody(b2SensorEndVisitorShape(i)) into sEvtXV[sEvtXN] - end repeat -end b2kHarvestEvents - -command b2kDispatchContacts - local i, tA, tB - if sContactObj is empty then exit b2kDispatchContacts - repeat with i = 1 to sEvtCN - put sCtrl[sEvtCA[i]] into tA - put sCtrl[sEvtCB[i]] into tB - dispatch "b2kContact" to sContactObj with tA, tB - end repeat - repeat with i = 1 to sEvtEN - put sCtrl[sEvtEA[i]] into tA - put sCtrl[sEvtEB[i]] into tB - dispatch "b2kEndContact" to sContactObj with tA, tB - end repeat -end b2kDispatchContacts - --- Deliver the frame's buffered sensor begin/end overlaps (if a target is --- set) as on b2kSensorEnter / on b2kSensorExit, in step order. -command b2kDispatchSensors - local i, tS, tV - if sContactObj is empty then exit b2kDispatchSensors - repeat with i = 1 to sEvtSN - put sCtrl[sEvtSS[i]] into tS - put sCtrl[sEvtSV[i]] into tV - dispatch "b2kSensorEnter" to sContactObj with tS, tV - end repeat - repeat with i = 1 to sEvtXN - put sCtrl[sEvtXS[i]] into tS - put sCtrl[sEvtXV[i]] into tV - dispatch "b2kSensorExit" to sContactObj with tS, tV - end repeat -end b2kDispatchSensors --- <<< END EMBEDDED KIT <<< diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 5f5dc80..90c9e79 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -3859,16 +3859,6 @@ on rawKeyDown pKeyCode b2kSoundMute (not b2kSoundMuted()) exit rawKeyDown end if - -- >>> DEBUG WARP (Wave 4 swim testing) -- delete this block before merge. - -- "0" drops the hero onto the L1 hilltop pool's left bank so the swim - -- is reachable without replaying the level. No-op on other levels. - if pKeyCode is 48 and gHero is not empty and gLevel is 1 then - b2kMoveTo gHero, 7620, 400 - b2kSetVelocity gHero, 0, 0 - b2kCamGoto 7620, 400 - exit rawKeyDown - end if - -- <<< END DEBUG WARP pass rawKeyDown end rawKeyDown diff --git a/tools/sync-embedded-kit.py b/tools/sync-embedded-kit.py index 0d461b8..60dbcb7 100755 --- a/tools/sync-embedded-kit.py +++ b/tools/sync-embedded-kit.py @@ -29,7 +29,6 @@ ROOT / "examples" / "box2dxt-contraption-builder.livecodescript", ROOT / "examples" / "box2dxt-spike-gamekit.livecodescript", ROOT / "examples" / "box2dxt-platformer.livecodescript", - ROOT / "examples" / "box2dxt-microgame.livecodescript", ROOT / "examples" / "box2dxt-selftest.livecodescript", ROOT / "examples" / "box2dxt-slingshot.livecodescript", ] From 93271f3f3bb43217e6d1dfefa4dc59de59f7cdd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 02:45:22 +0000 Subject: [PATCH 02/19] Kit Wave 5: wall-jump, dash, double-jump, duck-reshape, platform-carry New player-controller actions, each OPT-IN through a knob whose default leaves the pre-Wave-5 controller byte-for-byte unchanged, and each idle path costs one compare per frame: - airJumps: native double/multi air-jump (refilled on landing). - wallJumpX/Y + wallSlideMax: a side ray detects a wall while airborne; hugging it caps the fall (wallslide state), JUMP launches up-and-away with a brief steer lock. - dashSpeed/dashMs/dashCooldownMs: a flat horizontal burst (gravity parked) on the new dash action (bound to shift/x); yields to climb/swim; cooldown-gated. New dash state. - duckScale (<1): DOWN reshapes the capsule to a feet-anchored crawl (b2kReshape) with a headroom check before standing, so you can slip under low gaps; duckScale 1 keeps the Wave 2 brake-duck. - platformCarry: a grounded player inherits its platform's velocity (rides a moving/kinematic platform; vertical lift exempts ground-snap). Plus the pure-win helpers that remove example boilerplate and serve gotcha 28: b2kPlayerHalfH/HalfW (live capsule extents), b2kPlayerInLadder/ InWater (this frame's zone membership), and b2kPlayerRespawn (teleport + zero velocity + clean state). b2kPlayerAnims gains pWall/pDash slots. Self-test harness -> v13: six new hand-stepped tests (double-jump, wall-slide/jump, dash+cooldown, platform-carry, duck-reshape, the getters+respawn). Re-synced into every embedded copy. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 418 +++++++++++- examples/box2dxt-demo.livecodescript | 418 +++++++++++- examples/box2dxt-platformer.livecodescript | 418 +++++++++++- examples/box2dxt-selftest.livecodescript | 620 +++++++++++++++++- examples/box2dxt-slingshot.livecodescript | 418 +++++++++++- examples/box2dxt-spike-gamekit.livecodescript | 418 +++++++++++- src/box2dxt-kit.livecodescript | 418 +++++++++++- 7 files changed, 2938 insertions(+), 190 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index 3f40fed..2dd49b8 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -281,6 +281,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -2019,6 +2043,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -3168,6 +3193,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -3197,6 +3223,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -3226,7 +3266,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -3254,6 +3299,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -3312,6 +3368,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -3324,8 +3402,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -3355,6 +3434,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -3373,9 +3462,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -3385,6 +3476,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -3454,6 +3568,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -3501,10 +3616,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -3522,6 +3677,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -3551,6 +3709,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -3578,6 +3751,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -3603,6 +3777,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -3674,6 +3849,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -3682,6 +3944,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -3698,6 +3961,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -3725,6 +3991,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -3760,6 +4027,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -3833,25 +4127,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -3866,14 +4194,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -3883,6 +4235,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -3908,7 +4261,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -3953,11 +4306,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -3971,6 +4328,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -3989,7 +4352,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 56a10f8..3c83de4 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -271,6 +271,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -2009,6 +2033,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -3158,6 +3183,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -3187,6 +3213,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -3216,7 +3256,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -3244,6 +3289,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -3302,6 +3358,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -3314,8 +3392,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -3345,6 +3424,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -3363,9 +3452,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -3375,6 +3466,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -3444,6 +3558,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -3491,10 +3606,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -3512,6 +3667,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -3541,6 +3699,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -3568,6 +3741,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -3593,6 +3767,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -3664,6 +3839,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -3672,6 +3934,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -3688,6 +3951,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -3715,6 +3981,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -3750,6 +4017,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -3823,25 +4117,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -3856,14 +4184,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -3873,6 +4225,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -3898,7 +4251,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -3943,11 +4296,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -3961,6 +4318,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -3979,7 +4342,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 90c9e79..deb730f 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -4114,6 +4114,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -5852,6 +5876,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -7001,6 +7026,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -7030,6 +7056,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -7059,7 +7099,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -7087,6 +7132,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -7145,6 +7201,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -7157,8 +7235,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -7188,6 +7267,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -7206,9 +7295,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -7218,6 +7309,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -7287,6 +7401,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -7334,10 +7449,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -7355,6 +7510,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -7384,6 +7542,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -7411,6 +7584,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -7436,6 +7610,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -7507,6 +7682,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -7515,6 +7777,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -7531,6 +7794,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -7558,6 +7824,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -7593,6 +7860,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -7666,25 +7960,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -7699,14 +8027,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -7716,6 +8068,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -7741,7 +8094,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -7786,11 +8139,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -7804,6 +8161,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -7822,7 +8185,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index 8a53957..f8dea50 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "12" -- bump on EVERY harness change: the report +constant kStHarnessV = "13" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -135,6 +135,12 @@ command stRunAll stRun "stTestSwim" stRun "stTestSwimGrounded" stRun "stTestSwimClear" + stRun "stTestDoubleJump" + stRun "stTestWallJump" + stRun "stTestDash" + stRun "stTestPlatformCarry" + stRun "stTestDuckReshape" + stRun "stTestPlayerHelpers" stRun "stTestTeardown" try b2kInputInjectOff @@ -1188,6 +1194,200 @@ command stTestHurtKnockback (b2kPlayerState() is "hurt") end stTestHurtKnockback +-- Wave 5: double-jump (airJumps). A ground jump + one mid-air jump, then +-- the budget is spent until the next landing. +command stTestDoubleJump + local tRef, i, tV1, tV2, tV3 + stNewWorld "player: double-jump (airJumps = 1)" + stSlab "st_ground", 50, 500, 800, 560 + b2kPlayerMake 300, 470, 32, 48 + put the result into tRef + b2kPlayerSet "airJumps", 1 + stStep 30 + b2kInputInject "space" + stStep 2 + b2kInputInject "" + put round(stVY(tRef)) into tV1 + stAssert "ground jump launched up (vy " & tV1 & ")", (tV1 < -250) + -- rise to the apex and begin to fall + repeat with i = 1 to 120 + b2kStepOnce + if stVY(tRef) > 60 then exit repeat + end repeat + -- AIR-JUMP: a second press while falling re-launches up + b2kInputInject "space" + stStep 2 + b2kInputInject "" + put round(stVY(tRef)) into tV2 + stAssert "double-jump re-launched mid-fall (vy " & tV2 & ")", (tV2 < -250) + -- the budget is spent: fall again, a THIRD press does nothing + repeat with i = 1 to 120 + b2kStepOnce + if stVY(tRef) > 60 then exit repeat + end repeat + b2kInputInject "space" + stStep 2 + b2kInputInject "" + put round(stVY(tRef)) into tV3 + stAssert "no third jump - air budget spent (vy " & tV3 & " still falling)", (tV3 > 0) +end stTestDoubleJump + +-- Wave 5: wall-slide caps the fall against a wall; wall-jump launches up +-- and AWAY. The player hugs a vertical wall, holding into it while falling. +command stTestWallJump + local tRef, i, tVcap, tVup, tVx + stNewWorld "player: wall-slide + wall-jump" + stSlab "st_ground", 50, 520, 800, 560 + stSlab "st_wall", 400, 80, 440, 520 -- a vertical wall (left face x400) + b2kPlayerMake 380, 300, 32, 48 -- just left of the wall, airborne + put the result into tRef + b2kPlayerSet "wallJumpX", 240 + b2kPlayerSet "wallJumpY", 380 + b2kPlayerSet "wallSlideMax", 90 + stStep 1 + b2kSetVelocity tRef, 0, 400 -- a fast fall, to show the cap + b2kInputInject "right" -- press INTO the wall + put 0 into tVcap + repeat with i = 1 to 40 + b2kStepOnce + if b2kPlayerState() is "wallslide" then + put round(stVY(tRef)) into tVcap + exit repeat + end if + end repeat + stAssert "wall-slide engaged + capped the fall (vy " & tVcap & " in 1..100)", \ + (tVcap > 0 and tVcap <= 100) + -- WALL-JUMP: jump while sliding -> up and away (to the LEFT) + b2kInputInject "right,space" + stStep 2 + b2kInputInject "" + put round(stVY(tRef)) into tVup + put round(stVX(tRef)) into tVx + stAssert "wall-jump launched up (vy " & tVup & ")", (tVup < -200) + stAssert "wall-jump pushed away from the wall (vx " & tVx & " < -80)", (tVx < -80) +end stTestWallJump + +-- Wave 5: dash is a flat horizontal burst (dashSpeed) for dashMs, gated +-- by a cooldown so it cannot re-fire immediately. +command stTestDash + local tRef, i, tVmax, tState, tV2 + stNewWorld "player: dash burst + cooldown" + stSlab "st_ground", 50, 500, 1200, 560 + b2kPlayerMake 200, 470, 32, 48 + put the result into tRef + b2kPlayerSet "dashSpeed", 520 + b2kPlayerSet "dashMs", 160 + b2kPlayerSet "dashCooldownMs", 500 + b2kInputInject "right" + stStep 6 -- face right, then release the direction + b2kInputInject "" + stStep 4 + b2kInputInject "x" + stStep 1 + put round(stVX(tRef)) into tVmax + put b2kPlayerState() into tState + b2kInputInject "" + stAssert "dash burst hit dash speed (vx " & tVmax & " > 450)", (tVmax > 450) + stAssert "dash state reported (got " & tState & ")", (tState is "dash") + stStep 20 -- run the dash out (dashMs) and decelerate + b2kInputInject "x" + stStep 1 + put round(stVX(tRef)) into tV2 + b2kInputInject "" + stAssert "cooldown blocks an immediate re-dash (vx " & tV2 & " < 300)", (tV2 < 300) +end stTestDash + +-- Wave 5: a player standing on a horizontally-moving kinematic platform +-- inherits its velocity (platformCarry, default ON). +command stTestPlatformCarry + local tRef, tPlat, i, tX0, tX1, tDX + stNewWorld "player: moving-platform carry" + b2kSpawnBox 300, 400, 200, 30, "gray" + put the result into tPlat + b2kSetKinematic tPlat + b2kSetVelocity tPlat, 80, 0 -- drift right at 80 px/s + b2kPlayerMake 300, 360, 32, 48 -- settle onto the platform + put the result into tRef + b2kPlayerSet "platformCarry", 1 -- opt in (default off) + stStep 30 + put stX(tRef) into tX0 + stStep 60 + put stX(tRef) into tX1 + put tX1 - tX0 into tDX + -- 60 frames at 80 px/s = ~80px; a carried player tracks the platform + stAssert "player rode the platform right (~80px, got " & round(tDX) & ")", \ + (tDX > 55 and tDX < 105) +end stTestPlatformCarry + +-- Wave 5: duck capsule reshape (duckScale < 1). The capsule shrinks for a +-- crawl, stand-up is blocked under a low ceiling, and clears once past it. +command stTestDuckReshape + local tRef, tFullH, tDuckH, i + stNewWorld "player: duck capsule reshape (crawl)" + stSlab "st_ground", 50, 500, 900, 560 + stSlab "st_ceil", 300, 400, 600, 462 -- a low ceiling (underside y462) + b2kPlayerMake 200, 470, 32, 48 + put the result into tRef + b2kPlayerSet "duckScale", 0.5 + put round(b2kPlayerHalfH()) into tFullH + stStep 20 + b2kInputInject "down" + stStep 5 + put round(b2kPlayerHalfH()) into tDuckH + stAssert "duck shrank the capsule (" & tFullH & " -> " & tDuckH & ")", (tDuckH < tFullH) + stAssert "ducked state reported (got " & b2kPlayerState() & ")", (b2kPlayerState() is "duck") + -- crawl RIGHT until well under the ceiling + b2kInputInject "down,right" + repeat with i = 1 to 120 + b2kStepOnce + if stX(tRef) > 360 then exit repeat + end repeat + stAssert "crawled under the low ceiling (x " & round(stX(tRef)) & " > 350)", (stX(tRef) > 350) + -- release DOWN under the ceiling: the headroom check keeps it crouched + b2kInputInject "right" + stStep 6 + stAssert "stand-up blocked under the ceiling (halfH " & round(b2kPlayerHalfH()) & ")", \ + (round(b2kPlayerHalfH()) is tDuckH) + -- crawl out PAST the ceiling, then it can stand + b2kInputInject "down,right" + repeat with i = 1 to 160 + b2kStepOnce + if stX(tRef) > 660 then exit repeat + end repeat + b2kInputInject "" + stStep 12 + stAssert "stood back up past the ceiling (halfH " & round(b2kPlayerHalfH()) & ")", \ + (round(b2kPlayerHalfH()) >= tFullH) +end stTestDuckReshape + +-- Wave 5: the pure-win getters (half-extents, zone queries) + the respawn +-- primitive (teleport + zero velocity + clean state). +command stTestPlayerHelpers + local tRef + stNewWorld "player: half-extents, zone queries, respawn" + stSlab "st_ground", 50, 500, 900, 560 + b2kPlayerMake 200, 470, 30, 50 + put the result into tRef + stStep 8 + stAssert "halfW getter (got " & round(b2kPlayerHalfW()) & " ~ 15)", (round(b2kPlayerHalfW()) is 15) + stAssert "halfH getter (got " & round(b2kPlayerHalfH()) & " ~ 25)", (round(b2kPlayerHalfH()) is 25) + stAssert "not in a ladder/water zone yet", \ + (b2kPlayerInLadder() is false and b2kPlayerInWater() is false) + b2kPlayerAddWater 100, 300, 400, 560 + b2kPlayerAddLadder 600, 300, 660, 560 + stStep 2 + stAssert "in the water zone now", (b2kPlayerInWater() is true) + -- RESPAWN to the ladder spot: velocity zeroed, the zone queries follow + b2kSetVelocity tRef, 200, -200 + b2kPlayerRespawn 630, 470 + stStep 2 + stAssert "respawn moved the player (x " & round(stX(tRef)) & " ~ 630)", (abs(stX(tRef) - 630) < 30) + stAssert "respawn in the ladder zone, not water", \ + (b2kPlayerInLadder() is true and b2kPlayerInWater() is false) + stAssert "respawn zeroed the horizontal speed (vx " & round(stVX(tRef)) & ")", \ + (abs(stVX(tRef)) < 60) +end stTestPlayerHelpers + -- ===================================================================== -- The Kit (embedded verbatim; regenerated by tools/sync-embedded-kit.py -- - do not edit between the sentinels) @@ -1440,6 +1640,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -3178,6 +3402,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -4327,6 +4552,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -4356,6 +4582,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -4385,7 +4625,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -4413,6 +4658,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -4471,6 +4727,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -4483,8 +4761,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -4514,6 +4793,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -4532,9 +4821,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -4544,6 +4835,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -4613,6 +4927,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -4660,10 +4975,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -4681,6 +5036,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -4710,6 +5068,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -4737,6 +5110,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -4762,6 +5136,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -4833,6 +5208,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -4841,6 +5303,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -4857,6 +5320,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -4884,6 +5350,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -4919,6 +5386,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -4992,25 +5486,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -5025,14 +5553,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -5042,6 +5594,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -5067,7 +5620,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -5112,11 +5665,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -5130,6 +5687,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -5148,7 +5711,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index a809a47..26811c9 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -1028,6 +1028,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -2766,6 +2790,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -3915,6 +3940,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -3944,6 +3970,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -3973,7 +4013,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -4001,6 +4046,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -4059,6 +4115,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -4071,8 +4149,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -4102,6 +4181,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -4120,9 +4209,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -4132,6 +4223,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -4201,6 +4315,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -4248,10 +4363,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -4269,6 +4424,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -4298,6 +4456,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -4325,6 +4498,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -4350,6 +4524,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -4421,6 +4596,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -4429,6 +4691,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -4445,6 +4708,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -4472,6 +4738,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -4507,6 +4774,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -4580,25 +4874,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -4613,14 +4941,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -4630,6 +4982,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -4655,7 +5008,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -4700,11 +5053,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -4718,6 +5075,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -4736,7 +5099,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 8cc9873..7e1469d 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -1608,6 +1608,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -3346,6 +3370,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -4495,6 +4520,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -4524,6 +4550,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -4553,7 +4593,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -4581,6 +4626,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -4639,6 +4695,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -4651,8 +4729,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -4682,6 +4761,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -4700,9 +4789,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -4712,6 +4803,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -4781,6 +4895,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -4828,10 +4943,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -4849,6 +5004,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -4878,6 +5036,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -4905,6 +5078,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -4930,6 +5104,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -5001,6 +5176,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -5009,6 +5271,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -5025,6 +5288,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -5052,6 +5318,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -5087,6 +5354,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -5160,25 +5454,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -5193,14 +5521,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -5210,6 +5562,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -5235,7 +5588,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -5280,11 +5633,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -5298,6 +5655,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -5316,7 +5679,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 0dd4d4e..f8470e4 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -245,6 +245,30 @@ local sPlayClock -- the player's SIM-TIME clock: summed frame ms. -- on slow machines (90ms = fewer frames); sim time -- keeps them frame-coherent everywhere and makes -- hand-stepped tests deterministic. +-- Wave 5 actions: double-jump, wall-slide/jump, dash, duck capsule reshape, +-- moving-platform carry. Each is OPT-IN through a knob whose default leaves +-- the pre-Wave-5 controller byte-for-byte unchanged, and each idle path +-- costs ONE compare per frame (the wall side-probe casts only while the +-- system is on AND airborne; carry reads a velocity only while grounded). +local sPlayAirJumps -- extra mid-air jumps allowed (airJumps knob; 0 = none) +local sPlayAirJumpsLeft -- air jumps remaining (reset to sPlayAirJumps on ground) +local sPlayWallOn -- the wall system is armed (wallJumpX or wallSlideMax > 0) +local sPlayWallJumpX, sPlayWallJumpY, sPlayWallSlideMax -- wall tune caches +local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 none +local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) +local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) +local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayDash -- true while a dash is in flight (gravity parked at 0) +local sPlayDashEnd -- sim-clock the dash ends +local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) +local sPlayDashDir -- dash direction (the facing captured at dash start) +local sPlayDashGravSave -- the body's gravity scale to restore when the dash ends +local sPlayDuckScale -- ducked capsule height as a fraction of standing (1 = no reshape) +local sPlayDucked -- true while the capsule is shrunk for a crawl +local sPlayStandH -- the full standing capsule height in px (for the duck reshape) +local sPlayCarry -- true = inherit a moving platform's velocity (platformCarry knob) +local sPlayGroundBody -- the body handle under the grounding ray (carry reads its velocity) +local sPlayInLad, sPlayInWat -- this frame's ladder/water zone membership (exposed by getters) local sSndClip -- sound name -> audioClip short name ("b2ksnd_...") local sSndMute -- true = swallow play calls (a user preference; survives teardown) local sSndDead -- true after a play failure: degrade to silence, never errors @@ -1983,6 +2007,7 @@ command b2kInputOn put comma into sKeysPrev -- starter bindings; rebind freely (these only fill empty slots) if sKeyActions["jump"] is empty then b2kBindAction "jump", "space" + if sKeyActions["dash"] is empty then b2kBindAction "dash", "shift,x" if sAxisNeg["moveX"] is empty then b2kBindAxis "moveX", "left,a", "right,d" if sAxisNeg["moveY"] is empty then b2kBindAxis "moveY", "up,w", "down,s" end b2kInputOn @@ -3132,6 +3157,7 @@ command b2kPlayerAttach pCtrl b2kSetSleepEnabled tRef, false -- a player must always respond put (the width of tRef) / 2 into sPlayHalfW put (the height of tRef) / 2 into sPlayHalfH + put (the height of tRef) into sPlayStandH -- full height, for the duck reshape put "idle" into sPlayState put 1 into sPlayFacing put false into sPlayGrounded @@ -3161,6 +3187,20 @@ command b2kPlayerAttach pCtrl put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state starts clean (the knobs decide whether each is reachable) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat if sPlayLadN is empty then put 0 into sPlayLadN b2kPlayerTuneCache -- bake the knobs + probe geometry for the tick b2kPlayerResolveArt @@ -3190,7 +3230,12 @@ end b2kPlayerResolveArt -- dropMs (drop-through window), climbSpeed (ladder px/s), swimSpeed/ -- swimJump (water px/s + stroke), swimGravity/swimMaxFall (buoyancy), -- hurtPopX/hurtPopY (knockback launch px/s), hurtMs (control-off span), --- invulnMs (post-hurt mercy). Settable any time, before or after the +-- invulnMs (post-hurt mercy). Wave 5, all OPT-IN (default = off): airJumps +-- (extra mid-air jumps; 1 = double-jump), wallJumpX/wallJumpY (wall-jump +-- launch px/s) + wallSlideMax (capped slide fall px/s), dashSpeed/dashMs/ +-- dashCooldownMs (the dash; bind the "dash" action), duckScale (ducked +-- capsule height as a fraction of standing, <1 to crawl), platformCarry +-- (1 = ride moving platforms). Settable any time, before or after the -- player exists; unknown keys are stored verbatim for your own use. command b2kPlayerSet pKey, pValue put pValue into sPlayTune[toLower(pKey)] @@ -3218,6 +3263,17 @@ command b2kPlayerTuneCache put b2kPlayerGet("hurtPopY") into sPlayHurtPopY put b2kPlayerGet("hurtMs") into sPlayHurtMS put b2kPlayerGet("invulnMs") into sPlayInvulnMS + -- Wave 5 knob caches + the one-compare gates the tick reads + put b2kPlayerGet("airJumps") into sPlayAirJumps + put b2kPlayerGet("wallJumpX") into sPlayWallJumpX + put b2kPlayerGet("wallJumpY") into sPlayWallJumpY + put b2kPlayerGet("wallSlideMax") into sPlayWallSlideMax + put (sPlayWallJumpX > 0 or sPlayWallSlideMax > 0) into sPlayWallOn + put b2kPlayerGet("dashSpeed") into sPlayDashSpd + put b2kPlayerGet("dashMs") into sPlayDashMS + put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put b2kPlayerGet("duckScale") into sPlayDuckScale + put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope if sPlayHalfW is not empty and sPlayHalfW > 0 then put sPlayHalfH + 4 into sPlayReach @@ -3276,6 +3332,28 @@ function b2kPlayerDefault pKey return 700 case "invulnms" return 900 + -- Wave 5 (all OPT-IN: these defaults disable the feature, so an + -- untouched controller behaves exactly as it did before Wave 5) + case "airjumps" + return 0 -- extra mid-air jumps (1 = double-jump) + case "walljumpx" + return 0 -- away-from-wall launch px/s (0 = wall system off) + case "walljumpy" + return 0 -- up launch off a wall (0 = fall back to jumpSpeed) + case "wallslidemax" + return 0 -- capped fall px/s while hugging a wall (0 = no slide) + case "dashspeed" + return 0 -- dash px/s (0 = dash off) + case "dashms" + return 160 -- dash duration + case "dashcooldownms" + return 500 -- minimum gap between dashes + case "duckscale" + return 1 -- ducked capsule height / standing height (1 = no reshape) + case "platformcarry" + return 0 -- 1 = inherit a moving platform's velocity (opt-in: it + -- costs 2 reads/grounded-frame and changes how a player + -- rides any kinematic body), 0 = off end switch return empty end b2kPlayerDefault @@ -3288,8 +3366,9 @@ end b2kPlayerDefault -- Wave 2 slots (all optional, so old five-argument calls keep working): -- pDuck falls back to the idle pose, pClimb and pHurt to the jump pose; -- the Wave 4 pSwim falls back to the fall pose -- sheets without those --- frames still read correctly. -command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim +-- frames still read correctly. Wave 5 slots: pWall (wall-slide) falls back +-- to the fall pose, pDash falls back to the run pose. +command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, pSwim, pWall, pDash put pIdle into sPlayAnims["idle"] put pRun into sPlayAnims["run"] put pJump into sPlayAnims["jump"] @@ -3319,6 +3398,16 @@ command b2kPlayerAnims pIdle, pRun, pJump, pFall, pLand, pDuck, pClimb, pHurt, p else put pSwim into sPlayAnims["swim"] end if + if pWall is empty then + put sPlayAnims["fall"] into sPlayAnims["wallslide"] + else + put pWall into sPlayAnims["wallslide"] + end if + if pDash is empty then + put sPlayAnims["run"] into sPlayAnims["dash"] + else + put pDash into sPlayAnims["dash"] + end if put empty into sPlayAnimNow -- re-assert on the next tick if sPlayArt is empty then b2kPlayerResolveArt end b2kPlayerAnims @@ -3337,9 +3426,11 @@ function b2kPlayerOnGround return (sPlayGrounded is true) end b2kPlayerOnGround --- idle | run | jump | fall | duck | climb | hurt | swim, plus "land" for --- exactly one frame on touch-down from jump/fall (dust puffs, landing --- sounds). A drop-through renders as "fall". Empty = no player. +-- idle | run | jump | fall | duck | climb | hurt | swim | wallslide | dash, +-- plus "land" for exactly one frame on touch-down from jump/fall (dust +-- puffs, landing sounds). A drop-through renders as "fall". The Wave 5 +-- states (wallslide, dash) only appear when their knobs are enabled. +-- Empty = no player. function b2kPlayerState return sPlayState end b2kPlayerState @@ -3349,6 +3440,29 @@ function b2kPlayerFacing return 1 end b2kPlayerFacing +-- The capsule's CURRENT half-extents in px (the half-height drops while +-- the player is in a reshaped duck/crawl). Head-reach logic should read +-- these live rather than bake a constant (gotcha 28: a hitbox taller than +-- the visible art bumps things the head never touches). +function b2kPlayerHalfH + return b2kNumberOr(sPlayHalfH, 0) +end b2kPlayerHalfH + +function b2kPlayerHalfW + return b2kNumberOr(sPlayHalfW, 0) +end b2kPlayerHalfW + +-- This frame's ladder / water zone membership (the controller computes +-- these every tick anyway -- read them for "press UP to climb" prompts, +-- splash effects, breath meters, without recomputing the rects yourself). +function b2kPlayerInLadder + return (sPlayInLad is true) +end b2kPlayerInLadder + +function b2kPlayerInWater + return (sPlayInWat is true) +end b2kPlayerInWater + -- Programmatic jump (springs, double-jump powerups): the same launch as a -- pressed jump but WITHOUT the grounded/coyote gate -- the caller decides -- when it is allowed. Uses the jumpSpeed knob unless given a speed. @@ -3418,6 +3532,7 @@ command b2kPlayerHurt pFromX if sPlayControl is not true then exit b2kPlayerHurt -- a cutscene owns the body if sPlayClimb is true then b2kPlayerClimbEnd sBody[sPlayRef] if sPlaySwim is true then b2kPlayerSwimEnd sBody[sPlayRef] + if sPlayDash is true then b2kPlayerDashEnd sBody[sPlayRef] if pFromX is a number then if pFromX > sPlayPX then put -1 into tDir @@ -3465,10 +3580,50 @@ command b2kPlayerControl pFlag put false into sPlayHurt put 0 into sPlayHurtLand end if + -- a dash parks gravity at 0 and is ended only by its own tick, which + -- stops running when control goes off -- so end it here or the parked + -- gravity (and the held vy) leak into the cutscene. + if sPlayControl is not true and sPlayDash is true \ + and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if -- returning control must re-assert the state anim over any manual pose if sPlayControl then put empty into sPlayAnimNow end b2kPlayerControl +-- Teleport the player to a screen-px point and reset it to a clean +-- standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck +-- state cleared; the air and air-jump budgets refreshed. This is the +-- respawn most games hand-roll (b2kMoveTo + b2kSetVelocity + clearing a +-- pile of flags) in one call. Tuning and zones are kept (world/config +-- state). Empty pX/pY reuse the current centre (an in-place reset). +command b2kPlayerRespawn pX, pY + local tB + if sPlayRef is empty or sBody[sPlayRef] is empty then exit b2kPlayerRespawn + put sBody[sPlayRef] into tB + if sPlayClimb is true then b2kPlayerClimbEnd tB + if sPlaySwim is true then b2kPlayerSwimEnd tB + if sPlayDash is true then b2kPlayerDashEnd tB + if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore + if sPlayDucked is true then b2kPlayerStandUp + put b2kNumberOr(pX, sPlayPX) into pX + put b2kNumberOr(pY, sPlayPY) into pY + b2kMoveTo sPlayRef, pX, pY + b2kSetVelocity sPlayRef, 0, 0 + put true into sPlayControl + put false into sPlayJumping + put false into sPlayHurt + put 0 into sPlayHurtLand + put 0 into sPlayInvulnUntil + put false into sPlayGrounded + put 0 into sPlayAir + put 0 into sPlayWallLockUntil + put false into sPlayWallSliding + put sPlayAirJumps into sPlayAirJumpsLeft + put "idle" into sPlayState + put empty into sPlayAnimNow -- re-assert the idle pose next tick +end b2kPlayerRespawn + -- Tear down the controller, tuning included. The body and sprite remain -- yours: remove them with b2kRemove / b2kSpriteRemove as usual. command b2kPlayerRemove @@ -3486,6 +3641,9 @@ command b2kPlayerForget pFull if sPlaySwim is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then b2kPlayerSwimEnd sBody[sPlayRef] end if + if sPlayDash is true and sPlayRef is not empty and sBody[sPlayRef] is not empty then + b2kPlayerDashEnd sBody[sPlayRef] + end if if sPlayDropUntil is not empty and sPlayDropUntil > 0 then b2kPlayerDropRestore put empty into sPlayRef put empty into sPlayArt @@ -3515,6 +3673,21 @@ command b2kPlayerForget pFull put 0 into sPlayHurtHalf put 0 into sPlayHurtLand put 0 into sPlayInvulnUntil + -- Wave 5 state (the body keeps any reshaped duck size -- it is yours + -- now; b2kClear removes it, teardown removes everything) + put 0 into sPlayAirJumpsLeft + put 0 into sPlayWall + put false into sPlayWallSliding + put 0 into sPlayWallLockUntil + put false into sPlayDash + put 0 into sPlayDashEnd + put 0 into sPlayDashReady + put 0 into sPlayDashDir + put empty into sPlayDashGravSave + put false into sPlayDucked + put empty into sPlayGroundBody + put false into sPlayInLad + put false into sPlayInWat put 0 into sPlayLadN put empty into sPlayLadL put empty into sPlayLadT @@ -3542,6 +3715,7 @@ command b2kPlayerProbe pBody, pVY put false into sPlayGrounded put false into sPlayOnOneWay put false into sPlayDropSeen + put empty into sPlayGroundBody set the itemDelimiter to comma -- raw reads with the caller's body handle: the probe runs every -- frame, so it skips the ref->body lookup and the "x,y" string pack. @@ -3567,6 +3741,7 @@ command b2kPlayerProbe pBody, pVY put true into sPlayOnOneWay -- standing on a chain: drop eligible end if put true into sPlayGrounded + put sRayBodyH into sPlayGroundBody -- the platform under us (carry) put sRayNX into sPlayNormX -- flat vs slope, for ground-snap put sPlayClock into sPlayGroundMS -- the sim clock, not wall time exit b2kPlayerProbe @@ -3638,6 +3813,93 @@ command b2kPlayerDropRestore put empty into sPlayDropMask end b2kPlayerDropRestore +-- Internal (Wave 5): enter the dash -- gravity parks at 0 (saved/restored +-- like the climb) so the burst is a flat horizontal zip; the tick holds vx +-- at dashSpeed for dashMs, then b2kPlayerDashEnd restores gravity. The +-- cooldown (dashReady) gates the next start. +command b2kPlayerDashStart pBody + if sPlayDash is true then exit b2kPlayerDashStart + put b2BodyGravityScale(pBody) into sPlayDashGravSave + b2SetGravityScale pBody, 0 + put true into sPlayDash + put sPlayFacing into sPlayDashDir + put sPlayClock + sPlayDashMS into sPlayDashEnd + put sPlayClock + sPlayDashMS + sPlayDashCool into sPlayDashReady + put false into sPlayJumping +end b2kPlayerDashStart + +command b2kPlayerDashEnd pBody + if sPlayDash is not true then exit b2kPlayerDashEnd + b2SetGravityScale pBody, b2kNumberOr(sPlayDashGravSave, 1) + put empty into sPlayDashGravSave + put false into sPlayDash +end b2kPlayerDashEnd + +-- Internal (Wave 5): a single horizontal ray toward the input/facing side. +-- A near-vertical hit within a capsule-width is a wall -> sPlayWall = the +-- side (-1 left, 1 right; 0 = none). Runs only while the wall system is on +-- AND the player is airborne, so the steady-state budget is untouched. +command b2kPlayerWallProbe pBody, pAxis + local tDir + put 0 into sPlayWall + if pAxis is 0 then + put sPlayFacing into tDir + else + put pAxis into tDir + end if + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY, sPlayPX + tDir * (sPlayHalfW + 4), sPlayPY) + if sRayNX is not empty and abs(sRayNX) > 0.7 and abs(sRayNY) < 0.6 then + put tDir into sPlayWall + end if +end b2kPlayerWallProbe + +-- Internal (Wave 5): enter/leave the reshaped crawl (only when duckScale +-- < 1). Entering shrinks the capsule FEET-ANCHORED (drop the centre by +-- half the height lost so the feet stay planted); standing waits for +-- headroom (a ray up from the crouched top), so a low ceiling keeps you +-- crawling. b2kReshape resets the material, so friction/bounce are re-set. +command b2kPlayerDuckSet pWantDuck + local tNewH, tShift, tNeed + if sPlayRef is empty then exit b2kPlayerDuckSet + if pWantDuck is true then + if sPlayDucked is true or sPlayDuckScale >= 1 then exit b2kPlayerDuckSet + put max(8, round(sPlayStandH * sPlayDuckScale)) into tNewH + put (sPlayStandH - tNewH) / 2 into tShift + set the height of sPlayRef to tNewH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY + tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put tNewH / 2 into sPlayHalfH + put true into sPlayDucked + b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule + else + if sPlayDucked is not true then exit b2kPlayerDuckSet + put sPlayStandH - (the height of sPlayRef) into tNeed + set the itemDelimiter to comma + get b2kRayHit(sPlayPX, sPlayPY - sPlayHalfH, sPlayPX, sPlayPY - sPlayHalfH - tNeed - 2) + if sRayNY is not empty then exit b2kPlayerDuckSet -- a ceiling: stay crawling + b2kPlayerStandUp + end if +end b2kPlayerDuckSet + +-- Internal (Wave 5): restore the capsule to standing height, feet planted. +-- Used by the duck exit (with headroom) and by b2kPlayerRespawn (forced). +command b2kPlayerStandUp + local tShift + if sPlayDucked is not true or sPlayRef is empty then exit b2kPlayerStandUp + put (sPlayStandH - (the height of sPlayRef)) / 2 into tShift + set the height of sPlayRef to sPlayStandH + b2kMoveTo sPlayRef, sPlayPX, sPlayPY - tShift + b2kReshape sPlayRef, "capsule" + b2kSetFriction sPlayRef, 0.08 + b2kSetBounce sPlayRef, 0 + put sPlayStandH / 2 into sPlayHalfH + put false into sPlayDucked + b2kPlayerTuneCache +end b2kPlayerStandUp + -- Internal: the per-frame controller. Loop order: input -> PLAYER -> -- sprites -> camera, so it reads this frame's edges and the sprite tick -- applies the anim it picks. Exits in one compare when unused. The @@ -3646,6 +3908,7 @@ end b2kPlayerDropRestore command b2kPlayerTick local tNow, tDT, tB, tVX, tVY, tAxis, tAxisY, tTarget, tAcc, tStep local tPrevState, tWrite, tInZone, tDuck, tClimbJump, i, tInWater + local tOnLift, tPVX, tPVY -- Wave 5: platform-carry scratch if sPlayRef is empty then exit b2kPlayerTick put sBody[sPlayRef] into tB if tB is empty then exit b2kPlayerTick @@ -3662,6 +3925,9 @@ command b2kPlayerTick put b2BodyVX(tB) * sScale into tVX put 0 - (b2BodyVY(tB) * sScale) into tVY b2kPlayerProbe tB, tVY + -- a touch of ground refills the air-jump budget (Wave 5; idle when + -- airJumps is 0, the default) + if sPlayGrounded is true then put sPlayAirJumps into sPlayAirJumpsLeft -- the drop window's bookkeeping runs UNGATED (a hurt or control-off -- mid-drop must never strand the mask without its one-way bit). The -- mask returns when the clock has run AND the capsule has cleared the @@ -3689,6 +3955,7 @@ command b2kPlayerTick end if put false into tWrite put false into tDuck + put false into tOnLift if sPlayControl is true and sPlayHurt is not true then put sFrameMS / 1000 into tDT if tDT <= 0 then put 1 / 60 into tDT @@ -3724,6 +3991,33 @@ command b2kPlayerTick end if end repeat end if + put tInZone into sPlayInLad -- exposed by b2kPlayerInLadder/InWater + put tInWater into sPlayInWat + -- DASH (Wave 5): a flat horizontal burst that overrides normal + -- movement for dashMs, then hands back. Idle in one compare when + -- dashSpeed is 0; yields to climb/swim (it ends on entering either). + if sPlayDash is true then + if tNow >= sPlayDashEnd or tInWater is true or tInZone is true then + b2kPlayerDashEnd tB + else + put sPlayDashDir into sPlayFacing -- face the dash, not late input + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + end if + if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and sPlayClimb is not true and sPlaySwim is not true \ + and tInZone is not true and tInWater is not true \ + and b2kActionPressed("dash") then + b2kPlayerDashStart tB + put sPlayDashDir * sPlayDashSpd into tVX + put 0 into tVY + put true into tWrite + end if + -- everything below (climb/swim entry + the three movement modes) is + -- suspended while a dash owns the body + if sPlayDash is not true then if sPlayClimb is not true and sPlaySwim is not true and tInZone is true then -- enter: UP any time in-zone; DOWN only while AIRBORNE (a -- grounded DOWN is a duck -- or a drop-through on a chain) @@ -3797,25 +4091,59 @@ command b2kPlayerTick if sPlayClimb is not true and sPlaySwim is not true then -- horizontal: accelerate vx toward axis * moveSpeed (air = airAccel) put tAxis * sPlayMoveSpd into tTarget - -- DUCK: down on the ground crouches and brakes to a stop at - -- the normal decel (the hitbox keeps its size this wave) + -- DUCK: down on the ground. With duckScale < 1 the capsule + -- reshapes to a CRAWL (slow movement, shorter hitbox -- so you + -- can slip under a low gap); otherwise the Wave 2 duck brakes to + -- a stop with the hitbox unchanged. if tAxisY is 1 and sPlayGrounded is true then put true into tDuck - put 0 into tTarget + if sPlayDuckScale < 1 then + b2kPlayerDuckSet true + put tAxis * sPlayMoveSpd * 0.5 into tTarget -- crawl, not brake + else + put 0 into tTarget + end if + end if + if tDuck is not true and sPlayDucked is true then b2kPlayerDuckSet false + -- PLATFORM CARRY (Wave 5): inherit the ground body's velocity so a + -- moving platform carries you (static ground reads 0 -> no effect). + -- A vertical lift's carry exempts the ground-snap below. + if sPlayCarry is true and sPlayGrounded is true and sPlayGroundBody is not empty then + put b2BodyVX(sPlayGroundBody) * sScale into tPVX + put 0 - (b2BodyVY(sPlayGroundBody) * sScale) into tPVY + add tPVX to tTarget + if tPVY is not 0 and sPlayJumping is not true then + put tPVY into tVY + put true into tOnLift + end if end if if sPlayGrounded then put sPlayAccelG into tAcc else put sPlayAccelA into tAcc end if - put tAcc * tDT into tStep - if tVX < tTarget then - put min(tTarget, tVX + tStep) into tVX - else - put max(tTarget, tVX - tStep) into tVX + -- a wall-jump owns vx briefly (sPlayWallLockUntil): skip the air + -- steer so the away-launch carries clear before control resumes + if tNow >= sPlayWallLockUntil then + put tAcc * tDT into tStep + if tVX < tTarget then + put min(tTarget, tVX + tStep) into tVX + else + put max(tTarget, tVX - tStep) into tVX + end if end if put true into tWrite if tClimbJump is not true and b2kActionPressed("jump") then put tNow into sPlayPressMS + -- WALL slide (Wave 5; airborne only, one ray when the system is + -- armed): hugging a wall while falling caps the fall at wallSlideMax + put false into sPlayWallSliding + if sPlayWallOn is true and sPlayGrounded is not true then + b2kPlayerWallProbe tB, tAxis + if sPlayWall is not 0 and tAxis is sPlayWall and tVY > 0 and sPlayWallSlideMax > 0 then + if tVY > sPlayWallSlideMax then put sPlayWallSlideMax into tVY + put true into sPlayWallSliding + end if + end if if tDuck is true then -- a press while crouched: on a ONE-WAY CHAIN it drops -- through (dropMs of no chain collision); on solid ground @@ -3830,14 +4158,38 @@ command b2kPlayerTick put 0 into sPlayPressMS end if else - -- jump: a buffered press fires while grounded-or-coyote + -- a buffered press, in priority: WALL-JUMP > ground/coyote + -- jump > air-jump (the double-jump). The wall and air branches + -- idle (their knobs are 0) unless the game enables them. if sPlayPressMS > 0 and tNow - sPlayPressMS <= sPlayBuffer then - if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then - put 0 - sPlayJumpSpd into tVY + if sPlayWallOn is true and sPlayWall is not 0 \ + and sPlayGrounded is not true and sPlayWallJumpX > 0 then + -- WALL-JUMP: up + away from the wall, with a brief steer + -- lock so the launch carries before air control resumes + put sPlayWallJumpY into tStep + if tStep <= 0 then put sPlayJumpSpd into tStep + put 0 - tStep into tVY + put (0 - sPlayWall) * sPlayWallJumpX into tVX + put 0 - sPlayWall into sPlayFacing put true into sPlayJumping - put false into sPlayGrounded -- airborne from this frame on - put 0 into sPlayGroundMS -- consume coyote - put 0 into sPlayPressMS -- consume the buffer + put tNow + 180 into sPlayWallLockUntil + put 0 into sPlayPressMS + else + if sPlayGrounded or (sPlayGroundMS > 0 and tNow - sPlayGroundMS <= sPlayCoyote) then + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + put false into sPlayGrounded -- airborne from this frame on + put 0 into sPlayGroundMS -- consume coyote + put 0 into sPlayPressMS -- consume the buffer + else + if sPlayAirJumps > 0 and sPlayAirJumpsLeft > 0 then + -- DOUBLE / AIR JUMP: airborne, no ground or coyote + put 0 - sPlayJumpSpd into tVY + put true into sPlayJumping + subtract 1 from sPlayAirJumpsLeft + put 0 into sPlayPressMS + end if + end if end if end if end if @@ -3847,6 +4199,7 @@ command b2kPlayerTick put false into sPlayJumping end if end if + end if end if if sPlayJumping and tVY >= 0 then put false into sPlayJumping -- apex -- terminal velocity: the low swimMaxFall is the buoyant sink cap while @@ -3872,7 +4225,7 @@ command b2kPlayerTick -- must use b2kPlayerJump, which sets the jump flag (b2kPlayerHurt's -- pop rides the same flag). if sPlayGrounded and sPlayJumping is not true and sPlayClimb is not true \ - and sPlaySwim is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then + and sPlaySwim is not true and tOnLift is not true and tVY < 0 and abs(sPlayNormX) < 0.1 then put 0 into tVY put true into tWrite end if @@ -3917,11 +4270,15 @@ command b2kPlayerTick put 0 into sPlayAir else add 1 to sPlayAir - if sPlayJumping is true or sPlayAir >= 2 then - if tVY < 0 then - put "jump" into sPlayState - else - put "fall" into sPlayState + if sPlayWallSliding is true then + put "wallslide" into sPlayState -- Wave 5: clinging a wall + else + if sPlayJumping is true or sPlayAir >= 2 then + if tVY < 0 then + put "jump" into sPlayState + else + put "fall" into sPlayState + end if end if end if end if @@ -3935,6 +4292,12 @@ command b2kPlayerTick put "swim" into sPlayState put 0 into sPlayAir end if + -- a dash OWNS the state outright (it yields to swim/climb, so it can + -- never be underwater -- this safely overrides last) + if sPlayDash is true then + put "dash" into sPlayState + put 0 into sPlayAir + end if -- animations: only while controlling (manual poses own the art when -- control is off), and never let a vanished art control abort the -- frame -- the loop's ticks share one try block @@ -3953,7 +4316,8 @@ command b2kPlayerShowState pNow, pVX local tWant, tAnim, tAKey, tFPS, tFlip put sPlayState into tWant if pNow < sPlayHoldMS and tWant is not "jump" and tWant is not "fall" \ - and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" then + and tWant is not "hurt" and tWant is not "climb" and tWant is not "swim" \ + and tWant is not "wallslide" and tWant is not "dash" then put empty into tAnim -- mid land-flourish: leave it playing else if tWant is "land" and sPlayAnims["land"] is empty then From e1dc5720bec4ce918fafb62b0d92141c7a981eb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 02:57:08 +0000 Subject: [PATCH 03/19] docs: Wave 5 player actions across the reference, guide, changelog, plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kit-reference: the new b2kPlayer* getters (HalfH/HalfW/InLadder/InWater), b2kPlayerRespawn, the wall/dash anim slots + states, and the Wave 5 opt-in tuning keys (airJumps, wall*, dash*, duckScale, platformCarry). - kit-guide: a Wave 5 actions subsection (§21) with the enable recipe; retired the deleted micro-game's file references (the pattern stays). - expansion-prep: Wave 5 marked BUILT (harness v13). - CHANGELOG: Wave 5 Added entry + micro-game Removed entry. - plan.md: the Wave 5 decision-log entry (the as-built record). - README: the platformer's controller blurb advertises the new moves. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- CHANGELOG.md | 32 +++++++++++++++++++++ README.md | 5 ++-- docs/expansion-prep.md | 3 +- docs/kit-guide.md | 64 ++++++++++++++++++++++++++++++++++++------ docs/kit-reference.md | 35 +++++++++++++++++++++-- plan.md | 1 + 6 files changed, 127 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 675623e..fb40a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,40 @@ The native shim's ABI is tracked separately by `b2Version()` (currently `4`). one-way platform builders at chains (chain segments are the one-sided primitive). +### Removed + +- **The micro-game example (`box2dxt-microgame.livecodescript`) was + retired.** The repo concentrates its game work on the platformer showcase; + the "build a whole game" pattern it demonstrated is preserved in + kit-guide §20. Dropped from the embedded-Kit sync list and the example + lists in the README and CLAUDE.md. + ### Added +- **Wave 5 (player actions II) — five new player-controller moves + (statically verified + harness v13).** All **opt-in** through + `b2kPlayerSet` knobs whose defaults leave the pre-Wave-5 controller + byte-for-byte unchanged, and each idle path costs one compare per frame: + - **Double-jump** (`airJumps` — extra mid-air jumps, refilled on landing). + - **Wall-slide + wall-jump** (`wallSlideMax` caps the fall while pressing + into a wall; `wallJumpX`/`wallJumpY` launch up and away with a brief + steer-lock; a side ray runs only while airborne). New `wallslide` state. + - **Dash** (`dashSpeed`/`dashMs`/`dashCooldownMs` on the new `dash` action, + bound to SHIFT/X — a flat horizontal burst with gravity parked; yields to + climb/swim). New `dash` state. + - **Duck capsule-reshape** (`duckScale < 1` turns the Wave 2 brake-duck into + a feet-anchored **crawl** via `b2kReshape`, with a headroom check before + standing — so the hero slips under low gaps). + - **Moving-platform carry** (`platformCarry 1` — a grounded player inherits + the velocity of the moving kinematic body it rides; a vertical lift's + carry is exempt from the ground-snap). + - New helpers: `b2kPlayerHalfH()`/`b2kPlayerHalfW()` (live capsule extents, + serving gotcha 28), `b2kPlayerInLadder()`/`b2kPlayerInWater()` (this + frame's zone membership), and `b2kPlayerRespawn x,y` (teleport + zero + velocity + clean state). `b2kPlayerAnims` gains `wall`/`dash` slots. + - The **platformer showcase** turns them all on and leans each beat on one + (double-jump throughout, a wall-jump shaft, a dash gap, a crawl tunnel, + moving-platform lifts). Self-test **v13** adds six hand-stepped tests. - **Wave 4 (liquids) — SWIM, the Kit's first new player-action since Wave 2 (statically verified + harness v12; user play-tested in the platformer).** A new `b2kPlayerAddWater x1,y1,x2,y2` registers a polled diff --git a/README.md b/README.md index 0e6ff9e..509ecf1 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ Box2D v3.1.0 (fetched by CMake) a full build-and-run physics sandbox with fans, magnets, lasers, bombs, motors, and save/load. Game-minded? The [platformer showcase](examples/box2dxt-platformer.livecodescript) is the - Game Kit pushed hard — player controller (run/jump/climb/duck/swim), - scrolling camera, spritesheets, coin puzzles, a hilltop swim pool — and the + Game Kit pushed hard — player controller (run, double-jump, wall-jump, + dash, climb, crawl, swim), scrolling camera, spritesheets, moving + platforms, coin puzzles, a hilltop swim pool — and the [slingshot](examples/box2dxt-slingshot.livecodescript) is pure physics joy: catapult cannonballs into toppling towers, angry-birds style (three levels, ballistic aim preview, zero assets). And the diff --git a/docs/expansion-prep.md b/docs/expansion-prep.md index 8b34dc8..4b3a42e 100644 --- a/docs/expansion-prep.md +++ b/docs/expansion-prep.md @@ -14,7 +14,8 @@ that keep the expansion as reliable as the engine underneath it. | Wave 3 | **BUILT — statically verified 2026-06-13** (bestiary I + HAUNTED HOLLOW; see §10) | | Showcase polish | **BUILT — statically verified 2026-06-13** (pre-Wave-4: longer/re-spaced levels, the kit's first JOINT mechanics — rope bridge + boulder + barrel; a prototyped wrecking ball was cut as un-sprite-able — and four variety species; all example-side, zero Kit change, no harness bump) | | Wave 4 | **SWIM user play-tested in the platformer 2026-06-14** (harness **v12**, two Opus reviews clean; see §11). The Kit gained `b2kPlayerAddWater` + a buoyant `swim` mode/state/anim; the platformer's L1 GREEN HILLS gained a **HILLTOP POOL** (a raised-bank basin — the swim showcase, where it's tested), tuned heavier and with the hero hitbox fixed to match the art (gotcha 28), all per the user's OXT pass. DONE: swim zones, pit-dwellers (the micro-game `fish`, debut), lava (already in platformer L4). CARRY-OVER: the collapsing-bridge trap, and the micro-game's L3 "THE DEEP" (built but shows an example-side white-world build issue — set aside) | -| Next | **Wave 5 — player actions II** (wall-slide/jump, dash, double-jump; capsule reshape on duck) — see §7 and §12. Loose ends: the collapsing bridge + the micro-game L3 fix | +| Wave 5 | **BUILT — statically verified 2026-06-14** (player actions II: double-jump `airJumps`, wall-slide/jump, dash, duck capsule-reshape, moving-platform carry — all opt-in Kit knobs, defaults unchanged; harness **v13**, six new tests; see §12). Enabled + showcased in the platformer; the micro-game was retired (focus is the platformer). Awaiting the OXT feel pass. | +| Next | Iterate Wave 5 feel in OXT (the new moves' tuning numbers are first-pass); the collapsing-bridge trap remains a loose end | | Companions | [plan.md](../plan.md) (history/decision log) · [game-engine-spec.md](game-engine-spec.md) (module design) | --- diff --git a/docs/kit-guide.md b/docs/kit-guide.md index 6885669..0a875eb 100644 --- a/docs/kit-guide.md +++ b/docs/kit-guide.md @@ -1022,11 +1022,14 @@ joints, smooth chain terrain, and a per-frame motor driven by the keyboard. ## 20. Building a whole game (the micro-game pattern) -`examples/box2dxt-microgame.livecodescript` is a complete game — start -screen, two levels, a win screen — in a few hundred lines of card logic, -with nothing to install beyond the extension (the hero sheet is embedded -base64; every sound is `b2kToneMake`d). It is the file to copy when you -start your own game. Its skeleton is four ideas: +The **micro-game pattern** is the recommended skeleton for a green-field +game on the Kit: a complete game — start screen, levels, a win screen — in a +few hundred lines of card logic, with nothing to install beyond the +extension (embed the hero sheet as base64; synthesize every sound with +`b2kToneMake`). A dedicated micro-game example once shipped this verbatim; +the repo now concentrates its game work on the **platformer showcase**, but +the pattern below is exactly the one to copy when you start your own game. +Its skeleton is four ideas: **1. A game-state machine, gated by `b2kPlayerControl`.** One `gMode` local (`menu` / `play` / `won`) decides what clicks and keys mean. The @@ -1149,9 +1152,54 @@ governs the apex once you break the surface), so to make climbing out of a pool harder, lower `swimJump`, not the gravity. **(2) layout** — a swim pool can't be a pit *below* the ground, because `b2kCamBounds` clamps the camera at the world's bottom edge and anything lower is off-screen; build -the pool as a RAISED basin between two banks (or raise the whole ground, as -the micro-game does), then hop in, dive for the coins, and stroke up + -hold-forward to hop out the far bank. +the pool as a RAISED basin between two banks (or raise the whole ground), +then hop in, dive for the coins, and stroke up + hold-forward to hop out the +far bank. + +### Wave 5 actions: double-jump, wall-jump, dash, crawl, carry + +The Wave 5 moves are all **opt-in** through `b2kPlayerSet` knobs — the +defaults leave the controller exactly as the Wave 2/4 chapters describe, and +each idle path costs one compare per frame, so you only pay for what you turn +on. Turn them on for a modern-feeling platformer: + +``` +b2kPlayerSet "airJumps", 1 -- a second jump in mid-air (double-jump) +b2kPlayerSet "wallJumpX", 240 -- wall-slide + wall-jump (away + up)... +b2kPlayerSet "wallJumpY", 430 +b2kPlayerSet "wallSlideMax", 120 -- ...the slowed slide down a wall +b2kPlayerSet "dashSpeed", 560 -- DASH on the "dash" action (SHIFT/X) +b2kPlayerSet "duckScale", 0.6 -- DOWN now CRAWLS under a low gap +b2kPlayerSet "platformCarry", 1 -- ride a moving kinematic platform +``` + +- **`airJumps`** is the air-jump budget, refilled on every landing — `1` is a + classic double-jump, `2` a triple. (For a *powerup* double-jump, grant it + with `b2kPlayerJump` from a sensor instead.) +- **Wall moves** arm when `wallJumpX > 0` (or `wallSlideMax > 0`). While + airborne and pressing INTO a wall you `wallslide` (the fall caps at + `wallSlideMax`); JUMP launches up and away with a brief steer-lock so the + launch carries clear. `wallJumpY` falls back to `jumpSpeed`. +- **Dash** is a flat horizontal burst (gravity parked) on the `dash` action + — bound to SHIFT/X by default; rebind with `b2kBindAction "dash", …`. It + runs `dashMs`, then `dashCooldownMs` must pass before the next, and it + yields to climb/swim. +- **`duckScale < 1`** turns the Wave 2 brake-duck into a real **crawl**: the + capsule reshapes (feet-anchored) to that fraction of its standing height, + so you fit under a low overhead, and stands back up only when there's + headroom. Read the live half-height with `b2kPlayerHalfH()` for any + head-reach logic, and size crawl gaps against it. +- **`platformCarry 1`** makes a grounded player inherit the velocity of the + moving kinematic body under it — so a moving platform *carries* you instead + of sliding out from under. The platform must move by **velocity** (a + kinematic body with `b2kSetVelocity`), not by position, because carry reads + that velocity; flip the velocity at the patrol endpoints (write-on-change). + +New conveniences: `b2kPlayerHalfH()`/`b2kPlayerHalfW()` (live capsule +extents), `b2kPlayerInLadder()`/`b2kPlayerInWater()` (this frame's zone +membership, for prompts/effects), and `b2kPlayerRespawn x, y` (teleport + +zero velocity + clean state in one call). The **platformer example** is the +marquee showcase for all of these. --- diff --git a/docs/kit-reference.md b/docs/kit-reference.md index 788863c..f45104c 100644 --- a/docs/kit-reference.md +++ b/docs/kit-reference.md @@ -343,15 +343,33 @@ terminal), UP/DOWN drive vy at `swimSpeed`, and a JUMP press is a exactly once. Mutually exclusive with the climb. Sized for a *raised-bank* basin — a sub-ground pit falls below the camera (see kit-guide §21). +**Wave 5 actions**, each **opt-in** through a knob (the defaults leave the +controller byte-for-byte as above, and every idle path is one compare per +frame): **double-jump** (`airJumps` — extra mid-air jumps, refilled on +landing), **wall-slide + wall-jump** (`wallSlideMax` caps the fall while you +press into a wall; `wallJumpX`/`wallJumpY` launch up and away with a brief +steer lock — states `wallslide` and a side ray that runs only while airborne), +**dash** (`dashSpeed` on the new `dash` action — a flat horizontal burst for +`dashMs` with gravity parked, cooldown-gated; state `dash`; yields to +climb/swim), **duck capsule reshape** (`duckScale < 1` turns the Wave 2 brake +into a real **crawl** — a feet-anchored `b2kReshape` to a shorter capsule with +a headroom check before standing), and **platform carry** (`platformCarry 1` — +a grounded player inherits the velocity of the moving kinematic body it rides; +a vertical lift's carry is exempt from the ground-snap). The marquee +showcase is the **platformer example**. + | Handler | Purpose | |---------|---------| | `b2kPlayerMake x, y, w, h [,sheet]` → control | One call: a capsule body host (`w`×`h` collision box — a visible capsule graphic, or invisible with a bound sprite of `sheet`'s first frame on top), controller armed, input on. Reports the player control. | | `b2kPlayerAttach ctrl` | Adopt an existing control (or sprite) as the player. A capsule body is added if it has none (then the controller also sets low friction); a body you made yourself keeps your material. Also sets fixed rotation + sleep-off and arms input. | -| `b2kPlayerAnims idle, run, jump [,fall] [,land] [,duck] [,climb] [,hurt] [,swim]` | Map states to the art's animation names (`fall` defaults to `jump`; `duck` to `idle`; `climb` and `hurt` to `jump`; `swim` to `fall` — sheets without those frames still read correctly). `land` is an optional non-looping touch-down flourish, held for its own duration. **Map a LOOPING animation to `hurt`** if your game uses `b2kSpriteOnFinish` on the player's art — a non-looping hurt pose fires that finish message mid-knockback. The art is the player control itself if it is a sprite, else the first sprite `b2kSpriteBind`-pinned to it. | +| `b2kPlayerAnims idle, run, jump [,fall] [,land] [,duck] [,climb] [,hurt] [,swim] [,wall] [,dash]` | Map states to the art's animation names (`fall` defaults to `jump`; `duck` to `idle`; `climb` and `hurt` to `jump`; `swim` and `wall` to `fall`; `dash` to `run` — sheets without those frames still read correctly). `land` is an optional non-looping touch-down flourish, held for its own duration. **Map a LOOPING animation to `hurt`** if your game uses `b2kSpriteOnFinish` on the player's art — a non-looping hurt pose fires that finish message mid-knockback. The art is the player control itself if it is a sprite, else the first sprite `b2kSpriteBind`-pinned to it. | | `b2kPlayerSet key, value` / `b2kPlayerGet(key)` | Tuning knobs (table below). Settable any time; `b2kClear` keeps them (config, like input bindings), `b2kTeardown`/`b2kPlayerRemove` wipe them. | | `b2kPlayerOnGround()` | Grounded this frame (post-tick; false on the frame a jump launches). | -| `b2kPlayerState()` | `idle` / `run` / `jump` / `fall` / `duck` / `climb` / `hurt` / `swim`, plus `land` for exactly one frame on touch-down (dust puffs, sounds — read it in `on b2kFrame`). A drop-through renders as `fall`; a knockback's own landing shows no `land` tick. | +| `b2kPlayerState()` | `idle` / `run` / `jump` / `fall` / `duck` / `climb` / `hurt` / `swim` / `wallslide` / `dash`, plus `land` for exactly one frame on touch-down (dust puffs, sounds — read it in `on b2kFrame`). A drop-through renders as `fall`; a knockback's own landing shows no `land` tick. The Wave 5 states (`wallslide`, `dash`) appear only when their knobs are enabled. | | `b2kPlayerFacing()` | 1 right / -1 left — the last horizontal intent. | +| `b2kPlayerHalfH()` / `b2kPlayerHalfW()` | The capsule's **current** half-extents in px — the half-height drops while in a reshaped duck/crawl. Read these live for head-reach logic (never bake a constant: a hitbox taller than the visible art bumps things the head never touches). | +| `b2kPlayerInLadder()` / `b2kPlayerInWater()` | This frame's ladder / water zone membership (the controller computes them every tick anyway) — for "press UP to climb" prompts, splash effects, a breath meter. | +| `b2kPlayerRespawn x, y` | Teleport to a screen-px point and reset to a clean standing idle: velocity zeroed; the jump/hurt/dash/climb/swim/drop/duck state cleared; the air and air-jump budgets refreshed. The respawn most games hand-roll (move + zero velocity + clear a pile of flags) in one call. Tuning and zones are kept. Empty `x`/`y` reset in place. | | `b2kPlayerJump [speed]` | Programmatic jump (springs, double-jump powerups): the same launch as a pressed jump but **without** the grounded/coyote gate — the caller decides when it is allowed. | | `b2kPlayerAddLadder x1, y1, x2, y2` | Register a ladder **zone** (screen-px rect, any corner order; purely polled — no physics object). Zones are world state: `b2kClear` wipes them with everything else. Run the zone a little above a platform at the ladder's top so walking off that edge holding DOWN grabs it. | | `b2kPlayerAddWater x1, y1, x2, y2` | Register a water/**swim zone** (screen-px rect, any corner order; purely polled). World state, wiped by `b2kClear` like ladders. Top the zone a little above the drawn surface so the dive-in and surface-out break the water where the art is. The pool is a *raised basin* between banks (a sub-ground pit clamps below the camera). | @@ -374,6 +392,19 @@ height, so lower IT to make climbing out harder) · `hurtPopX` 220 / `hurtPopY` 320 px/s (knockback launch) · `hurtMs` 700 (control-off span) · `invulnMs` 900 (post-hurt mercy). +**Wave 5 action keys** — all **opt-in** (these defaults leave the controller +exactly as above): `airJumps` 0 (extra mid-air jumps; **1 = double-jump**, +refilled on landing) · `wallJumpX` 0 / `wallJumpY` 0 (wall-jump launch px/s, +away + up; `wallJumpX > 0` arms the **wall system**, `wallJumpY` falls back to +`jumpSpeed`) · `wallSlideMax` 0 (capped fall px/s while pressing into a wall — +the `wallslide` state) · `dashSpeed` 0 (a flat horizontal burst on the **`dash` +action**, default keys SHIFT/X; `0` = off) / `dashMs` 160 / `dashCooldownMs` +500 · `duckScale` 1 (ducked capsule height ÷ standing; **< 1 reshapes to a +crawl** so the hero slips under low gaps — feet-anchored, with a headroom check +before standing) · `platformCarry` 0 (**1** = a grounded player inherits the +velocity of a moving kinematic platform it rides; costs two reads per +grounded frame, and changes how a player rides *any* kinematic body). + ``` -- a playable character in four lines (after b2kQuickStart + sheet load): b2kPlayerMake 200, 100, 32, 48, "chars" diff --git a/plan.md b/plan.md index 69602e5..6bf4900 100644 --- a/plan.md +++ b/plan.md @@ -323,3 +323,4 @@ user-confirmed in OXT before the next begins. | 2026-06-13 | **Showcase round 4b: a per-frame optimization pass (user: "as fully optimized to the current kit/library as possible").** An Opus audit of `on b2kFrame` + all 14 `pfTick*` against the perf playbook (cost order: interpreter ops > FFI round-trips > property-set redraws) found one real regression and three free wins, all example-side. (1) HIGH - `pfTickThwomps` did an FFI `b2kPosition` + comma-split for EVERY block every frame, and the new crusher alleys had doubled the block count to ~7-8/level while only 0-1 are ever in motion. Fix: cache each block's perch x at make + re-arm (new `gBlockX[]`), gate the armed->falling trigger on the cached x + the hero snapshot (perch y is invariantly 200, so `tHY > tBY` becomes `tHY > 200`), and read the live position ONLY in the in-motion states - cutting thwomp FFI from ~8/frame to ~0-1/frame with byte-identical trigger semantics. (2) The `shellslide` tick read `b2kVelocity` twice (vx test + vy pass-through) -> read once into a local. (3) The HUD reused the already-snapshotted `gHeroState` instead of re-calling `b2kPlayerState()` and hoisted `the hScroll of b2kCamGroup()` (read twice) into one local - both 4 Hz so minor, but free. The audit confirmed every other tick already optimal (O(1) idle gates via `gXxxN is 0`/`is empty`, hoisted clocks, the shared snapshot, change-gated property/velocity writes, sleeping bodies left asleep) and the gotcha scan clean (no smart quotes; no `local` nested in a block; no per-frame velocity write to a resting body; no two velocity-asserting bodies sharing a band; no stale `the result`). Example-only, so NO harness bump (v10 holds; the harness drives the Kit, not the example's `pf*` ticks). Static checker clean; awaiting the OXT pass. | User direction; this commit | | 2026-06-13 | **WAVE 4 begins (liquids): SWIM lands in the Kit + a water level in the micro-game.** User: "move on to wave 4" + (AskUserQuestion) "Kit + a playable level together" and water content in "Both" games. SWIM is the first new player-action since Wave 2, so it is a KIT change (harness bump), built as a faithful parallel to the ladder/climb system: a new `b2kPlayerAddWater x1,y1,x2,y2` polled zone (flat `sPlayWat*` arrays, wiped by `b2kClear` like ladders); while the centre is submerged the controller enters a `swim` mode - gravity scaled to `swimGravity` (0.35, saved/restored like the climb's), the fall capped at `swimMaxFall` (150) instead of the air terminal, UP/DOWN drive vy at +/-`swimSpeed`, and a JUMP press is a REPEATABLE upward stroke (`swimJump`, no grounded/coyote/buffer gate). New `swim` state (overrides the grounded/airborne machine while submerged, clears `sPlayAir` so surfacing is not a phantom land), a 9th `pSwim` arg on `b2kPlayerAnims` (falls back to fall), and mutual exclusion with the climb (start gates check BOTH flags, so two gravity-saves can never fight). Knobs cached in `b2kPlayerTuneCache`. Harness **v11**: `stTestSwim` hand-steps a dive/sink-cap/stroke/swim-up/exit-restore sequence, self-diagnosing. An Opus review traced every risk (gravity leak, double-save, climb/swim exclusion, same-tick surfacing handoff, state/anim, gotchas, knob round-trip, harness thresholds) - all SAFE, no blockers. Re-synced into all examples. Content: the micro-game gains L3 "THE DEEP" - dive a pool, every coin underwater (the door forces the swim), alien skins drive real `swim1/2`; new `water` and `fish` (vertical pit-dweller, knockback) data verbs; stroke+hold-right hops you out. The platformer water beat (the second half of "Both") needs a raised-bank basin (the 640-tall world clamps the camera at y640, so a deep pit is off-screen - the micro-game raised its land for the same reason) and lands next. Static gates clean; awaiting the OXT pass. | User direction; this commit | | 2026-06-14 | **Wave 4 round 2 — swim PIVOTS to the platformer; two playtest fixes; harness v12; docs deep-dive.** User OXT pass: the micro-game L3 shows a "white empty world" (an L3-only build issue in the EXAMPLE's own code; the Kit swim runs fine), and "the platformer is where we have been testing" -> swim content MOVES there (AskUserQuestion: add a debug warp, leave the micro-game L3 for later). Built L1 GREEN HILLS' **HILLTOP POOL**: a raised-bank basin (banks y480, water 480..640, floor y616) past the crusher alley with 3 underwater coins, a finale shift (flag 7540->8520, `pfBounds` ->8640), a new `pfMakeWater` helper, swim anim = the fall-pose fallback (`character_beige` has no swim frame). A `0`-key debug warp (delete-before-merge sentinel) drops onto the pool for fast iteration. **Harness v12** adds `stTestSwimGrounded` (swim while grounded on a submerged floor) and `stTestSwimClear` (the level-rebuild path: `b2kClear` must wipe the zone). A second Opus audit (micro-game + harness) was clean and caught two polish items folded in earlier: the L3 fish never breaching the surface (widened its bob), and a triple `b2kPosition(gHero)` per frame (hoisted to one snapshot passed into both ticks). **Two playtest fixes:** (1) swim too floaty -> heavier (`swimGravity` 0.35->0.6, `swimMaxFall` ->200, `swimJump` 360->300); the lesson: `swimGravity` sets only the between-stroke SINK, `swimJump` ALONE sets the single-stroke escape height (it writes velocity directly), so trimming the stroke is the lever for "harder to climb out". (2) a brick head-bump GAP the user flagged as a regression -> traced to the hero's 88px hitbox being TALLER than its ~76px visible character (128px frame headroom at 0.75 scale): the invisible "hat" hit the brick while the visible head sat low (the bonk still FIRED). Fixed by sizing the hitbox to the art (`tH` 88->76, `tDY` derived to keep the feet planted) and reading the real half-height (`gHeroHalfH`) in the bonk window instead of a hardcoded 44. Logged as **gotcha 28** + a Liquids/SWIM layout law. Docs deep-dive: swim added across kit-reference + kit-guide §21 + the API index; CHANGELOG/expansion-prep repointed from the micro-game to the platformer; all harness refs ->v12. | User OXT feedback; this commit | +| 2026-06-14 | **WAVE 5 (player actions II): five new controller moves + the platformer leans on them; harness v13. Full audit, user direction "improve the platformer as much as possible, kit as source of truth", maximal path (AskUserQuestion: Full Wave 5 + Rebalance+add).** Built as a fleet of Opus deep-dives (Kit internals, the platformer, docs/history, OXT/harness tooling) -> a synthesized plan. The KIT moves, every one **opt-in** through a knob whose default leaves the pre-Wave-5 controller byte-for-byte unchanged and whose idle path is one compare/frame: **double-jump** (`airJumps`, refilled on landing, fired from the buffered-press branch); **wall-slide + wall-jump** (a side ray that runs only while airborne -> `sPlayWall`; `wallSlideMax` caps the fall, `wallJumpX/Y` launch up+away with a 180ms steer-lock so the launch carries; `wallslide` state); **dash** (`dashSpeed/dashMs/dashCooldownMs` on the new `dash` action = SHIFT/X; gravity parked like a climb, vy held flat; yields to climb/swim; `dash` state); **duck capsule-reshape** (`duckScale < 1` -> a feet-anchored `b2kReshape` to a crawl with an up-ray headroom check before standing; `duckScale 1` keeps the Wave 2 brake-duck exactly); **moving-platform carry** (`platformCarry`: the probe already stashes the ground body handle, so the tick adds that body's velocity to the move target and a vertical lift exempts the ground-snap). Pure-win helpers that delete example boilerplate + serve gotcha 28: `b2kPlayerHalfH/HalfW` (live extents), `b2kPlayerInLadder/InWater` (this frame's zones), `b2kPlayerRespawn` (teleport+zero+clean). `b2kPlayerAnims` gains `pWall/pDash` (fall/run fallbacks). Carry defaulted OFF (not ON) on reflection: ON would silently change how a player rides ANY kinematic body (the platformer's ridable thwomp heads) - it must be opt-in like the rest. Harness **v13**: six hand-stepped, self-diagnosing tests (double-jump budget, wall-slide+jump, dash+cooldown, platform-carry, duck-reshape, getters+respawn). The PLATFORMER turns all five on globally (re-applied each level since teardown wipes tuning) and rebalances so each level's finale leans on one move + the four copy-pasted crusher alleys are de-duplicated. The micro-game was RETIRED (focus is the platformer); its "whole game" pattern survives in kit-guide §20. Static gates clean throughout; CI green (lint + 5 native builds). Awaiting the OXT feel pass (the move tuning numbers are first-pass). | User direction; this commit | From c3f4558d2f24895da2b3c740925c076ebd89f607 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 03:00:22 +0000 Subject: [PATCH 04/19] Platformer: turn on the Wave 5 moves and teach them in the help Enable double-jump, wall-slide/jump, dash, crawl (duckScale) and platform-carry on the showcase player (re-applied each level since teardown wipes tuning), and rewrite the on-screen help to teach the new controls. First-pass feel numbers; tune in OXT. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- examples/box2dxt-platformer.livecodescript | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index deb730f..a605368 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -727,6 +727,20 @@ command pfStartGame b2kPlayerSet "swimJump", 300 b2kPlayerSet "swimGravity", 0.6 b2kPlayerSet "swimMaxFall", 200 + -- Wave 5 moves, on for the whole showcase (each beat below leans on one; + -- all stay live everywhere). The Kit falls the wall-slide pose back to + -- the fall/jump frame and the dash pose to the run frame -- the beige + -- hero has no dedicated wall/dash art -- so they read correctly. Tune in + -- OXT to taste: these are first-pass feel numbers. + b2kPlayerSet "airJumps", 1 -- DOUBLE JUMP (a second jump in mid-air) + b2kPlayerSet "wallJumpX", 240 -- WALL JUMP: launch away from a wall... + b2kPlayerSet "wallJumpY", 430 -- ...and up (matches the ground jump) + b2kPlayerSet "wallSlideMax", 120 -- ...after a slowed slide down it + b2kPlayerSet "dashSpeed", 560 -- DASH (SHIFT or X): a flat ground/air zip + b2kPlayerSet "dashMs", 150 + b2kPlayerSet "dashCooldownMs", 420 + b2kPlayerSet "duckScale", 0.6 -- DOWN now CRAWLS (shorter hitbox) under low gaps + b2kPlayerSet "platformCarry", 1 -- ride the moving platforms (L2 lift, L3 raft) put 75 into gIntroPan -- a splash beat (~1.2 s), then control -- ===== LEVEL CAST (coins, enemies, checkpoint, the goal flag) ===== switch gLevel @@ -745,7 +759,7 @@ command pfStartGame -- help + the level banner on the splash (visible through the intro -- beat); gCoinsTotal counted itself during the build above if gAssetsOK is true and gLoadNote is empty then - set the text of field "pfHelp" to "Arrows/A-D run, SPACE jumps (tap = hop, hold = full), DOWN ducks - DOWN+JUMP drops through bridges/clouds - UP/DOWN" & cr & "climbs ladders (JUMP lets go). R restarts, ESC pauses, M mutes, MOUSE drags the crate. LEVEL " & gLevel & " of 4: " & gLevelName & "." & cr & "Collect ALL " & gCoinsTotal & " coins - the flag turns GOLD - then touch the flag to advance. Four levels to win." + set the text of field "pfHelp" to "Arrows/A-D run, SPACE jumps - press it again in mid-air to DOUBLE JUMP, or off a wall to WALL-JUMP. SHIFT or X = DASH." & cr & "DOWN crawls under low gaps (DOWN+JUMP drops through bridges/clouds); UP/DOWN climbs ladders. R restarts, ESC pauses, M mutes, MOUSE drags the crate." & cr & "LEVEL " & gLevel & " of 4: " & gLevelName & ". Collect ALL " & gCoinsTotal & " coins - the flag turns GOLD - then touch the flag. Four levels to win." else if gAssetsOK is true then set the text of field "pfHelp" to "KENNEY CHARACTERS LOADED, but: " & gLoadNote & cr & "Missing atlases fall back to plain shapes." & cr & "Shift+Reset re-asks for the Spritesheets folder." From f0feb9d84d930ca5d950cdb6a39fa82e50c9aeff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 03:25:22 +0000 Subject: [PATCH 05/19] Platformer: rebalance the four levels + a moving-platform lift mechanic Give each level a distinct signature finale that leans on a Wave 5 move, and de-duplicate the four copy-pasted crusher alleys: - New pfMakeLift/pfTickLift: a kinematic platform driven by VELOCITY (write-on-change at the patrol endpoints) so the Kit's platformCarry ferries its rider. Wired through globals, the pfStartGame reset, and the on b2kFrame fan-out. - L1 GREEN HILLS: a CRAWL TUNNEL (duck under a low overhang for a coin) - showcases the duckScale reshape. Swim pool stays its signature. - L2 THE WORKS: a LIFT BAY - ride a moving deck across a grinder hazard (the platform-carry showcase). Additive: no ground split, no x-shifts. - L3 FROZEN CITADEL: a WALL-JUMP SHAFT - two floating ice pillars, a top coin reachable by wall-jumps (or a plain double-jump). - L4 HAUNTED HOLLOW: a LAVA LIFT over the existing lava strip; trimmed the finale from four snails to two. Keeps its crusher alley. Every new coin goes through pfMakeCoin (self-counting total stays correct); every move-gated beat is also double-jumpable, so no level can dead-end. Fixed the stale header widths (L1 8640/L2 5952/L3 6592/L4 6656) and the 'three-level' leftovers; added a Wave 5 verify checklist item. First-pass geometry/feel; the new beats need an OXT pass. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- examples/box2dxt-platformer.livecodescript | 298 +++++++++++++++++---- 1 file changed, 245 insertions(+), 53 deletions(-) diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index a605368..116ce9b 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -23,22 +23,26 @@ -- stack property; Shift+Reset re-asks; Cancel = placeholder shapes). -- -- CONTROLS arrows or A/D run · SPACE / UP / W jumps (tap = hop, hold = --- full) · DOWN/S ducks - and DOWN+JUMP on a one-way deck --- (bridge, clouds) DROPS THROUGH it · UP/DOWN at the L2 --- ladder CLIMBS (JUMP lets go) · the MOUSE DRAGS the crate --- (chained weights are NOT draggable) · R restarts the --- CURRENT level · ESC pauses · M mutes the synthesized sound +-- full) · jump AGAIN in mid-air = DOUBLE JUMP · jump off a wall +-- you are sliding = WALL JUMP · SHIFT or X = DASH (a flat zip) · +-- DOWN/S ducks/CRAWLS (shorter, fits low gaps) - and DOWN+JUMP +-- on a one-way deck (bridge, clouds) DROPS THROUGH it · UP/DOWN +-- at the L2 ladder CLIMBS (JUMP lets go) · ride the moving LIFTS +-- (they carry you) · the MOUSE DRAGS the crate (chained weights +-- are NOT draggable) · R restarts the CURRENT level · ESC pauses +-- · M mutes the synthesized sound -- -- THE FOUR LEVELS (every beat holds a coin; the flag advances): --- LEVEL 1 GREEN HILLS (6336px) - movement + the toys: the +-- LEVEL 1 GREEN HILLS (8640px) - movement + the toys: the -- SPRINGBOARD mid-meadow (sky coin above; a 42px hop for -- non-bouncers), the BONK ROW (headbutt ?-boxes, SMASH bricks -- into debris), the one-way BRIDGE over a spike slime, the -- slope MOUND, one-way CLOUDS with the fly, the bee, the --- SPIKE PIT, a fast MOUSE, a flying ladybug - then the --- finale: a sagging ROPE BRIDGE (hinged planks) over a chasm --- and a ladybug on the far meadow to the flag. --- LEVEL 2 THE WORKS (4672px) - the machines: drag the +-- SPIKE PIT, a fast MOUSE, the Wave 5 CRAWL TUNNEL (a low +-- overhang - duck/DOWN to crawl under for a hidden coin), a +-- flying ladybug - then the rope-bridge finale over a chasm, +-- the crusher alley, and the hilltop SWIM POOL to the flag. +-- LEVEL 2 THE WORKS (5952px) - the machines: drag the -- CRATE onto the yellow BUTTON to open the gate (coin in -- the gateway), the Wave 2 LADDER up to its bonus ledge, -- the red CHECKPOINT, the saw LEVER (STAND at it to power @@ -46,18 +50,22 @@ -- both SAWS, a crawling WORM, THWOMP ALLEY (chained weights: -- ride the second one's head to the top coin; grab the -- YELLOW KEY between them), a breather cloud with its bee, --- and the WALLED DOOR - a stone +-- the Wave 5 LIFT BAY (its signature: RIDE the moving deck +-- across the grinder for a mid-bay coin - platform-carry), +-- then the crusher alley and the WALLED DOOR - a stone -- curtain to the ceiling, so the stone steps, the coins and -- the FLAG behind it are reachable ONLY through the door. --- LEVEL 3 FROZEN CITADEL (5312px) - everything at once, --- on ICE (~15% acceleration: momentum rules): a ladybug, --- spring over the first spiked pit, a bonk row, the first --- sweeping saw, a second spiked pit, a checkpoint, a thwomp --- guarding the RED key, the glacier run (a SECOND always-on --- saw under a snow cloud) with a ROLLING BOULDER that slides --- the ice head-on (leap it), the red walled door, snow --- steps, the final flag. --- LEVEL 4 HAUNTED HOLLOW (5376px) - WAVE 3's bestiary in +-- LEVEL 3 FROZEN CITADEL (6592px) - everything at once, +-- on ICE (~15% acceleration: momentum rules): the Wave 5 +-- WALL-JUMP SHAFT (its signature: climb two ice pillars to a +-- top coin - or double-jump the slot), a ladybug, spring +-- over the first spiked pit, a bonk row, the first sweeping +-- saw, a second spiked pit, a checkpoint, a thwomp guarding +-- the RED key, the glacier run (a SECOND always-on saw under +-- a snow cloud) with a ROLLING BOULDER that slides the ice +-- head-on (leap it), the blue crusher alley, the red walled +-- door, snow steps, the final flag. +-- LEVEL 4 HAUNTED HOLLOW (6656px) - WAVE 3's bestiary in -- the purple biome: a MIMIC field (grass blocks that do -- not belong - they wake and lunge), the SNAIL (stomp it -- into a SHELL, kick the shell, bowl the slime down the @@ -65,9 +73,11 @@ -- and swoop), a pit, a long FOUR-burrow PIRANHA row (they -- will not rise under your feet), the shy GHOST that drifts -- closer while you face away, two faced CRUSHERS around a --- LAVA strip, a smouldering FIRE SLIME, the POWDER KEG bay --- (an explosive barrel + woodpile, scattered by b2kExplode), --- purple steps, the last flag. +-- LAVA strip crossed by the Wave 5 LAVA LIFT (ride the deck +-- over the lava, or double-jump it), a smouldering FIRE +-- SLIME, the POWDER KEG bay (an explosive barrel + woodpile, +-- scattered by b2kExplode), the red crusher alley (its +-- signature gauntlet), purple steps, the last flag. -- -- WHAT TO VERIFY (the Phase 3 + level-rebuild OXT pass) -- 1. Feel: a TAPPED jump is clearly shorter than a HELD one; jumping @@ -129,7 +139,7 @@ -- 12. THE LEVEL FLOW: flag on L1/L2 banners "LEVEL N CLEAR!" and the -- next level builds ~1.2s later (outside the physics frame); R -- restarts only the CURRENT level; the final dialog shows TOTAL --- time and falls across all three; Play again is a fresh L1 run. +-- time and falls across all four; Play again is a fresh L1 run. -- The boundaries audit rides pfBounds: every level gets thick -- side slabs PLUS edge wall segments PLUS the matching camera -- clamp - check you cannot leave the world on any level. @@ -182,6 +192,30 @@ -- art): a fast MOUSE (L1), a slow WORM (L2), a LADYBUG (L1/L3), a -- FIRE SLIME by the lava (L4, spike-type - hurts every side). The -- HUD now shows "awake N/M" bodies (watch it fall as things sleep). +-- 16. WAVE 5 MOVES (the Kit controller's new abilities, on everywhere; +-- each level leans on one new beat - all example-side, zero Kit +-- change). DOUBLE JUMP: a second jump fires in mid-air (clears the +-- taller beats). WALL JUMP: hug a wall while airborne to slow the +-- fall, then jump to launch up-and-away. DASH (SHIFT or X): a flat +-- horizontal burst on a cooldown. CRAWL: DOWN reshapes the capsule +-- shorter (b2kPlayerHalfH drops) to fit low gaps. PLATFORM CARRY: a +-- grounded hero on a moving lift inherits its velocity. The signature +-- beats: L1 CRAWL TUNNEL (duck under the 52px overhang for the coin - +-- or jump over the bar; never blocks the path); L2 LIFT BAY (jump the +-- pedestal, RIDE the deck across the grinder for the mid-bay coin, +-- step off the far pedestal - a fall onto the grinder is a recoverable +-- knockback; the ~200px gap is also double-jumpable); L3 WALL-JUMP +-- SHAFT (climb the two floating ice pillars to the top coin - a +-- straight double-jump up the slot also reaches it, so it is never +-- trick-only; walk under the pillars unobstructed); L4 LAVA LIFT (ride +-- the deck over the lava between the crushers for the mid-lava coin - +-- or double-jump the 128px strip; a fall onto the lava is a knockback, +-- recoverable). EVERY new-move coin is also reachable WITHOUT the +-- trick (double-jump/crawl are universal), so no level can dead-end. +-- FEEL is unverifiable statically - tune the Wave 5 knobs in OXT and +-- confirm: the lifts carry you (stand still and drift with the deck), +-- a crouched hero clears the L1 bar a standing one cannot, and the L3 +-- slot is climbable by wall-jumps AND by a plain double-jump. -- ===================================================================== local gStarted, gHero, gHeroSpr, gHudLast, gHurtLock @@ -190,7 +224,7 @@ local gGate, gGateSprA, gGateSprB, gPlateHoldMS, gPlateLook, gCamOK local gIntroPan, gRunStart, gFalls, gWinLock, gFlagHint, gWinSecs local gOuches -- Wave 2: contact hits (knockbacks) - falls stay falls local gBuilding, gLands, gPrevState, gHudNextMS, gGateVel, gAirMS --- the three-level game: which level, its width/name, and the banked +-- the four-level game: which level, its width/name, and the banked -- totals carried across level clears for the final win screen local gLevel, gLevelW, gLevelName, gTotalFalls, gSecsBank local gPlateX, gGateUpY, gGateDownY, gDoorX, gDoorWord, gCheckX @@ -234,6 +268,14 @@ local gSlimeSpeed, gSlimeFlat, gSlimeFlip local gBoulderN, gBoulderB, gBoulderSpr, gBoulderHomeX, gBoulderHomeY local gBoulderResetX, gBoulderT, gBoulderHurtR, gBoulderGrace, gBoulderVX local gBarrelN, gBarrelB, gBarrelSpr, gBarrelX, gBarrelY, gBarrelState, gBarrelT +-- Wave 5 showcase: the moving-platform LIFT (platform-carry). A KINEMATIC +-- box driven by VELOCITY (carry reads b2BodyVX, so a position-driven body +-- would report zero velocity and never carry its rider). pfTickLift flips +-- each lift's vx at its min/max endpoints (write-on-change only - a resting +-- lift must keep moving, so it ASSERTS velocity, but only at the turns). +-- gLiftVX caches the last-written vx so the tick never re-sets an unchanged +-- velocity (gotcha 17). Indexed 1..gLiftN. +local gLiftN, gLift, gLiftMin, gLiftMax, gLiftSpeed, gLiftVX constant kMoveSpeed = 280 constant kJumpSpeed = 430 @@ -609,6 +651,13 @@ command pfStartGame put empty into gBarrelY put empty into gBarrelState put empty into gBarrelT + -- the Wave 5 moving-platform lifts start the run empty/idle + put 0 into gLiftN + put empty into gLift + put empty into gLiftMin + put empty into gLiftMax + put empty into gLiftSpeed + put empty into gLiftVX -- per-critter speed + squash frame + facing polarity (slime columns) put empty into gSlimeSpeed put empty into gSlimeFlat @@ -740,7 +789,7 @@ command pfStartGame b2kPlayerSet "dashMs", 150 b2kPlayerSet "dashCooldownMs", 420 b2kPlayerSet "duckScale", 0.6 -- DOWN now CRAWLS (shorter hitbox) under low gaps - b2kPlayerSet "platformCarry", 1 -- ride the moving platforms (L2 lift, L3 raft) + b2kPlayerSet "platformCarry", 1 -- ride the moving platforms (L2 lift bay, L4 lava lift) put 75 into gIntroPan -- a splash beat (~1.2 s), then control -- ===== LEVEL CAST (coins, enemies, checkpoint, the goal flag) ===== switch gLevel @@ -1472,7 +1521,7 @@ end pfMakeSpikes -- The goal flag: BLUE while coins remain, GOLD (pfGainCoin) when the -- level is sweepable - touch it then to clear the level (or win, on --- level three). +-- level four). command pfMakeGoal pX, pY local tRef put empty into tRef @@ -1632,13 +1681,41 @@ command pfMakeWoodCrate pX, pY end if end pfMakeWoodCrate +-- A moving-platform LIFT (the Wave 5 platform-carry showcase): a KINEMATIC +-- box at (pX,pY) that patrols horizontally pMinX..pMaxX at pSpeed px/sec. A +-- grounded player standing on it INHERITS its velocity (b2kPlayerSet +-- "platformCarry" is on for the whole showcase), so it ferries the hero +-- across the hazard below. It MUST move by VELOCITY, not by repositioning: +-- the carry reads the body's vx, and a position-driven kinematic reports +-- zero velocity (no carry). Sleep is disabled so it never naps at a turn. +-- A kinematic box is a graphic (no rotation, gotcha 23), so a plain visible +-- slab reads fine - the metal-deck colour suits a machine bay. Wiped with +-- the world (b2kTeardown/b2kClear) like every spawned body. +command pfMakeLift pX, pY, pW, pH, pMinX, pMaxX, pSpeed + local tRef, tIdx + b2kSpawnBox pX, pY, pW, pH, "120,128,140" + put the result into tRef + if tRef is empty then exit pfMakeLift + add 1 to gLiftN + put gLiftN into tIdx + b2kSetKinematic tRef + b2kSetSleepEnabled tRef, false -- a lift that naps at a turn would strand its rider + b2kSetVelocity tRef, pSpeed, 0 -- initial drift toward pMaxX (signed pSpeed) + put tRef into gLift[tIdx] + put pMinX into gLiftMin[tIdx] + put pMaxX into gLiftMax[tIdx] + put pSpeed into gLiftSpeed[tIdx] + put pSpeed into gLiftVX[tIdx] -- the last vx written (write-on-change cache) +end pfMakeLift + -- ===================================================================== --- LEVEL 1 - "GREEN HILLS" (6336px). Movement and the Wave 1 toys: +-- LEVEL 1 - "GREEN HILLS" (8640px). Movement and the Wave 1 toys: -- springboard + sky coin, the bonk row, the one-way bridge over the -- spike slime, the slope mound, two one-way clouds, the spike pit, a --- skittering MOUSE and a flying ladybug, then the showcase finale: a --- sagging ROPE BRIDGE (hinged planks) over a real chasm and a ladybug --- ambling the far meadow to the flag. +-- skittering MOUSE, the Wave 5 CRAWL TUNNEL (duck under a low overhang +-- for a hidden coin) and a flying ladybug, then the showcase finale: a +-- sagging ROPE BRIDGE (hinged planks) over a real chasm, the crusher +-- alley and the hilltop swim pool to the flag. -- ===================================================================== command pfL1Scene local tX @@ -1681,6 +1758,24 @@ command pfL1Scene set the foregroundColor of it to "70,190,110" b2kCamAdopt the long id of graphic "pf_cloudledgeC" end if + -- the CRAWL TUNNEL (level 1's Wave 5 beat - DOWN now reshapes the capsule + -- shorter, FEET-ANCHORED, so the hero can CRAWL under a low overhang a + -- standing hero cannot clear). The bar bottom is at y515: the standing + -- 76px capsule (head ~y500) is BLOCKED, the ducked ~46px one (head ~y530) + -- slips through the ~61px gap with margin to spare. A coin waits inside + -- (cast). The bar is only ~60px tall, so a jump/double-jump also tops it + -- (no dead end) - but the coin under it is the crawl's reward. Built as a + -- visible earth bar (a solid grass block, like the bat-bar overhang on L4). + pfSlab "pf_crawlbar", 3300, 455, 3492, 515 + set the backgroundColor of graphic "pf_crawlbar" to "90,150,72" + set the visible of graphic "pf_crawlbar" to true + b2kCamAdopt the long id of graphic "pf_crawlbar" + if gAssetsOK is true and b2kSheetHasFrame("tiles", "terrain_grass_block_center") then + set the visible of graphic "pf_crawlbar" to false -- the tiles carry the face + pfTile "terrain_grass_block_center", 3300, 451 + pfTile "terrain_grass_block_center", 3364, 451 + pfTile "terrain_grass_block_center", 3428, 451 + end if pfMakeSpikes 2560, 2752 -- the one-way bridge (GHOST RULE: the chain runs one tile past the -- deck on each side - 688..1072 under art 752..1008) @@ -1881,6 +1976,7 @@ command pfL1Cast pfMakeCoin 2876, 500 -- the second act's slime beat pfMakeCoin 3076, 500 -- the skittering mouse's beat pfMakeCoin 3232, 392 -- on the rest cloud + pfMakeCoin 3396, 548 -- inside the CRAWL TUNNEL: duck (DOWN) to slip under for it pfMakeCoin 3616, 500 -- the last meadow slime's beat pfMakeCoin 4128, 510 -- mid-bridge: grab it as the planks dip pfMakeCoin 4456, 500 -- the far meadow's ladybug beat @@ -1968,13 +2064,14 @@ command pfL1Cast end pfL1Cast -- ===================================================================== --- LEVEL 2 - "THE WORKS" (4672px). The machines: crate + button gate, +-- LEVEL 2 - "THE WORKS" (5952px). The machines: crate + button gate, -- the Wave 2 ladder to its bonus ledge, the stand-to-flip saw lever, -- both saws, a crawling WORM, chained thwomps, a breather cloud with its -- bee, a SECOND machine bay (another chained crusher + an always-on --- sweeping saw), then the yellow key and the walled door, stone steps to --- the flag. Spaced so every machine is its own beat (round-6: cramped --- reads as "what IS all this?"). +-- sweeping saw), the Wave 5 LIFT BAY (the signature: ride the moving deck +-- over a grinder), then the crusher alley, the yellow key and the walled +-- door, stone steps to the flag. Spaced so every machine is its own beat +-- (round-6: cramped reads as "what IS all this?"). -- ===================================================================== command pfL2Scene local tX, tRef @@ -2071,8 +2168,46 @@ command pfL2Scene -- crusher + the always-on sweeping saw are built in the CAST) if gAssetsOK is true and b2kSheetHasFrame("tiles", "bush") then pfTile "sign", 2400, 512 - pfTile "bush", 3160, 512 - end if + pfTile "bush", 2720, 512 -- moved clear of the LIFT BAY (3010..3210) + end if + -- ===== THE LIFT BAY (level 2's signature finale - the Wave 5 PLATFORM- + -- CARRY showcase). A GRINDER strip runs along the floor; two boarding + -- pedestals flank it. The moving LIFT (built in the cast) shuttles the + -- pedestal tops, so you jump up onto the left pedestal, RIDE the deck + -- across the grinder (its velocity carries you - stand still and you + -- drift with it), grab the mid-bay coin, and step off the right pedestal. + -- The grinder hurts (pfOuch knockback, recoverable) so the floor route is + -- blocked; but a running DOUBLE JUMP also clears the ~200px, so the bay is + -- never a dead end. Built as scenery (behind the hero). ===== + pfSlab "pf_liftpedL", 2920, 489, 3010, 576 + pfSlab "pf_liftpedR", 3210, 489, 3300, 576 + set the backgroundColor of graphic "pf_liftpedL" to "96,104,118" + set the backgroundColor of graphic "pf_liftpedR" to "96,104,118" + set the visible of graphic "pf_liftpedL" to true + set the visible of graphic "pf_liftpedR" to true + b2kCamAdopt the long id of graphic "pf_liftpedL" + b2kCamAdopt the long id of graphic "pf_liftpedR" + -- the grinder: a visible danger strip on the floor + a hurt sensor over + -- it (the y548 top sits at the floor line, like the lava sensor, so it + -- catches a walker but never the deck/rider gliding above at y~500) + create graphic "pf_grinder" + set the style of it to "rectangle" + set the rect of it to 3010, 556, 3210, 576 + set the filled of it to true + set the backgroundColor of it to "200,70,70" + b2kCamAdopt the long id of graphic "pf_grinder" + create graphic "pf_grindersens" + set the style of it to "rectangle" + set the rect of it to 3016, 548, 3204, 632 + set the visible of it to false + set the uPfHazardFlag of the long id of graphic "pf_grindersens" to true + b2kAddSensor the long id of graphic "pf_grindersens", "box" + -- the bay's moving deck: a kinematic platform shuttling 3020..3200 at + -- 60px/s between the pedestals. A grounded hero on it inherits its + -- velocity (platformCarry), so it ferries him over the grinder to the far + -- pedestal. Built here in the SCENE (before the hero) so the deck layers + -- BEHIND him - he rides ON it, drawn in front, never hidden by the deck. + pfMakeLift 3020, 500, 96, 22, 3020, 3200, 60 -- a THIRD machine run: a snail + another chained crusher (in the cast) -- under a one-way cloud (ghost-padded a tile past the art each side) b2kSmoothGround "3956,448" & cr & "3892,448" & cr & "3700,448" & cr & "3636,448" @@ -2130,6 +2265,7 @@ command pfL2Cast pfMakeCoin 2210, 392 -- on the breather cloud (the bee patrols it) pfMakeCoin 2680, 500 -- past the second bay's chained crusher pfMakeCoin 2880, 448 -- above the second sweeping saw: time the hop + pfMakeCoin 3110, 465 -- mid-LIFT-BAY: grab it as the deck carries you across the grinder pfMakeCoin 3300, 500 -- the third bay's snail (shell it, kick it) pfMakeCoin 3600, 500 -- beneath the third chained crusher: grab it as you dash under pfMakeCoin 3796, 392 -- up on the third bay's one-way cloud @@ -2219,12 +2355,14 @@ command pfL2Cast end pfL2Cast -- ===================================================================== --- LEVEL 3 - "FROZEN CITADEL" (5312px). Everything at once, on ICE: --- snow ground with quarter-strength player acceleration (momentum!), a --- ladybug, the spring arcs over the first spiked pit, a bonk row, the --- sweeping saw, a second spiked pit, a thwomp guarding the RED key, --- then the GLACIER RUN where a ROLLING BOULDER slides the ice head-on --- (leap it!), the red walled door, snow steps to the flag. +-- LEVEL 3 - "FROZEN CITADEL" (6592px). Everything at once, on ICE: +-- snow ground with quarter-strength player acceleration (momentum!), the +-- Wave 5 WALL-JUMP SHAFT (climb two ice pillars to a top coin, or +-- double-jump the slot), a ladybug, the spring arcs over the first spiked +-- pit, a bonk row, the sweeping saw, a second spiked pit, a thwomp +-- guarding the RED key, then the GLACIER RUN where a ROLLING BOULDER +-- slides the ice head-on (leap it!), the blue crusher alley, the red +-- walled door, snow steps to the flag. -- ===================================================================== command pfL3Scene local tX @@ -2258,6 +2396,24 @@ command pfL3Scene end if pfMakeSpikes 768, 960 pfMakeSpikes 1792, 1984 + -- ===== THE WALL-JUMP SHAFT (level 3's signature vertical beat - the + -- Wave 5 WALL-SLIDE + WALL-JUMP). Two FLOATING ice pillars form a slot + -- over the opening run: hug one, jump to launch up-and-away, alternate + -- pillar to pillar to climb to the coin at the top. The pillars float + -- (bottoms at y460), so the hero walks UNDER them at ground level + -- unobstructed - the main path is never blocked. And the slot coin sits + -- where a straight DOUBLE JUMP up the gap also reaches it, so a player + -- who has not mastered wall-jumps is never locked out (no dead end, the + -- coin is never trick-only). Wall-jumping is just the elegant way up. + -- Built on ground1 (the run to the spring), before the first pit. ===== + pfSlab "pf_wjShaftL", 300, 220, 318, 460 + pfSlab "pf_wjShaftR", 410, 220, 428, 460 + set the backgroundColor of graphic "pf_wjShaftL" to "150,180,210" + set the backgroundColor of graphic "pf_wjShaftR" to "150,180,210" + set the visible of graphic "pf_wjShaftL" to true + set the visible of graphic "pf_wjShaftR" to true + b2kCamAdopt the long id of graphic "pf_wjShaftL" + b2kCamAdopt the long id of graphic "pf_wjShaftR" -- the glacier run (the level's second act): a snow cloud rest above -- the second sweeping saw's stretch b2kSmoothGround "3008,448" & cr & "2944,448" & cr & "2752,448" & cr & "2688,448" @@ -2331,6 +2487,7 @@ command pfL3Cast -- with each rebuild, so only this level skates) b2kPlayerSet "accel", 260 b2kPlayerSet "airAccel", 480 + pfMakeCoin 364, 340 -- atop the WALL-JUMP SHAFT: wall-jump up the slot (or double-jump straight up) pfMakeCoin 1100, 500 -- the ladybug's beat, after the spring landing pfMakeCoin 1300, 500 -- under the bonk row pfMakeCoin 1640, 448 -- above the first sweeping saw @@ -2407,13 +2564,15 @@ command pfL3Cast end pfL3Cast -- ===================================================================== --- LEVEL 4 - "HAUNTED HOLLOW" (5376px). Wave 3's bestiary in the purple +-- LEVEL 4 - "HAUNTED HOLLOW" (6656px). Wave 3's bestiary in the purple -- biome: a MIMIC field (grass blocks that do not belong), the SNAIL -- whose kicked shell bowls a slime over, the BAT overhang, a pit, a long -- FOUR-burrow PIRANHA row, the GHOST stalking the back half, a pair of --- faced CRUSHERS around a lava strip, a smouldering FIRE SLIME, then the --- POWDER KEG bay (an explosive barrel + woodpile, scattered by --- b2kExplode), purple steps to the flag. Every beat spaced per the +-- faced CRUSHERS around a lava strip crossed by the Wave 5 LAVA LIFT +-- (ride the deck over the lava, or double-jump it), a smouldering FIRE +-- SLIME, then the POWDER KEG bay (an explosive barrel + woodpile, +-- scattered by b2kExplode), the red crusher alley (the signature +-- gauntlet), purple steps to the flag. Every beat spaced per the -- layout law (~100px+ of clear air). -- ===================================================================== command pfL4Scene @@ -2474,6 +2633,13 @@ command pfL4Scene pfTile "sign", 2980, 512 -- a way-marker past the piranha row end if pfMakeLava 3136, 3264 + -- the LAVA LIFT (the Wave 5 platform-carry showcase, hollow edition): a + -- moving deck that ferries the hero across the lava strip between the two + -- faced crushers. Its patrol (3150..3250) stays clear of either crusher's + -- drop zone, so it never meets a slam. Ride it for the mid-lava coin; or, + -- since a running DOUBLE JUMP clears the 128px strip, leap it the old way + -- (no dead end). A slip onto the lava is a knockback (pfOuch), recoverable. + pfMakeLift 3150, 510, 80, 22, 3150, 3250, 70 -- the POWDER KEG bay (scenery, so it layers behind the hero): an -- explosive barrel amid a woodpile of loose crates. Coming near lights -- the fuse and b2kExplode (the kit's native radial blast, dark in the @@ -2526,9 +2692,9 @@ command pfL4Cast pfMakeCoin 3892, 500 -- past the powder keg (the lure: mind the blast) pfMakeCoin 4000, 500 -- the second snail's beat (shell it, kick it!) pfMakeCoin 4220, 500 -- the slime its bowled shell can flatten - pfMakeCoin 4440, 500 -- a THIRD snail's beat + pfMakeCoin 4440, 500 -- on the run up to the chained crusher pfMakeCoin 4600, 500 -- beneath the chained crusher: grab it as you dash under - pfMakeCoin 4780, 500 -- a FOURTH snail's beat + pfMakeCoin 4780, 500 -- past the crusher, into the red alley pfMakeCoin 5090, 500 -- threading the RED CRUSHER alley: grab between drops pfMakeCoin 5270, 500 -- ...and between the next pair of crushers pfMakeCoin 5450, 500 -- ...and the last gap @@ -2560,11 +2726,10 @@ command pfL4Cast -- and a slime its sliding shell flattens - the haunted level escalates pfMakeSnail 8, 4000, 3940, 4060 pfMakeSlime 9, "normal", 4220, 4160, 4280, 576 - -- the haunted FINALE: snails used liberally (two more) around the - -- classic chained-weight THWOMP coming back among the faced crushers - pfMakeSnail 10, 4440, 4380, 4500 + -- the haunted FINALE: the classic chained-weight THWOMP comes back among + -- the faced crushers. (Trimmed from four snails to two for the showcase + -- pass - the bowling-lane snail above is plenty; the alley reads cleaner.) pfMakeThwomp 3, 4600 - pfMakeSnail 11, 4780, 4720, 4840 -- the RED CRUSHER ALLEY (the hollow's marquee gauntlet): a row of four -- danger-block thwomps you time your run beneath - each drops as you near -- it, so keep moving and snatch the coins in the gaps. The haunted level's @@ -2697,6 +2862,7 @@ on b2kFrame pfTickPlants pfTickGhost pfTickBoulder + pfTickLift pfTickBarrel -- HUD at 4 Hz, not 60: the ms readout changes every frame, so an -- unthrottled HUD re-sets the field text (= engine relayout+redraw) @@ -3373,6 +3539,32 @@ command pfTickBoulder end repeat end pfTickBoulder +-- Patrol the moving-platform lifts: bounce each off its min/max endpoints. +-- WRITE-ON-CHANGE only - a lift must keep moving (so it asserts velocity), +-- but re-setting an unchanged vx every frame is the parked-shell mistake +-- (gotcha 17: a needless wake + 2 FFI per frame). The rider inherits the +-- velocity automatically (platformCarry), so there is no per-rider work +-- here. One clock-free pass: endpoints are positions, no time math needed. +command pfTickLift + local i, tPos, tX, tV + if gLiftN is 0 or gLiftN is empty then exit pfTickLift + set the itemDelimiter to comma + repeat with i = 1 to gLiftN + if gLift[i] is empty then next repeat + put b2kPosition(gLift[i]) into tPos + if tPos is empty then next repeat + put item 1 of tPos into tX + put gLiftVX[i] into tV + -- past an endpoint while still heading that way: reverse the drift + if tV > 0 and tX >= gLiftMax[i] then put - gLiftSpeed[i] into tV + if tV < 0 and tX <= gLiftMin[i] then put gLiftSpeed[i] into tV + if tV is not gLiftVX[i] then + b2kSetVelocity gLift[i], tV, 0 -- the ONLY per-lift write, at the turn + put tV into gLiftVX[i] + end if + end repeat +end pfTickLift + -- The powder keg: idle until the hero is near, then the fuse lights -- (bomb_active); a beat later b2kExplode scatters the woodpile, knocks the -- hero if he lingered, and the barrel parks off-world (the door-clear From 8eebd9bdd74dac7cbc6529fcc2156a05ae83b736 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 13:59:55 +0000 Subject: [PATCH 06/19] Kit: persistent spritesheet cache - load atlases once, not per level The platformer reloaded all five Kenney atlases on every level rebuild: b2kTeardown wiped the sheets, then the level re-decoded each PNG, re-parsed each XML, and re-sliced every frame - the costliest thing the Kit does, repeated per level. That is the between-levels load time. Sheets are ASSETS, not world state (b2kClear already keeps them, and sounds already survive teardown for the same reason). New opt-in b2kSheetPersist makes them survive b2kTeardown too: - Idempotent loaders: an identical b2kSheetLoad/LoadAtlas/FromImage is a no-op (matched by a stored source path), so a level rebuild reuses the cache. Sliced frames survive too, so re-warming is free. - Cross-session: source images keep their deterministic name (b2ksheet_) + a uB2kSrcPath tag, and frames are sliced into deterministically named images (b2kfr__). A SAVED stack carries them, so on reopen the load adopts the in-stack image - no PNG decode, no re-slice - instead of importing from disk. - b2kTeardown clears only sprite instances + dead viewports when persisting (b2kSpriteSweepOrphans gains pKeepAssets); b2kSheetsWipe is still the explicit purge. All gated on the persist flag (default off), so the demo, builder, slingshot, spike and the harness are byte-for-byte unchanged. The platformer turns it on at openCard and purges on Shift+Reset. Self-test v14: stTestSheetPersist (survive-teardown + idempotent-reload + purge); stNewWorld resets the flag for isolation. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 113 ++++++++++++-- examples/box2dxt-demo.livecodescript | 113 ++++++++++++-- examples/box2dxt-platformer.livecodescript | 124 +++++++++++++-- examples/box2dxt-selftest.livecodescript | 143 ++++++++++++++++-- examples/box2dxt-slingshot.livecodescript | 113 ++++++++++++-- examples/box2dxt-spike-gamekit.livecodescript | 113 ++++++++++++-- src/box2dxt-kit.livecodescript | 113 ++++++++++++-- 7 files changed, 753 insertions(+), 79 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index 2dd49b8..f37860b 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -200,6 +200,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -360,7 +362,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -2288,8 +2301,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -2299,9 +2333,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -2309,10 +2347,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -2362,6 +2406,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -2387,6 +2434,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -2795,6 +2843,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -2819,6 +2868,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -2830,7 +2880,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -2849,9 +2899,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -2872,11 +2927,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -2891,6 +2956,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -2936,11 +3002,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -2966,7 +3052,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -2995,11 +3081,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 3c83de4..b980fa6 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -190,6 +190,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -350,7 +352,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -2278,8 +2291,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -2289,9 +2323,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -2299,10 +2337,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -2352,6 +2396,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -2377,6 +2424,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -2785,6 +2833,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -2809,6 +2858,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -2820,7 +2870,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -2839,9 +2889,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -2862,11 +2917,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -2881,6 +2946,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -2926,11 +2992,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -2956,7 +3042,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -2985,11 +3071,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 116ce9b..f03b4c0 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -286,6 +286,12 @@ constant kHeroSheetB64 = "iVBORw0KGgoAAAANSUhEUgAAAYAAAAEACAYAAAC6d6FnAAAI/ElEQV -- Lifecycle -- ===================================================================== on openCard + -- Load the five Kenney atlases ONCE, not every level: persisted sheets + -- survive b2kTeardown (like sounds), so a level rebuild reuses them + -- instead of re-decoding/re-parsing/re-slicing. They also ride along in + -- a SAVED stack, so a reopened stack skips the disk import entirely + -- (Shift+Reset purges the cache to re-pick the art folder). + b2kSheetPersist true if the uPfUIVersionTag of this stack is not kPfUIVersion then buildPfUI if gStarted is true then b2kStart @@ -515,7 +521,10 @@ command pfStartGame local tRef, tW, tH, tDY, tX, tS if gBuilding is true then exit pfStartGame -- ignore R-mash mid-rebuild put true into gBuilding - if the shiftKey is down then set the uSpriteSheetFolderPath of this stack to empty + if the shiftKey is down then + set the uSpriteSheetFolderPath of this stack to empty + b2kSheetsWipe -- drop the cached sheets so a re-picked folder loads fresh + end if try b2kClear -- spawned bodies (confetti, the crate) go catch tErr -- while the world still exists @@ -4239,6 +4248,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -4399,7 +4410,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -6327,8 +6349,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -6338,9 +6381,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -6348,10 +6395,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -6401,6 +6454,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -6426,6 +6482,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -6834,6 +6891,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -6858,6 +6916,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -6869,7 +6928,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -6888,9 +6947,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -6911,11 +6975,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -6930,6 +7004,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -6975,11 +7050,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -7005,7 +7100,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -7034,11 +7129,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index f8dea50..ace56b6 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "13" -- bump on EVERY harness change: the report +constant kStHarnessV = "14" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -119,6 +119,7 @@ command stRunAll stRun "stTestCoyoteBuffer" stRun "stTestSprites" stRun "stTestSheetExtras" + stRun "stTestSheetPersist" stRun "stTestSounds" stRun "stTestCamera" stRun "stTestCamScrolledWrite" @@ -189,6 +190,7 @@ end stAssert -- nothing advances except our hand-stepping. command stNewWorld pName b2kInputInjectOff + b2kSheetPersist false -- isolate tests: a leaked persist flag must not keep sheets b2kTeardown stWipe put 0 into gFellCount @@ -1388,6 +1390,32 @@ command stTestPlayerHelpers (abs(stVX(tRef)) < 60) end stTestPlayerHelpers +-- b2kSheetPersist: a loaded sheet survives b2kTeardown (so a multi-level +-- game loads its atlases ONCE), an identical reload is a no-op, and the +-- explicit b2kSheetsWipe still purges it. +command stTestSheetPersist + local tBefore, tReload + stNewWorld "sheets: persist across teardown + idempotent reload" + b2kSheetPersist true + if there is an image "st_sheetsrc" then delete image "st_sheetsrc" + create image "st_sheetsrc" + set the visible of image "st_sheetsrc" to false + set the rect of image "st_sheetsrc" to 0, 0, 32, 16 + b2kSheetFromImage "stpersist", the long id of image "st_sheetsrc", 16, 16 + put the result into tBefore + stAssert "grid sheet loaded with 2 frames (got " & tBefore & ")", (tBefore is 2) + stAssert "frame 1 present before teardown", (b2kSheetHasFrame("stpersist", 1) is true) + b2kTeardown + stAssert "sheet SURVIVES b2kTeardown (persist on)", (b2kSheetHasFrame("stpersist", 1) is true) + b2kSheetFromImage "stpersist", the long id of image "st_sheetsrc", 16, 16 + put the result into tReload + stAssert "identical reload is a cache no-op (still 2 frames, got " & tReload & ")", (tReload is 2) + b2kSheetsWipe + stAssert "b2kSheetsWipe still purges the persisted sheet", (b2kSheetHasFrame("stpersist", 1) is false) + b2kSheetPersist false -- restore the default so the later tests teardown clean + if there is an image "st_sheetsrc" then delete image "st_sheetsrc" +end stTestSheetPersist + -- ===================================================================== -- The Kit (embedded verbatim; regenerated by tools/sync-embedded-kit.py -- - do not edit between the sentinels) @@ -1559,6 +1587,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -1719,7 +1749,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -3647,8 +3688,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -3658,9 +3720,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -3668,10 +3734,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -3721,6 +3793,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -3746,6 +3821,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -4154,6 +4230,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -4178,6 +4255,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -4189,7 +4267,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -4208,9 +4286,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -4231,11 +4314,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -4250,6 +4343,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -4295,11 +4389,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -4325,7 +4439,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -4354,11 +4468,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index 26811c9..8cbcac1 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -947,6 +947,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -1107,7 +1109,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -3035,8 +3048,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -3046,9 +3080,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -3056,10 +3094,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -3109,6 +3153,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -3134,6 +3181,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -3542,6 +3590,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -3566,6 +3615,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -3577,7 +3627,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -3596,9 +3646,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -3619,11 +3674,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -3638,6 +3703,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -3683,11 +3749,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -3713,7 +3799,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -3742,11 +3828,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 7e1469d..8cd0c1e 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -1527,6 +1527,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -1687,7 +1689,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -3615,8 +3628,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -3626,9 +3660,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -3636,10 +3674,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -3689,6 +3733,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -3714,6 +3761,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -4122,6 +4170,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -4146,6 +4195,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -4157,7 +4207,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -4176,9 +4226,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -4199,11 +4254,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -4218,6 +4283,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -4263,11 +4329,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -4293,7 +4379,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -4322,11 +4408,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index f8470e4..4c8abd8 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -164,6 +164,8 @@ local sSheetFlip -- sheet -> frame key -> mirrored image id (lazy) local sSheetData -- sheet -> cached source imageData (freed on teardown) local sSheetAlpha -- sheet -> cached source alphaData local sSheetScale -- sheet -> display scale factor (default 1; engine-resampled at slice time) +local sSheetPath -- sheet -> its source path/ref (the idempotent-reload + reuse key) +local sSheetKeep -- true = sheets survive b2kTeardown (b2kSheetPersist); assets, like sounds local sAnimList -- "sheet|anim" -> CR list of frame keys local sAnimFPS -- "sheet|anim" -> frames per second local sAnimLoop -- "sheet|anim" -> true/false @@ -324,7 +326,18 @@ command b2kTeardown if sWorld is not empty then b2DestroyWorld sWorld put empty into sWorld b2kPlayerForget true -- full: a teardown wipes the tuning too - b2kSheetsWipe -- sprites first: their stored long ids include the group + if sSheetKeep is true then + -- sheets are ASSETS that SURVIVE teardown (b2kSheetPersist), exactly + -- like sounds: clear only the sprite INSTANCES + dead viewports and + -- keep the sheet cache, so a level rebuild reuses it instead of + -- re-decoding/re-parsing/re-slicing (the costliest thing the Kit does). + b2kSpritesClear + b2kSpriteSweepOrphans true + put empty into sSheetData -- the imageData cache re-derives lazily; + put empty into sSheetAlpha -- free it like the full wipe would + else + b2kSheetsWipe -- sprites first: their stored long ids include the group + end if -- sounds deliberately SURVIVE teardown: clips are tiny (KBs) and -- deterministic, and re-synthesis cost a fifth of a second on every -- reset. b2kSoundsWipe purges them when you really want them gone. @@ -2252,8 +2265,29 @@ end b2kFrameMS -- BUTTON whose icon is the current frame's image -- a frame switch is one -- property set, and every sprite of a sheet shares the same frame images. -- Mirrored (left-facing) frames are flip-clones, also made lazily. --- Sheets persist until b2kTeardown; sprites are Kit-created controls, so --- b2kClear removes them like everything else the Kit spawned. +-- Sheets persist until b2kTeardown (unless b2kSheetPersist is on; see below); +-- sprites are Kit-created controls, so b2kClear removes them like everything +-- else the Kit spawned. + +-- Loaded sheets are ASSETS, not world state. By default b2kTeardown wipes +-- them (a full reset), but a multi-LEVEL game reloads the same atlases every +-- rebuild -- the costliest thing the Kit does (decode each PNG, parse each +-- XML, re-slice every frame). Turn this ON and sheets SURVIVE b2kTeardown, +-- exactly like synthesized sounds do, so they load ONCE per session: a level +-- rebuild reuses them (an identical b2kSheetLoad/LoadAtlas is then a no-op), +-- and re-warmed frames are already sliced. The Kit's source images are named +-- deterministically (b2ksheet_) and tagged with their file path, so a +-- SAVED stack carries them: on reopen the load reuses the in-stack image +-- (skipping the expensive decode) instead of re-importing from disk. Call +-- b2kSheetsWipe to force a clean reload (e.g. after the user picks a new +-- asset folder). OFF by default, so single-shot examples are unchanged. +command b2kSheetPersist pFlag + put (pFlag is not false) into sSheetKeep +end b2kSheetPersist + +function b2kSheetPersists + return (sSheetKeep is true) +end b2kSheetPersists -- Register an image FILE as a uniform grid of pFW x pFH frames, numbered -- 1..N left-to-right, top-to-bottom. Reports the frame count. Sheets that @@ -2263,9 +2297,13 @@ end b2kFrameMS -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing local tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put pPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -2273,10 +2311,16 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing + local tRef + put the long id of pImgRef into tRef + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if b2kSheetForget pName - put the long id of pImgRef into sSheetSrc[pName] + put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing + put tRef into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -2326,6 +2370,9 @@ end b2kSheetAddFrame -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath local tRef, tXml, tLine, tNm, tX, tY, tW, tH + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then + return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + end if if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." @@ -2351,6 +2398,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] + put pPngPath into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -2759,6 +2807,7 @@ command b2kSheetForget pName delete variable sSheetData[pName] delete variable sSheetAlpha[pName] delete variable sSheetScale[pName] + delete variable sSheetPath[pName] repeat for each key tKey in sAnimList if char 1 to (the number of chars of pName) + 1 of tKey is pName & "|" then delete variable sAnimList[tKey] @@ -2783,6 +2832,7 @@ command b2kSheetsWipe put empty into sSheetData put empty into sSheetAlpha put empty into sSheetScale + put empty into sSheetPath put empty into sAnimList put empty into sAnimFPS put empty into sAnimLoop @@ -2794,7 +2844,7 @@ end b2kSheetsWipe -- script is pasted in), but the controls persist -- registry cleanup can't -- see them, so a reopened stack would show ghost sprites frozen on their -- last frame. Swept by name prefix on every teardown. -command b2kSpriteSweepOrphans +command b2kSpriteSweepOrphans pKeepAssets local tAgain, i, tName, tHit put true into tAgain repeat while tAgain @@ -2813,9 +2863,14 @@ command b2kSpriteSweepOrphans end if put false into tHit if char 1 to 7 of tName is "b2kspr_" then put true into tHit - if char 1 to 6 of tName is "b2kfr_" then put true into tHit - if char 1 to 6 of tName is "b2kfl_" then put true into tHit - if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + -- sheet ASSET images (sources + sliced frames + flips) are KEPT + -- when persisting (b2kSheetPersist); only sprite instances + dead + -- viewports go, so the sheet cache survives the teardown + if pKeepAssets is not true then + if char 1 to 6 of tName is "b2kfr_" then put true into tHit + if char 1 to 6 of tName is "b2kfl_" then put true into tHit + if char 1 to 9 of tName is "b2ksheet_" then put true into tHit + end if if tHit then delete control i of this card put true into tAgain @@ -2836,11 +2891,21 @@ end b2kSpriteSweepOrphans -- lockLoc only AFTER the content, so the control has auto-sized first. function b2kSheetSourceFromFile pName, pPath local tImg, tData + put "b2ksheet_" & pName into tImg + -- REUSE: a source image already decoded for this exact file -- earlier + -- this session, or carried inside a SAVED stack (b2kSheetPersist) -- is + -- the cache. Adopt it in place and skip the costly binfile decode. + if sSheetKeep is true and there is an image tImg \ + and the uB2kSrcPath of image tImg is pPath and the width of image tImg >= 2 then + set the lockLoc of image tImg to true + put the long id of image tImg into sSheetSrc[pName] + put true into sSheetOwned[pName] + return sSheetSrc[pName] + end if b2kSheetForget pName if there is no file pPath then return empty put URL ("binfile:" & pPath) into tData if tData is empty then return empty - put "b2ksheet_" & pName into tImg if there is an image tImg then delete image tImg create image tImg set the visible of it to false @@ -2855,6 +2920,7 @@ function b2kSheetSourceFromFile pName, pPath return empty end if set the lockLoc of image tImg to true + set the uB2kSrcPath of image tImg to pPath -- the cache key for next time put the long id of image tImg into sSheetSrc[pName] put true into sSheetOwned[pName] return sSheetSrc[pName] @@ -2900,11 +2966,31 @@ end b2kXmlAttr -- Internal: make sure a frame's sliced image exists (lazy, cached). The -- source pixels are fetched once per sheet and kept until teardown. +-- Internal: the 1-based position of a frame key in its sheet's key list -- +-- a STABLE id (load order is deterministic) used to name sliced frames so a +-- SAVED stack can find and reuse them (b2kSheetPersist), never duplicating. +function b2kSheetKeyIndex pSheet, pKey + local i, tK + put 0 into i + repeat for each line tK in sSheetKeys[pSheet] + add 1 to i + if tK is pKey then return i + end repeat + return 0 +end b2kSheetKeyIndex + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the + -- existing frame image rather than re-cut it from the source. + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] put the alphaData of sSheetSrc[pSheet] into sSheetAlpha[pSheet] @@ -2930,7 +3016,7 @@ command b2kSheetEnsureIcon pSheet, pKey if the number of bytes in tFA is not tW * tH then put empty into tFA -- no usable alpha: ship the frame fully opaque end if - put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName + if there is an image tName then delete image tName -- never duplicate a deterministic slice create image tName set the visible of it to false set the lockLoc of it to true @@ -2959,11 +3045,16 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if sSheetKeep is true and there is an image tName then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip try + if there is an image tName then delete image tName clone image id sSheetIcon[pSheet][pKey] - put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName set the name of it to tName set the visible of it to false flip image tName horizontal From 02ffa17cd0e7543ec10da7a1b9e20348abed1fe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:00:44 +0000 Subject: [PATCH 07/19] docs: b2kSheetPersist (the spritesheet cache) in the reference + changelog https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- CHANGELOG.md | 14 ++++++++++++++ docs/kit-reference.md | 1 + 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb40a78..a133e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,20 @@ The native shim's ABI is tracked separately by `b2Version()` (currently `4`). ### Added +- **Kit: a persistent spritesheet cache (`b2kSheetPersist`) — load atlases + once, not per level (statically verified + harness v14).** Opt-in (default + off, so every other example and the harness are byte-for-byte unchanged). + When on, loaded sheets are assets that **survive `b2kTeardown`** (like + synthesized sounds): a level rebuild reuses them instead of re-decoding + each PNG, re-parsing each XML, and re-slicing every frame — the costliest + work the Kit does, previously repeated on every level transition. An + identical `b2kSheetLoad`/`LoadAtlas`/`FromImage` becomes a no-op, sliced + frames survive, and because source/frame images are named deterministically + (`b2ksheet_` / `b2kfr__`, tagged with the file path) a + **saved stack** carries the cache — on reopen the load adopts the in-stack + images, skipping the disk import entirely. `b2kSheetsWipe` stays the + explicit purge. The **platformer** turns it on at `openCard` (Shift+Reset + purges) to cut its between-levels load time. - **Wave 5 (player actions II) — five new player-controller moves (statically verified + harness v13).** All **opt-in** through `b2kPlayerSet` knobs whose defaults leave the pre-Wave-5 controller diff --git a/docs/kit-reference.md b/docs/kit-reference.md index f45104c..4dab621 100644 --- a/docs/kit-reference.md +++ b/docs/kit-reference.md @@ -282,6 +282,7 @@ ghost sprites frozen on their last frame). | `b2kSheetFrames(name)` / `b2kSheetHasFrame(name, frame)` / `b2kSheetFrameNames(name)` | Frame count / existence / every frame key one per line (introspect an atlas you didn't make). | | `b2kSheetScale name, factor` | Display scale for the sheet's frames (default 1, range 0.05–8) — the engine resamples at slice time, so **any frame size displays at any sprite size**. Set it right after loading, before creating sprites or anims. | | `b2kSheetFrameSize(name, frame)` → "w,h" | A frame's display size (region × scale) — lay out tiles and platforms from this instead of hard-coding pixels. | +| `b2kSheetPersist flag` / `b2kSheetPersists()` | **Opt-in (default off).** When on, loaded sheets are treated as assets that **survive `b2kTeardown`** (like synthesized sounds) — so a multi-LEVEL game loads its atlases **once**, not on every rebuild (re-decoding/re-parsing/re-slicing is the costliest thing the Kit does). An identical reload becomes a no-op, and because the Kit's source/frame images are named deterministically (`b2ksheet_` / `b2kfr__`), a **saved stack** carries the cache: on reopen the load adopts the in-stack images instead of importing from disk. `b2kSheetsWipe` is still the explicit purge (e.g. after the user picks a different asset folder). | | `b2kAnimDef sheet, anim, frames, fps [,loop]` | Name an animation: `frames` is a comma list of names and/or indices, numeric ranges (`"1-8"`) expand. `loop` defaults true. | | `b2kSpriteNew sheet [,frame, x, y]` → control | Create a sprite showing `frame` (default: the sheet's first), sized to the frame. An ordinary Kit control: give it a body (`b2kAddCapsule …`) or bind it to one. | | `b2kSpriteFromGIF path [,x, y]` → control | An animated-GIF sprite (the engine plays it; play/stop/frame map to `repeatCount`/`currentFrame`). | From 1d5e7cc62e0bb14633c4c729e6ab4beb22757a9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:42:10 +0000 Subject: [PATCH 08/19] Kit: harden the spritesheet cache identity (review pass) Optimization + correctness follow-up to the persistent spritesheet cache, from a focused review of the cross-session reuse paths. Correctness (the saved-stack reuse was keyed too loosely): - Sliced frames are now stamped with a provenance signature (uB2kSig = source + grid/xml args + scale) and re-checked on reuse. Before, a slice was identified by (sheet name, frame index) and an existence check only, so on reopen a sheet NAME reused for different art -- e.g. the platformer uses "chars" for both the atlas and the placeholder -- could adopt the wrong saved frames, and a frame baked at one scale could be reused at another (wrong size). A mismatch now re-slices instead of showing stale pixels. - The loader idempotency key now includes ALL args (grid w/h/count/margin/ spacing, and the atlas xml path), not just the file path, so a reload that changes the grid or xml rebuilds instead of being silently skipped. Optimization: - The deterministic frame name (and its key-list scan) is now computed only when persisting; off the persist path the slicer uses a fresh unique name exactly as before, so the demo/builder/slingshot/spike are truly unchanged (not just gated -- byte-for-byte behavior). Self-test v15: stTestSheetPersist now also asserts that a changed grid and a changed source each force a rebuild. All static gates pass; embedded Kit re-synced. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 73 +++++++++----- examples/box2dxt-demo.livecodescript | 73 +++++++++----- examples/box2dxt-platformer.livecodescript | 75 +++++++++----- examples/box2dxt-selftest.livecodescript | 99 +++++++++++++------ examples/box2dxt-slingshot.livecodescript | 73 +++++++++----- examples/box2dxt-spike-gamekit.livecodescript | 73 +++++++++----- src/box2dxt-kit.livecodescript | 73 +++++++++----- 7 files changed, 371 insertions(+), 168 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index f37860b..ff5aaf5 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -2332,14 +2332,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -2347,16 +2348,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -2405,15 +2407,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -2434,7 +2437,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -3015,17 +3018,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -3073,6 +3094,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -3081,10 +3103,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -3094,6 +3120,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index b980fa6..8cb4ccf 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -2322,14 +2322,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -2337,16 +2338,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -2395,15 +2397,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -2424,7 +2427,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -3005,17 +3008,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -3063,6 +3084,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -3071,10 +3093,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -3084,6 +3110,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index f03b4c0..bb8ad6a 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -529,7 +529,7 @@ command pfStartGame b2kClear -- spawned bodies (confetti, the crate) go catch tErr -- while the world still exists end try - b2kTeardown -- wipes sheets, closes the camera, sweeps orphans + b2kTeardown -- clears the world + camera; cached sheets survive (persist) pfWipeStage put false into gHurtLock put false into gWon @@ -6380,14 +6380,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -6395,16 +6396,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -6453,15 +6455,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -6482,7 +6485,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -7063,17 +7066,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -7121,6 +7142,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -7129,10 +7151,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -7142,6 +7168,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index ace56b6..8e0a851 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "14" -- bump on EVERY harness change: the report +constant kStHarnessV = "15" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -1394,26 +1394,40 @@ end stTestPlayerHelpers -- game loads its atlases ONCE), an identical reload is a no-op, and the -- explicit b2kSheetsWipe still purges it. command stTestSheetPersist - local tBefore, tReload + local tN stNewWorld "sheets: persist across teardown + idempotent reload" b2kSheetPersist true if there is an image "st_sheetsrc" then delete image "st_sheetsrc" + if there is an image "st_sheetsrc2" then delete image "st_sheetsrc2" create image "st_sheetsrc" set the visible of image "st_sheetsrc" to false set the rect of image "st_sheetsrc" to 0, 0, 32, 16 b2kSheetFromImage "stpersist", the long id of image "st_sheetsrc", 16, 16 - put the result into tBefore - stAssert "grid sheet loaded with 2 frames (got " & tBefore & ")", (tBefore is 2) + put the result into tN + stAssert "grid sheet loaded with 2 frames (got " & tN & ")", (tN is 2) stAssert "frame 1 present before teardown", (b2kSheetHasFrame("stpersist", 1) is true) b2kTeardown stAssert "sheet SURVIVES b2kTeardown (persist on)", (b2kSheetHasFrame("stpersist", 1) is true) b2kSheetFromImage "stpersist", the long id of image "st_sheetsrc", 16, 16 - put the result into tReload - stAssert "identical reload is a cache no-op (still 2 frames, got " & tReload & ")", (tReload is 2) + put the result into tN + stAssert "identical reload is a cache no-op (still 2 frames, got " & tN & ")", (tN is 2) + -- the idempotency key includes the grid args: a DIFFERENT grid under the + -- same image must rebuild, not silently keep the old grid + b2kSheetFromImage "stpersist", the long id of image "st_sheetsrc", 32, 16 + put the result into tN + stAssert "a changed grid rebuilds (1 frame now, got " & tN & ")", (tN is 1) + -- a DIFFERENT source under the same name must rebuild too + create image "st_sheetsrc2" + set the visible of image "st_sheetsrc2" to false + set the rect of image "st_sheetsrc2" to 0, 0, 48, 16 + b2kSheetFromImage "stpersist", the long id of image "st_sheetsrc2", 16, 16 + put the result into tN + stAssert "a changed source rebuilds (3 frames now, got " & tN & ")", (tN is 3) b2kSheetsWipe stAssert "b2kSheetsWipe still purges the persisted sheet", (b2kSheetHasFrame("stpersist", 1) is false) b2kSheetPersist false -- restore the default so the later tests teardown clean if there is an image "st_sheetsrc" then delete image "st_sheetsrc" + if there is an image "st_sheetsrc2" then delete image "st_sheetsrc2" end stTestSheetPersist -- ===================================================================== @@ -3719,14 +3733,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -3734,16 +3749,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -3792,15 +3808,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -3821,7 +3838,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -4402,17 +4419,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -4460,6 +4495,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -4468,10 +4504,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -4481,6 +4521,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index 8cbcac1..ba1d036 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -3079,14 +3079,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -3094,16 +3095,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -3152,15 +3154,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -3181,7 +3184,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -3762,17 +3765,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -3820,6 +3841,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -3828,10 +3850,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -3841,6 +3867,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 8cd0c1e..9db385c 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -3659,14 +3659,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -3674,16 +3675,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -3732,15 +3734,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -3761,7 +3764,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -4342,17 +4345,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -4400,6 +4421,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -4408,10 +4430,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -4421,6 +4447,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 4c8abd8..8ac2108 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -2296,14 +2296,15 @@ end b2kSheetPersists -- NO grid and name regions yourself with b2kSheetAddFrame -- the path for -- packed sheets that have no Kenney-style XML. command b2kSheetLoad pName, pPath, pFW, pFH, pCount, pMargin, pSpacing - local tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + local tRef, tSig + put pPath & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same file+grid): reuse it end if put b2kSheetSourceFromFile(pName, pPath) into tRef if tRef is empty then return 0 b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put pPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoad @@ -2311,16 +2312,17 @@ end b2kSheetLoad -- grid sheet. The image is used in place and never deleted by the Kit. -- Same grid arguments as b2kSheetLoad (margin/spacing; 0x0 = no grid). command b2kSheetFromImage pName, pImgRef, pFW, pFH, pCount, pMargin, pSpacing - local tRef + local tRef, tSig put the long id of pImgRef into tRef - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tRef then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it + put tRef & "|" & pFW & "|" & pFH & "|" & pCount & "|" & pMargin & "|" & pSpacing into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same image+grid): reuse it end if b2kSheetForget pName put tRef into sSheetSrc[pName] put false into sSheetOwned[pName] b2kSheetGridRegions pName, pFW, pFH, pCount, pMargin, pSpacing - put tRef into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetFromImage @@ -2369,15 +2371,16 @@ end b2kSheetAddFrame -- the Kenney pack format, like Spritesheets/ in this repo). Frames are -- addressed BY NAME. pXmlPath defaults to the png path with ".xml". command b2kSheetLoadAtlas pName, pPngPath, pXmlPath - local tRef, tXml, tLine, tNm, tX, tY, tW, tH - if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is pPngPath then - return the number of lines of sSheetKeys[pName] -- already loaded: reuse it - end if + local tRef, tXml, tLine, tNm, tX, tY, tW, tH, tSig if pXmlPath is empty then put pPngPath into pXmlPath set the itemDelimiter to "." put "xml" into item -1 of pXmlPath end if + put pPngPath & "|" & pXmlPath into tSig + if sSheetKeep is true and sSheetKeys[pName] is not empty and sSheetPath[pName] is tSig then + return the number of lines of sSheetKeys[pName] -- already loaded (same png+xml): reuse it + end if put URL ("file:" & pXmlPath) into tXml if tXml is empty then put URL ("binfile:" & pXmlPath) into tXml if tXml is empty then return 0 @@ -2398,7 +2401,7 @@ command b2kSheetLoadAtlas pName, pPngPath, pXmlPath end if end repeat if the last char of sSheetKeys[pName] is cr then delete the last char of sSheetKeys[pName] - put pPngPath into sSheetPath[pName] + put tSig into sSheetPath[pName] return the number of lines of sSheetKeys[pName] end b2kSheetLoadAtlas @@ -2979,17 +2982,35 @@ function b2kSheetKeyIndex pSheet, pKey return 0 end b2kSheetKeyIndex +-- Internal: a slice's provenance stamp. A saved frame image is only safe to +-- reuse if it was baked from the CURRENT source (sSheetPath already encodes +-- the file/image + grid/xml args) at the CURRENT scale. Stamped onto each +-- slice as uB2kSig and re-checked on reuse, so a reopened stack that reuses a +-- sheet NAME for different art, or changes a sheet's scale, re-slices instead +-- of adopting stale pixels (the name alone is not a safe identity). +function b2kSheetSliceSig pSheet + return sSheetPath[pSheet] & "|" & b2kNumberOr(sSheetScale[pSheet], 1) +end b2kSheetSliceSig + command b2kSheetEnsureIcon pSheet, pKey local tRegion, tX, tY, tW, tH, tSW, tRowPx, tFD, tFA, y, tName, tScale, tW2, tH2 if sSheetIcon[pSheet][pKey] is not empty then exit b2kSheetEnsureIcon put sSheetRegion[pSheet][pKey] into tRegion if tRegion is empty then exit b2kSheetEnsureIcon - put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - -- REUSE a slice carried in a SAVED stack (b2kSheetPersist): adopt the - -- existing frame image rather than re-cut it from the source. - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetIcon[pSheet][pKey] - exit b2kSheetEnsureIcon + -- Frame image name. Persisting: a DETERMINISTIC name (b2kfr__) + -- lets a slice carried in a SAVED stack be found and reused -- but only if + -- its uB2kSig still matches (baked from the current source at the current + -- scale), so a reused sheet name or a changed scale re-slices rather than + -- show stale pixels. Off the persist path: a fresh unique name (no key + -- scan; the slice is wiped on teardown anyway), exactly as before. + if sSheetKeep is true then + put "b2kfr_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetIcon[pSheet][pKey] + exit b2kSheetEnsureIcon + end if + else + put "b2kfr_" & the milliseconds & "_" & random(1000000) into tName end if if sSheetData[pSheet] is empty then put the imageData of sSheetSrc[pSheet] into sSheetData[pSheet] @@ -3037,6 +3058,7 @@ command b2kSheetEnsureIcon pSheet, pKey set the imageData of image tName to tFD if tFA is not empty then set the alphaData of image tName to tFA end if + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetIcon[pSheet][pKey] end b2kSheetEnsureIcon @@ -3045,10 +3067,14 @@ end b2kSheetEnsureIcon command b2kSheetEnsureFlip pSheet, pKey local tName if sSheetFlip[pSheet][pKey] is not empty then exit b2kSheetEnsureFlip - put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName - if sSheetKeep is true and there is an image tName then - put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip - exit b2kSheetEnsureFlip + if sSheetKeep is true then + put "b2kfl_" & pSheet & "_" & b2kSheetKeyIndex(pSheet, pKey) into tName + if there is an image tName and the uB2kSig of image tName is b2kSheetSliceSig(pSheet) then + put the id of image tName into sSheetFlip[pSheet][pKey] -- reuse a saved flip + exit b2kSheetEnsureFlip + end if + else + put "b2kfl_" & the milliseconds & "_" & random(1000000) into tName end if b2kSheetEnsureIcon pSheet, pKey if sSheetIcon[pSheet][pKey] is empty then exit b2kSheetEnsureFlip @@ -3058,6 +3084,7 @@ command b2kSheetEnsureFlip pSheet, pKey set the name of it to tName set the visible of it to false flip image tName horizontal + if sSheetKeep is true then set the uB2kSig of image tName to b2kSheetSliceSig(pSheet) put the id of image tName into sSheetFlip[pSheet][pKey] catch tErr put sSheetIcon[pSheet][pKey] into sSheetFlip[pSheet][pKey] From 2def779db26dbb9af9f0bdfc37d7c15674932dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:56:39 +0000 Subject: [PATCH 09/19] docs+tools: one-zip platformer distribution + refreshed install guide Adds an end-user distribution path: hand someone the extension, the native libraries, and a saved stack as a single self-contained zip they install in three steps. - dist/INSTALL.md: a simple, self-contained install guide that uses ONLY the files in the zip -- place the native library (already renamed to the bare name the loader wants), Load box2dxt.lcb, open platformer.livecode. Covers Windows, macOS, and Linux, with a focused troubleshooting table. - tools/make-release.py: assembles dist/box2dxt-platformer.zip from src/box2dxt.lcb + the prebuilt/ libraries (renamed to box2dxt.{dll,dylib,so} under lib/) + INSTALL.md + your saved --stack. --check validates inputs; --win/--mac/--linux override a library. Documented in docs/building.md. - prebuilt/README.md: corrected -- the committed binaries are now ABI 4 (the full ~370-handler set), so the stale 'outdated / ABI 3' warning is removed and the dead linux-x86_64/ subdir paths are fixed. - docs/getting-started.md: a zero-code 'just want to play?' callout pointing at the package, a worked 'try a few more things' snippet (spawn capsule + impulse + box), an Example Games entry, and the same prebuilt-path fixes. - .gitignore: ignore the generated zip / dropped-in stack artifacts. The zip itself is built after the stack is saved in OXT (the one input this repo can't produce); everything else is in place and tested. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- .gitignore | 5 ++ dist/INSTALL.md | 100 +++++++++++++++++++++++++++++++++++ docs/building.md | 31 +++++++++++ docs/getting-started.md | 24 +++++++-- prebuilt/README.md | 85 +++++++++++++++--------------- tools/make-release.py | 113 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 46 deletions(-) create mode 100644 dist/INSTALL.md create mode 100755 tools/make-release.py diff --git a/.gitignore b/.gitignore index 2349523..5c25562 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ # Python bytecode (from tools/) __pycache__/ *.pyc + +# Generated distribution zips (tools/make-release.py); the saved stack and the +# built zip are artifacts -- dist/INSTALL.md is the tracked source. +/dist/*.zip +/dist/*.livecode diff --git a/dist/INSTALL.md b/dist/INSTALL.md new file mode 100644 index 0000000..e1f75b7 --- /dev/null +++ b/dist/INSTALL.md @@ -0,0 +1,100 @@ +# Box2Dxt Platformer — Install & Run + +Everything needed to run the **Box2Dxt** physics platformer is in this folder. +No C compiler, no internet, no extra downloads — just these files and an +**OpenXTalk (OXT)** or **LiveCode 9.6.3+** IDE you already have installed. + +## What's in this package + +``` +box2dxt-platformer/ +├── INSTALL.md ← you are here +├── box2dxt.lcb ← the Box2Dxt extension (the b2… physics API) +├── platformer.livecode ← the platformer stack (you open this last) +└── lib/ + ├── box2dxt.dll ← native library — Windows (x64) + ├── box2dxt.dylib ← native library — macOS (Intel + Apple Silicon) + └── box2dxt.so ← native library — Linux (x86-64) +``` + +You only need the **one** `lib/` file for your operating system. + +--- + +## Step 1 · Put the native library where the engine can find it + +Box2Dxt's physics run in a small native library. Copy the file for your OS: + +| Your OS | Copy this file | To here | +|---------|----------------|---------| +| **Windows** | `lib/box2dxt.dll` | the **same folder** as `platformer.livecode` | +| **macOS** | `lib/box2dxt.dylib` | the **same folder** as `platformer.livecode` | +| **Linux** | `lib/box2dxt.so` | a library search path — see the note below | + +**Do not rename these files.** They are already the bare name the loader looks +for (`box2dxt`, with no `lib` prefix). Renaming is the #1 cause of "unable to +load foreign library". + +> **Linux only.** The dynamic loader does *not* search the stack's folder. Put +> `box2dxt.so` somewhere the loader looks: +> ``` +> sudo cp lib/box2dxt.so /usr/lib/ && sudo ldconfig +> ``` +> (or place it next to the OXT engine binary, or add its folder to +> `LD_LIBRARY_PATH` before launching OXT). + +--- + +## Step 2 · Install the extension + +The extension `box2dxt.lcb` adds the `b2…` physics handlers to the IDE. + +1. Launch **OXT / LiveCode**. +2. Open **Tools → Extension Manager**. +3. Click **+ (Add)**, choose **`box2dxt.lcb`** from this folder, then click + **Load**. + +> Alternatively: **Tools → Extension Builder**, open `box2dxt.lcb`, and click +> **Test** — that compiles and loads it in one step. + +**Confirm it worked:** open the **Message Box** (the small toolbar icon, or +Ctrl/Cmd-M) and type: + +``` +put b2Version() +``` + +Press Enter. You should see **`4`**. If you get an error, see *Troubleshooting*. + +--- + +## Step 3 · Open the platformer + +Open **`platformer.livecode`** (File → Open Stack…, or double-click it). The +game builds itself and starts immediately. + +- **First run** may ask you to locate a spritesheet folder. Click **Cancel** to + play with the built-in placeholder art, or point it at a Kenney spritesheet + folder if you have one. Once you **save** the stack, the artwork is remembered + and loads instantly every time after. +- **Controls:** **arrows** or **WASD** to move · **Space** to jump · **↓** to + duck. Grab the coins and touch the flag to advance — there are four levels. + +That's it. Enjoy the physics. + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `b2Version()` errors, or "handler not found" | The extension isn't loaded. Redo **Step 2** (Extension Manager → Load `box2dxt.lcb`). It loads per IDE session. | +| First physics call says **"unable to load foreign library"** | The native library isn't found. Make sure the `lib/` file for your OS sits **next to `platformer.livecode`** (Windows/macOS) or on a loader path (Linux). Don't rename it. *Tip: launch OXT from a terminal — it prints the exact filename it's looking for.* | +| `b2Version()` returns a number **other than 4** | Your `box2dxt.lcb` and the native library are from different builds. Use the `.lcb` and the `lib/` file **from this same package** together. | +| The window opens but nothing moves, or a `b2…` call errors | The extension loaded but the native library didn't (or is the wrong build). Re-check Steps 1–2 and confirm `put b2Version()` is `4` before opening the stack. | + +--- + +*Box2Dxt is the Box2D v3 physics engine packaged for OpenXTalk and the xTalk +language family. The platformer is one of several example games. For the full +toolkit, source, and documentation, see the project repository.* diff --git a/docs/building.md b/docs/building.md index 16018f3..ad15a5a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -8,6 +8,7 @@ new platform/architecture). Most users can skip this and grab a - [Build](#build) - [Run the tests](#run-the-tests) - [Output files](#output-files) +- [Packaging a distribution zip](#packaging-a-distribution-zip) - [Platform & CPU notes](#platform--cpu-notes) - [Continuous integration](#continuous-integration) @@ -74,6 +75,36 @@ the loader maps to these platform names. Deploy the file next to your stack or standalone (when you build a standalone, bundle the matching platform library with it). +## Packaging a distribution zip + +To hand someone a ready-to-run game (no repo, no toolchain, no internet), +bundle the extension, the per-platform native libraries, a built **and saved** +stack, and the end-user install guide into one zip. `tools/make-release.py` +does it: + +```sh +# Build & SAVE the stack in OXT first (e.g. the platformer), then: +python3 tools/make-release.py --stack /path/to/platformer.livecode +# -> dist/box2dxt-platformer.zip +``` + +It copies `src/box2dxt.lcb` and `dist/INSTALL.md`, renames each `prebuilt/` +library to the bare name the loader wants under `lib/`, and adds your saved +stack — producing: + +``` +box2dxt-platformer/ +├── INSTALL.md # the three-step end-user install guide +├── box2dxt.lcb +├── platformer.livecode # your --stack +└── lib/box2dxt.{dll,dylib,so} +``` + +Override a library with `--win` / `--mac` / `--linux` (e.g. an SSE2 or +older-glibc build); `--check` validates the inputs without writing the zip. +The recipient just follows `INSTALL.md`: drop their platform's `lib/` file +beside the stack, **Load** `box2dxt.lcb`, open the stack. + ## Platform & CPU notes - **AVX2 / SIMD.** Box2D assumes **AVX2** on x64 by default. If your binary must diff --git a/docs/getting-started.md b/docs/getting-started.md index 1cc81bc..8dfa6eb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,6 +4,11 @@ This guide takes you from nothing to a running, draggable physics scene in **OpenXTalk (OXT)** — or any compatible **LiveCode 9.6.3+** IDE. It assumes no C toolchain: you'll use a prebuilt native library. +> **Just want to *play*, not code?** If you were handed the prebuilt **platformer +> package** (a zip with the extension, the native libraries, and a saved +> `platformer.livecode`), open its `INSTALL.md` and follow three steps — no +> scripting. This guide is for building your *own* scenes from scratch. + - [1. Get the native library](#1-get-the-native-library) - [2. Load the extension](#2-load-the-extension) - [3. Sanity check](#3-sanity-check) @@ -25,11 +30,11 @@ file for your platform from [`prebuilt/`](../prebuilt/) (or from the |----------|----------|--------------| | Windows x64 | `prebuilt/box2dxt-windows-x64.dll` | `box2dxt.dll` | | macOS (Intel/Apple Silicon) | `prebuilt/libbox2dxt-macos-universal.dylib` | `box2dxt.dylib` | -| Linux x86-64 | `prebuilt/linux-x86_64/libbox2dxt.so` | `box2dxt.so` | +| Linux x86-64 | `prebuilt/libbox2dxt-linux-x86_64.so` | `box2dxt.so` | OXT's loader resolves the name `box2dxt` to the **bare platform filename with no `lib` prefix** (the table above). This bites on Linux especially: the committed -file is `libbox2dxt.so`, but OXT asks `dlopen` for `box2dxt.so` — leaving the +file is `libbox2dxt-linux-x86_64.so`, but OXT asks `dlopen` for `box2dxt.so` — leaving the `lib` prefix on is the single most common cause of "unable to load foreign library". @@ -116,6 +121,18 @@ you can **drag** them with the mouse. That's it — a live physics scene. builds static walls around the card edges, and starts the loop. From there, `b2kSpawnBall`/`b2kSpawnBox` create controls *and* their bodies in one go. +**Try a few more things** in the Message Box while the card is open: + +``` +b2kSpawnCapsule 220, 60, 70, 28, "teal" -- a pill-shaped body +put the result into tPill -- every b2kSpawn… reports its ref +b2kImpulse tPill, 0, -12 -- a sharp upward kick (mass-aware) +b2kSpawnBox 320, 40, 50, 50, "purple" -- drop another box in +``` + +Negative `y` is *up* here (screen coordinates). The +[Kit Reference](kit-reference.md) lists the full spawn / force / query surface. + ## 5. Attach controls you designed in the IDE Prefer to draw your objects in the IDE? Attach physics to **any** control — @@ -183,7 +200,7 @@ of the [Kit](kit-reference.md). | `b2Version()` throws / "handler not found" | The extension isn't loaded. Re-add and **Load** `box2dxt.lcb` in the Extension Manager. | | First `b2…` call (or `b2Version()`) errors **"unable to load foreign library"** | The native library isn't found or is misnamed. Use the **no-`lib`** bare name: `box2dxt.dll` / `box2dxt.dylib` / `box2dxt.so`. On **Linux** the stack folder isn't searched — put it in `/usr/lib` then run `sudo ldconfig`, or place it next to the OXT engine. (Tip: launching OXT from a terminal prints `dlopen failed ` showing the exact filename it wants.) | | `b2Version()` returns a different number | Your `box2dxt.lcb` and native library are from different versions. Rebuild/redownload both from the same tag. | -| Library won't load on an older PC (Linux/Windows) | Use the committed SIMD-disabled `prebuilt/` binary, or build with `-DBOX2D_DISABLE_SIMD=ON` (see [building.md](building.md)). | +| Library won't load on an older PC (Linux/Windows) | The CPU may lack AVX2. Build with `-DBOX2D_DISABLE_SIMD=ON` (see [building.md](building.md#platform--cpu-notes)), or grab a Release binary built for older CPUs. | | Bodies jitter or behave non-deterministically | You're stepping with a variable timestep. Let the Kit drive the loop, or step in fixed 1/60 s chunks (see [API Reference → Notes](api-reference.md#notes-and-gotchas)). | | Objects fly off instantly / explode | Sizes are wrong for Box2D's MKS units. Keep moving objects roughly 4–400 px at the default 40 px/m scale. | @@ -191,6 +208,7 @@ of the [Kit](kit-reference.md). - [**Kit Guide**](kit-guide.md) — the complete, teach-you-everything walkthrough of the `b2k…` toolkit, with runnable examples. - [Kit Reference](kit-reference.md) — the same `b2k…` API as quick-lookup tables. +- **Example games** — beyond the demo, [`examples/`](../examples/) ships a full **platformer**, an angry-birds-style **slingshot**, and a **contraption builder**, each a single self-contained paste. Hand one to someone else as a zero-setup zip with [`tools/make-release.py`](building.md#packaging-a-distribution-zip). - [API Reference](api-reference.md) — the low-level `b2…` API and units/gotchas. - [Architecture](architecture.md) — how it all works under the hood. - [Building](building.md) — compile the native library yourself. diff --git a/prebuilt/README.md b/prebuilt/README.md index 4d87b31..b4c305d 100644 --- a/prebuilt/README.md +++ b/prebuilt/README.md @@ -4,54 +4,53 @@ Drop-in native libraries so you can run Box2Dxt without a C toolchain. Place the file for your platform next to your stack/standalone (or anywhere the OpenXTalk / LiveCode foreign-binding loader can find it), then load `box2dxt.lcb`. -| Platform | File | Where | -|----------|------|-------| -| Linux x86-64 | `linux-x86_64/libbox2dxt.so` | committed here | -| macOS (universal: Intel + Apple Silicon) | `libbox2dxt-macos-universal.dylib` | committed here + GitHub **Releases** | -| Windows x64 | `box2dxt-windows-x64.dll` | committed here + GitHub **Releases** | +| Platform | File | +|----------|------| +| Windows x64 | `box2dxt-windows-x64.dll` | +| Windows x86 (32-bit) | `box2dxt-windows-x86.dll` | +| macOS (universal: Intel + Apple Silicon) | `libbox2dxt-macos-universal.dylib` | +| Linux x86-64 | `libbox2dxt-linux-x86_64.so` | +| Linux i686 (32-bit) | `libbox2dxt-linux-i686.so` | -When you deploy, rename the file to the bare name the loader resolves `box2dxt` -to for your platform — **no `lib` prefix**: +These are built from the source in this repo and report **ABI 4** — what the +current Kit and examples need. Confirm after loading with `put b2Version()` +(it should return `4`). + +## Deploy: rename to the bare name (no `lib` prefix) + +The `c:box2dxt>…` foreign-binding strings in `box2dxt.lcb` resolve the name +`box2dxt` to a **bare platform filename** at run time. Rename the file you ship: | Platform | Deploy as | |----------|-----------| -| Linux | `box2dxt.so` | -| macOS | `box2dxt.dylib` | -| Windows | `box2dxt.dll` | +| Windows | `box2dxt.dll` | +| macOS | `box2dxt.dylib` | +| Linux | `box2dxt.so` | -That bare name is what the `c:box2dxt>…` foreign-binding strings in `box2dxt.lcb` -resolve to at run time. Note the committed Linux file is `libbox2dxt.so`, but the -loader asks `dlopen` for `box2dxt.so` — rename it (dropping `lib`), or you'll get -"unable to load foreign library". +The committed Linux file is `libbox2dxt-linux-x86_64.so`, but the loader asks +`dlopen` for `box2dxt.so` — drop the `lib` prefix and the `-linux-x86_64` +suffix, or you'll get "unable to load foreign library". (If a particular engine +asks for the `lib`-prefixed name instead, provide that too — a copy or symlink +alongside is harmless.) On **Linux** the dynamic loader does not search the stack's folder: put the file in a search path with `sudo cp box2dxt.so /usr/lib/ && sudo ldconfig`, place it -next to the OXT engine binary, or set `LD_LIBRARY_PATH`. (If a specific engine -asks for `libbox2dxt.*` instead, provide that name too — a copy or symlink.) - -## Portability of the committed binaries - -The committed `linux-x86_64` binary is built with **SIMD disabled** (no AVX2/SSE -requirement), so it runs on any 64-bit x86 Linux machine. It is built from the -source in this repo and stripped. The macOS universal dylib covers both Intel -and Apple Silicon. - -The canonical, per-tag binaries are produced by the -[`build` GitHub Actions workflow](../.github/workflows/build.yml) on native -runners and attached to each tagged **[Release](../../releases)** — that's the -only way to get binaries compiled and tested on each operating system. - -> These committed files are convenience artifacts and can lag behind -> `src/box2d_lc.c`. When in doubt, build from source (two `cmake` commands — see -> [docs/building.md](../docs/building.md)) or grab the matching Release for a -> given tag. - -> **Heads-up — the committed binaries are currently outdated.** They predate most -> of the C shim: they report **ABI 3** and export only ~92 of the ~370 handlers the -> LCB now binds, missing whole families the Kit relies on (sensors, chains, spatial -> queries, body move-events, and more). The current Kit and examples will not run -> against them. The contraption builder now probes `b2Version()` against the ABI it -> needs (**4**) and shows a "rebuild from source" dialog instead of crashing mid-run -> on the first unresolved handler. Regenerate these files — build from source, or -> attach fresh per-tag binaries from the Release workflow (on a portable toolchain; -> e.g. an `manylinux`/older-glibc runner for Linux) — before relying on them. +next to the OXT engine binary, or set `LD_LIBRARY_PATH` before launching OXT. + +> **Tip — let a script do the rename + bundling.** `tools/make-release.py` +> assembles a ready-to-ship zip (the extension + per-platform libraries already +> renamed to the bare name + your saved stack + an install guide). See +> [docs/building.md](../docs/building.md#packaging-a-distribution-zip). + +## Portability & freshness + +- The macOS file is a **universal** binary (Intel + Apple Silicon). For + older-CPU (no-AVX2) or SSE2 builds, see the SIMD notes in + [docs/building.md](../docs/building.md#platform--cpu-notes). +- These committed files are convenience artifacts and can lag behind + `src/box2d_lc.c`. When in doubt, confirm `put b2Version()` matches the ABI + your `box2dxt.lcb` expects, build from source (two `cmake` commands — see + [docs/building.md](../docs/building.md)), or grab the matching tagged + **[Release](../../releases)**, whose per-platform binaries are built and + tested on native runners by the + [`build` workflow](../.github/workflows/build.yml). diff --git a/tools/make-release.py b/tools/make-release.py new file mode 100755 index 0000000..73ddedf --- /dev/null +++ b/tools/make-release.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Assemble a ready-to-ship Box2Dxt platformer zip. + +The zip is self-contained: the extension, the per-platform native libraries +(already renamed to the bare name the loader wants), a saved stack, and the +end-user install guide (dist/INSTALL.md). See its layout in that guide. + +The only thing this script can't produce is the *saved* stack -- you build and +save platformer.livecode in OXT first, then point --stack at it: + + python3 tools/make-release.py --stack ~/Desktop/platformer.livecode + +By default the native libraries come from prebuilt/ (the committed ABI-4 +binaries). Override any of them with --win / --mac / --linux if you have a +fresher or differently-tuned build. + +Run with --check to validate the inputs (and the embedded-Kit sync) without +writing the zip. +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# arcname (inside lib/) -> default source under prebuilt/ +DEFAULT_LIBS = { + "box2dxt.dll": "prebuilt/box2dxt-windows-x64.dll", + "box2dxt.dylib": "prebuilt/libbox2dxt-macos-universal.dylib", + "box2dxt.so": "prebuilt/libbox2dxt-linux-x86_64.so", +} + + +def human(nbytes): + size = float(nbytes) + for unit in ("B", "KB", "MB"): + if size < 1024 or unit == "MB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} MB" + + +def main(): + ap = argparse.ArgumentParser(description="Build the Box2Dxt platformer distribution zip.") + ap.add_argument("--stack", help="path to the built & saved platformer stack (.livecode)") + ap.add_argument("--out", default="dist/box2dxt-platformer.zip", help="output zip path (default: dist/box2dxt-platformer.zip)") + ap.add_argument("--top", default="box2dxt-platformer", help="top-level folder name inside the zip") + ap.add_argument("--stack-name", default="platformer.livecode", help="filename the stack gets inside the zip (the guide refers to this name)") + ap.add_argument("--win", help="override the Windows library (-> lib/box2dxt.dll)") + ap.add_argument("--mac", help="override the macOS library (-> lib/box2dxt.dylib)") + ap.add_argument("--linux", help="override the Linux library (-> lib/box2dxt.so)") + ap.add_argument("--check", action="store_true", help="validate inputs only; do not write the zip") + args = ap.parse_args() + + # Resolve the four always-bundled pieces + the per-platform libraries. + items = [] # (arcname-relative-to-top, source Path) + problems = [] + + lcb = REPO / "src" / "box2dxt.lcb" + install = REPO / "dist" / "INSTALL.md" + for arc, src in (("box2dxt.lcb", lcb), ("INSTALL.md", install)): + if src.is_file(): + items.append((arc, src)) + else: + problems.append(f"missing required file: {src.relative_to(REPO) if src.is_relative_to(REPO) else src}") + + overrides = {"box2dxt.dll": args.win, "box2dxt.dylib": args.mac, "box2dxt.so": args.linux} + for bare, default_rel in DEFAULT_LIBS.items(): + src = Path(overrides[bare]).expanduser() if overrides[bare] else (REPO / default_rel) + if src.is_file(): + items.append((f"lib/{bare}", src)) + else: + problems.append(f"missing library for lib/{bare}: {src}") + + # The saved stack is required to build the zip (but not just to --check). + stack = None + if args.stack: + stack = Path(args.stack).expanduser() + if stack.is_file(): + items.append((args.stack_name, stack)) + else: + problems.append(f"--stack is not a file: {stack}") + elif not args.check: + problems.append("--stack is required (build & save platformer.livecode in OXT first)") + + if problems: + print("Cannot build the release:", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + + print(f"Release contents (top folder: {args.top}/):") + for arc, src in items: + print(f" {arc:<22} <- {src if not src.is_relative_to(REPO) else src.relative_to(REPO)} ({human(src.stat().st_size)})") + + if args.check: + print("\n--check: inputs valid" + ("" if stack else " (no --stack given; a real build needs one)") + ".") + return 0 + + out = (REPO / args.out) if not Path(args.out).is_absolute() else Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for arc, src in items: + z.write(src, f"{args.top}/{arc}") + + print(f"\nWrote {out.relative_to(REPO) if out.is_relative_to(REPO) else out} ({human(out.stat().st_size)}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8e05feadcbce2612de8a40d47d4292183ac8d5b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 15:56:52 +0000 Subject: [PATCH 10/19] Demo: build the chrome once, reuse it on reopen The demo rebuilt its entire UI (top bar, scene/tool buttons, HUD, help bar) on every openCard -- buildUI always clearUI'd and recreated ~15 controls. The chrome is created controls that persist in a saved stack, so gate the rebuild behind a version check, exactly like the contraption builder's chromeBuilt and the platformer's kUIVersion: on reopen the persisted chrome is reused and only the (ephemeral) physics scene is rebuilt. - kDemoUIVersion + demoChromeBuilt(): present-and-current check on the saved UI. - startBox2DDemo builds the chrome only when it's missing or stale (a version bump rebuilds older saved stacks once). Example-only change (no Kit edit, no harness bump). Statically verified. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- examples/box2dxt-demo.livecodescript | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 8cb4ccf..3f35abc 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -5485,6 +5485,7 @@ constant kLabels = "Playground,Pyramid,Cradle,Bridge,Vehicle,Lidar" constant kTools = "box,ball,capsule,poly,bomb" constant kToolLabels = "Box,Ball,Capsule,Poly,Bomb" constant kMaxDrops = 70 +constant kDemoUIVersion = "1" -- bump when buildUI's chrome changes; older saved stacks rebuild it once -- --------------------------------------------------------------------- on preOpenCard @@ -5505,7 +5506,10 @@ on startBox2DDemo set the loc of this stack to the screenLoc set the backgroundColor of this card to "22,24,30" put "box" into gTool - buildUI + if not demoChromeBuilt() then -- build the chrome once; it persists in the saved stack + buildUI + set the uDemoUIVersion of this stack to kDemoUIVersion + end if switchScene "playground" end startBox2DDemo @@ -6310,6 +6314,14 @@ end newStaticBar -- ===================================================================== -- UI -- ===================================================================== +-- The chrome (top bar, scene/tool buttons, HUD, help bar) is generated once and +-- saved with the stack; this reports whether that persisted UI is present and +-- current, so startBox2DDemo skips the rebuild on reopen. A kDemoUIVersion bump +-- (after the demo's chrome changes) rebuilds it once on the next open. +function demoChromeBuilt + return (there is a button "ui_scene_playground") and (the uDemoUIVersion of this stack is kDemoUIVersion) +end demoChromeBuilt + on buildUI clearUI local tWide, tI, tScene, tTool, tX From 968196c6d7de81852a25c793a9ddebf95f93b951 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:21:58 +0000 Subject: [PATCH 11/19] Builder: sweep orphaned part graphics on rebuild + reopen clearAll removed only parts tracked in gParts, so any UNTRACKED part graphic survived -- and a stack saved with parts on the stage reopens with exactly that: the graphics persist but their bodies are gone and gParts is reset, so they sit there dead, and a subsequent Load piles the restored contraption on top of them (which makes Save/Load look broken). Add sweepOrphanParts: delete every control carrying a uPartId (the stamp tagPart puts on every PLACED part, and nothing else -- chrome is ui_*, the arena is cb_*, and the image library is data in uImageLibrary, not controls). Call it from clearAll (so Load/Reset/Clear/rebuildFromText always start from a truly clean stage) and from startCB after the world is built (so a reopened stack has no dead, untracked shapes). The Save/Load format itself is sound -- audited the full round-trip: partLine's 12 fields align with rebuildFromText's reads, all 18 part kinds rebuild, and joints/wires/world settings restore with id remapping. The orphan pile-up was what made it look broken. Example-only change. Statically verified. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index ff5aaf5..d5550ed 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -5692,6 +5692,8 @@ on startCB end if b2kSetup 0, kDefaultGravity -- creates the world; the loop stays stopped prepArena + sweepOrphanParts -- a stack saved with parts on the stage reopens to a + -- clean stage (no dead, untracked part graphics) b2kFrameTarget the long id of this stack b2kContactTarget the long id of this stack raiseUI @@ -8323,6 +8325,8 @@ on clearAll end try end repeat put empty into gParts + sweepOrphanParts -- also drop UNTRACKED part graphics (orphans), so a load + -- never piles on leftovers and a rebuild leaves no dead shapes put empty into gFlash resetSceneState unlock screen @@ -8330,6 +8334,25 @@ on clearAll updateHud end clearAll +-- Delete every PART graphic on the card, INCLUDING any not tracked in gParts +-- (orphans -- e.g. a stack saved with parts on the stage, then reopened: their +-- bodies are gone and gParts no longer lists them, so clearAll's gParts loop +-- can't see them). Parts are exactly the controls tagPart stamps with a uPartId; +-- chrome (ui_*), the arena (cb_*), and the image LIBRARY (data in the +-- uImageLibrary property, not controls) carry none, so they are never touched. +-- Safe to call whenever a truly clean stage is wanted (clearAll, reopen). +on sweepOrphanParts + local tI + repeat with tI = the number of controls of this card down to 1 + if the uPartId of control tI of this card is not empty then + try + b2kRemove control tI of this card + end try + delete control tI of this card + end if + end repeat +end sweepOrphanParts + on clearJoints local tI if gJN >= 1 then From 6ab88a36fc129b6843e194229aed91c52966d18f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:44:42 +0000 Subject: [PATCH 12/19] Kit: duck no longer sinks the player art below ground; fix L1 crawl-tunnel overlap THE DUCK BUG (Wave 5). b2kPlayerDuckSet shrinks the crawl capsule and drops the body CENTRE by tShift to keep the FEET planted (correct physics). But the bound art follows the body centre at a CONSTANT offset (sSprBindDY), so it sank by tShift with the centre -- the platformer's 'ducking puts me below ground'. The buried, crouched hero then can't jump (jumping is gated off while DOWN is held by design), so it read as 'breaks jumping entirely'. Fix: b2kPlayerDuckSet lifts the bound art by tShift (and b2kPlayerStandUp drops it back), so the visible character stays planted as the hitbox shrinks. The body math was already correct; this is purely the art-follow offset. Audited the rest of Wave 5 (dash, drop-through, wall-jump/slide, platform-carry, double-jump) -- all correct. Self-test v16: stTestPlayerDuck -- duck shrinks the hitbox, keeps the feet planted, and lifts/restores the art bind by exactly tShift. L1 LAYOUT. The rest cloud's one-way collision + tiles (solid span 3136..3328) overlapped the crawl-tunnel bar (slab + block tiles 3300..3492) by 28px -- the cloud's right edge sat INSIDE the solid bar, by the flying ladybug. Shifted the whole crawl-tunnel beat (bar, block tiles, the reward coin) right one tile to 3364..3556, clearing the cloud with a 36px gap (the space after was open). https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 4 ++ examples/box2dxt-demo.livecodescript | 4 ++ examples/box2dxt-platformer.livecodescript | 10 +++- examples/box2dxt-selftest.livecodescript | 55 ++++++++++++++++++- examples/box2dxt-slingshot.livecodescript | 4 ++ examples/box2dxt-spike-gamekit.livecodescript | 4 ++ src/box2dxt-kit.livecodescript | 4 ++ 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index d5550ed..7385e84 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -4027,6 +4027,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4051,6 +4054,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 3f35abc..32b8ebf 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -4017,6 +4017,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4041,6 +4044,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index bb8ad6a..3f0a8fb 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -1775,15 +1775,15 @@ command pfL1Scene -- (cast). The bar is only ~60px tall, so a jump/double-jump also tops it -- (no dead end) - but the coin under it is the crawl's reward. Built as a -- visible earth bar (a solid grass block, like the bat-bar overhang on L4). - pfSlab "pf_crawlbar", 3300, 455, 3492, 515 + pfSlab "pf_crawlbar", 3364, 455, 3556, 515 set the backgroundColor of graphic "pf_crawlbar" to "90,150,72" set the visible of graphic "pf_crawlbar" to true b2kCamAdopt the long id of graphic "pf_crawlbar" if gAssetsOK is true and b2kSheetHasFrame("tiles", "terrain_grass_block_center") then set the visible of graphic "pf_crawlbar" to false -- the tiles carry the face - pfTile "terrain_grass_block_center", 3300, 451 pfTile "terrain_grass_block_center", 3364, 451 pfTile "terrain_grass_block_center", 3428, 451 + pfTile "terrain_grass_block_center", 3492, 451 end if pfMakeSpikes 2560, 2752 -- the one-way bridge (GHOST RULE: the chain runs one tile past the @@ -1985,7 +1985,7 @@ command pfL1Cast pfMakeCoin 2876, 500 -- the second act's slime beat pfMakeCoin 3076, 500 -- the skittering mouse's beat pfMakeCoin 3232, 392 -- on the rest cloud - pfMakeCoin 3396, 548 -- inside the CRAWL TUNNEL: duck (DOWN) to slip under for it + pfMakeCoin 3460, 548 -- inside the CRAWL TUNNEL: duck (DOWN) to slip under for it pfMakeCoin 3616, 500 -- the last meadow slime's beat pfMakeCoin 4128, 510 -- mid-bridge: grab it as the planks dip pfMakeCoin 4456, 500 -- the far meadow's ladybug beat @@ -8075,6 +8075,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -8099,6 +8102,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index 8e0a851..365fdb2 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "15" -- bump on EVERY harness change: the report +constant kStHarnessV = "16" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -142,6 +142,7 @@ command stRunAll stRun "stTestPlatformCarry" stRun "stTestDuckReshape" stRun "stTestPlayerHelpers" + stRun "stTestPlayerDuck" stRun "stTestTeardown" try b2kInputInjectOff @@ -1390,6 +1391,54 @@ command stTestPlayerHelpers (abs(stVX(tRef)) < 60) end stTestPlayerHelpers +-- Wave 5 DUCK: the crawl shrinks the capsule and drops the body CENTRE to keep +-- the FEET planted, so the bound art must be LIFTED by the same amount or it +-- sinks through the floor (the platformer's "ducking puts me below ground" +-- report). Drives the duck/stand UNITS directly -- the per-frame tick is not +-- run here, so it sets the cached centre (sPlayPX/PY) its probe would, and +-- reads the bound art's offset (sSprBindDY) to confirm the compensation. +command stTestPlayerDuck + local tArt, tDy0, tH0, tStand, tNewH, tShift, tBot0, tBotD, tBotU + stNewWorld "player: duck plants the feet + lifts the bound art" + if there is an image "st_duckart" then delete image "st_duckart" + create image "st_duckart" + set the visible of image "st_duckart" to false + set the rect of image "st_duckart" to 0, 0, 32, 64 + b2kSheetFromImage "stduck", the long id of image "st_duckart", 32, 64 + b2kPlayerMake 200, 300, 40, 64, "stduck" + put b2kPlayerSprite() into tArt + b2kPlayerSet "duckScale", 0.6 + b2kPlayerTuneCache + put sSprBindDY[tArt] into tDy0 + put b2kPlayerHalfH() into tH0 -- 32 (standing half-height) + put 64 into tStand -- the height passed to b2kPlayerMake + put max(8, round(tStand * 0.6)) into tNewH + put (tStand - tNewH) / 2 into tShift + put stY(b2kPlayer()) + tH0 into tBot0 -- standing hitbox bottom (screen y) + stAssert "player got a bound art sprite", (tArt is not empty) + put stX(b2kPlayer()) into sPlayPX -- the tick's probe would set these + put stY(b2kPlayer()) into sPlayPY + b2kPlayerDuckSet true + put stY(b2kPlayer()) + b2kPlayerHalfH() into tBotD + stAssert "duck shrank the hitbox (halfH " & round(b2kPlayerHalfH()) & " < " & round(tH0) & ")", \ + (b2kPlayerHalfH() < tH0 - 1) + stAssert "duck planted the feet (bottom " & round(tBotD) & " ~ " & round(tBot0) & ")", \ + (abs(tBotD - tBot0) < 3) + stAssert "duck lifted the art by tShift (bindDY " & sSprBindDY[tArt] & " ~ " & (tDy0 - tShift) & ")", \ + (abs(sSprBindDY[tArt] - (tDy0 - tShift)) < 1) + put stX(b2kPlayer()) into sPlayPX -- refresh to the DUCKED centre + put stY(b2kPlayer()) into sPlayPY + b2kPlayerStandUp + put stY(b2kPlayer()) + b2kPlayerHalfH() into tBotU + stAssert "standup restored the hitbox (halfH " & round(b2kPlayerHalfH()) & " ~ " & round(tH0) & ")", \ + (abs(b2kPlayerHalfH() - tH0) < 1) + stAssert "standup planted the feet (bottom " & round(tBotU) & " ~ " & round(tBot0) & ")", \ + (abs(tBotU - tBot0) < 3) + stAssert "standup restored the art bind (bindDY " & sSprBindDY[tArt] & " ~ " & tDy0 & ")", \ + (abs(sSprBindDY[tArt] - tDy0) < 1) + if there is an image "st_duckart" then delete image "st_duckart" +end stTestPlayerDuck + -- b2kSheetPersist: a loaded sheet survives b2kTeardown (so a multi-level -- game loads its atlases ONCE), an identical reload is a no-op, and the -- explicit b2kSheetsWipe still purges it. @@ -5428,6 +5477,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -5452,6 +5504,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index ba1d036..8665d23 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -4774,6 +4774,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4798,6 +4801,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 9db385c..9d3edf2 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -5354,6 +5354,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -5378,6 +5381,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 8ac2108..7c4df52 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -3991,6 +3991,9 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked + -- the body CENTRE dropped by tShift to keep the FEET planted; lift the + -- bound art by the same amount, or the visible character sinks with it + if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4015,6 +4018,7 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked + if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp From e50be67ccf9ccd165cb91a154dad982eb9cd8f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:44:46 +0000 Subject: [PATCH 13/19] Kit: b2kReshape preserves the collision filter (fixes drop-through while crawling) Wave 5 interaction bug, same class as the duck (state lost across a reshape). b2kReshape rebuilt the shape with the DEFAULT collision filter, silently resetting collision layers. With duckScale < 1 (the platformer's 0.6), DOWN on a one-way cloud/bridge reshapes the capsule to a crawl; DOWN+JUMP then drops through -- b2kPlayerDropStart clears the one-way bit -- but the very next frame the airborne player stands up, b2kReshape rebuilds the shape, the one-way bit defaults back ON, and the body (centroid still above the line) snaps back onto the platform it was falling through (gotcha 20). The drop-window's careful timed restore was being bypassed by the reshape. Fix: b2kReshape reads the old shape's filter (category/mask/group) and re-applies it to the new shape, clamping Box2D's 2^64-1 default mask to the 32 Kit bits (gotcha 21). A reshape now never changes collision layers -- so a drop survives a duck/stand, and the drop-window owns the restore. Also fixes a latent bug where resizing a custom-filtered contraption part reset its layers. The player's material is still re-applied by b2kPlayerDuckSet/StandUp (b2kReshape resets density/friction/restitution by design); only the FILTER needed carrying. Self-test v17: stTestReshapeFilter -- a custom filter (cat 1, mask 5) set on a box survives a b2kReshape to a ball. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 16 +++++++- examples/box2dxt-demo.livecodescript | 16 +++++++- examples/box2dxt-platformer.livecodescript | 16 +++++++- examples/box2dxt-selftest.livecodescript | 39 ++++++++++++++++++- examples/box2dxt-slingshot.livecodescript | 16 +++++++- examples/box2dxt-spike-gamekit.livecodescript | 16 +++++++- src/box2dxt-kit.livecodescript | 16 +++++++- 7 files changed, 127 insertions(+), 8 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index 7385e84..bed95ec 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -880,7 +880,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -888,6 +888,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -960,6 +966,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 32b8ebf..8b74073 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -870,7 +870,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -878,6 +878,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -950,6 +956,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 3f0a8fb..a1e0749 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -4928,7 +4928,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -4936,6 +4936,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -5008,6 +5014,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index 365fdb2..a1c1b80 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "16" -- bump on EVERY harness change: the report +constant kStHarnessV = "17" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -143,6 +143,7 @@ command stRunAll stRun "stTestDuckReshape" stRun "stTestPlayerHelpers" stRun "stTestPlayerDuck" + stRun "stTestReshapeFilter" stRun "stTestTeardown" try b2kInputInjectOff @@ -1439,6 +1440,26 @@ command stTestPlayerDuck if there is an image "st_duckart" then delete image "st_duckart" end stTestPlayerDuck +-- b2kReshape must CARRY the collision filter across the rebuild. A body mid +-- drop-through has its one-way bit cleared (b2kPlayerDropStart); a crawl-duck +-- reshape that reset the filter would default the bit back ON and snap the body +-- up onto the platform it is falling through (gotcha 20) -- the Wave 5 +-- drop-through-while-crawling bug. +command stTestReshapeFilter + local tRef, tSh + stNewWorld "reshape: keeps the collision filter (so a drop survives a duck)" + b2kSpawnBox 200, 200, 40, 40 + put the result into tRef + put sShapeH[tRef] into tSh + stAssert "spawned box has a shape", (tSh is not empty) + b2SetShapeFilter tSh, 1, 5, 0 -- category 1, a CUSTOM mask (5), no group + stAssert "filter set to mask 5 (got " & b2ShapeFilterMask(tSh) & ")", (b2ShapeFilterMask(tSh) is 5) + b2kReshape tRef, "ball" -- reshape the body, as the crawl-duck does + put sShapeH[tRef] into tSh -- a NEW shape handle after the rebuild + stAssert "reshape kept the mask (got " & b2ShapeFilterMask(tSh) & " ~ 5)", (b2ShapeFilterMask(tSh) is 5) + stAssert "reshape kept the category (got " & b2ShapeFilterCategory(tSh) & " ~ 1)", (b2ShapeFilterCategory(tSh) is 1) +end stTestReshapeFilter + -- b2kSheetPersist: a loaded sheet survives b2kTeardown (so a multi-level -- game loads its atlases ONCE), an identical reload is a no-op, and the -- explicit b2kSheetsWipe still purges it. @@ -2330,7 +2351,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -2338,6 +2359,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -2410,6 +2437,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index 8665d23..a1ac7b4 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -1627,7 +1627,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -1635,6 +1635,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -1707,6 +1713,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 9d3edf2..96f3601 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -2207,7 +2207,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -2215,6 +2215,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -2287,6 +2293,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 7c4df52..e21c86d 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -844,7 +844,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa + local tWx, tWy, tWa, tFCat, tFMask, tFGrp put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -852,6 +852,12 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld + put empty into tFCat -- carry the old shape's collision FILTER across + if tOld is not empty then + put b2ShapeFilterCategory(tOld) into tFCat + put b2ShapeFilterMask(tOld) into tFMask + put b2ShapeFilterGroup(tOld) into tFGrp + end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -924,6 +930,14 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape + -- A reshape must NOT silently reset collision layers: a body mid drop-through + -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON + -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp + -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). + if tFCat is not empty and sShapeH[tRef] is not empty then + if tFMask > 4294967295 then put 4294967295 into tFMask + b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp + end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the From 8af7dcec65f3e8702bd9e06ba39fa260594c65b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 21:13:58 +0000 Subject: [PATCH 14/19] Revert "Kit: b2kReshape preserves the collision filter (fixes drop-through while crawling)" This reverts commit e50be67ccf9ccd165cb91a154dad982eb9cd8f56. --- ...box2dxt-contraption-builder.livecodescript | 16 +------- examples/box2dxt-demo.livecodescript | 16 +------- examples/box2dxt-platformer.livecodescript | 16 +------- examples/box2dxt-selftest.livecodescript | 39 +------------------ examples/box2dxt-slingshot.livecodescript | 16 +------- examples/box2dxt-spike-gamekit.livecodescript | 16 +------- src/box2dxt-kit.livecodescript | 16 +------- 7 files changed, 8 insertions(+), 127 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index bed95ec..7385e84 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -880,7 +880,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -888,12 +888,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -966,14 +960,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 8b74073..32b8ebf 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -870,7 +870,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -878,12 +878,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -956,14 +950,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index a1e0749..3f0a8fb 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -4928,7 +4928,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -4936,12 +4936,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -5014,14 +5008,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index a1c1b80..365fdb2 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "17" -- bump on EVERY harness change: the report +constant kStHarnessV = "16" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -143,7 +143,6 @@ command stRunAll stRun "stTestDuckReshape" stRun "stTestPlayerHelpers" stRun "stTestPlayerDuck" - stRun "stTestReshapeFilter" stRun "stTestTeardown" try b2kInputInjectOff @@ -1440,26 +1439,6 @@ command stTestPlayerDuck if there is an image "st_duckart" then delete image "st_duckart" end stTestPlayerDuck --- b2kReshape must CARRY the collision filter across the rebuild. A body mid --- drop-through has its one-way bit cleared (b2kPlayerDropStart); a crawl-duck --- reshape that reset the filter would default the bit back ON and snap the body --- up onto the platform it is falling through (gotcha 20) -- the Wave 5 --- drop-through-while-crawling bug. -command stTestReshapeFilter - local tRef, tSh - stNewWorld "reshape: keeps the collision filter (so a drop survives a duck)" - b2kSpawnBox 200, 200, 40, 40 - put the result into tRef - put sShapeH[tRef] into tSh - stAssert "spawned box has a shape", (tSh is not empty) - b2SetShapeFilter tSh, 1, 5, 0 -- category 1, a CUSTOM mask (5), no group - stAssert "filter set to mask 5 (got " & b2ShapeFilterMask(tSh) & ")", (b2ShapeFilterMask(tSh) is 5) - b2kReshape tRef, "ball" -- reshape the body, as the crawl-duck does - put sShapeH[tRef] into tSh -- a NEW shape handle after the rebuild - stAssert "reshape kept the mask (got " & b2ShapeFilterMask(tSh) & " ~ 5)", (b2ShapeFilterMask(tSh) is 5) - stAssert "reshape kept the category (got " & b2ShapeFilterCategory(tSh) & " ~ 1)", (b2ShapeFilterCategory(tSh) is 1) -end stTestReshapeFilter - -- b2kSheetPersist: a loaded sheet survives b2kTeardown (so a multi-level -- game loads its atlases ONCE), an identical reload is a no-op, and the -- explicit b2kSheetsWipe still purges it. @@ -2351,7 +2330,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -2359,12 +2338,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -2437,14 +2410,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index a1ac7b4..8665d23 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -1627,7 +1627,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -1635,12 +1635,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -1713,14 +1707,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 96f3601..9d3edf2 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -2207,7 +2207,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -2215,12 +2215,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -2293,14 +2287,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index e21c86d..7c4df52 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -844,7 +844,7 @@ end b2kSetDensity -- Resize the control's graphic first, then re-apply materials afterwards. command b2kReshape pControl, pShape local tRef, tBody, tOld, tHw, tHh, tWm, tHm, tR, tL, tHoriz, tCx, tCy, p, lx, ly, tCount, tNewVerts - local tWx, tWy, tWa, tFCat, tFMask, tFGrp + local tWx, tWy, tWa put the long id of pControl into tRef if sBody[tRef] is empty then exit b2kReshape put sBody[tRef] into tBody @@ -852,12 +852,6 @@ command b2kReshape pControl, pShape -- is never momentarily shapeless and a rejected shape leaves the original -- intact (any joints on the body stay valid throughout). put sShapeH[tRef] into tOld - put empty into tFCat -- carry the old shape's collision FILTER across - if tOld is not empty then - put b2ShapeFilterCategory(tOld) into tFCat - put b2ShapeFilterMask(tOld) into tFMask - put b2ShapeFilterGroup(tOld) into tFGrp - end if put empty into sShapeH[tRef] b2ShapeDefEnableSensorEvents true -- keep the reshaped body detectable by sensors if sSensor[tRef] is true then b2ShapeDefSensor true -- a sensor stays a sensor after reshaping @@ -930,14 +924,6 @@ command b2kReshape pControl, pShape end if end switch if tOld is not empty then b2DestroyShape tOld -- now safe to release the old shape - -- A reshape must NOT silently reset collision layers: a body mid drop-through - -- has its one-way bit cleared (b2kPlayerDropStart), and defaulting it back ON - -- snaps the body up onto the platform it is falling through (gotcha 20). Clamp - -- Box2D's 2^64-1 default mask to the 32 Kit bits first (gotcha 21). - if tFCat is not empty and sShapeH[tRef] is not empty then - if tFMask > 4294967295 then put 4294967295 into tFMask - b2SetShapeFilter sShapeH[tRef], tFCat, tFMask, tFGrp - end if -- The size/outline changed but the body need not have moved, so the move-event -- sync would skip it (an at-rest body produces no move event). Bust the -- pose-keyed draw cache and redraw at the body's current pose right now so the From 80cd804fb248dfb5012ad28e5e934131c544912f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 21:41:19 +0000 Subject: [PATCH 15/19] Revert my duck art-lift change (caused the mid-air float) + remove its broken test The sprite-bind compensation I added to b2kPlayerDuckSet/StandUp was based on a wrong model: it assumed the camera is vertically LOCKED, so it lifted the bound art by tShift to cancel the body-centre drop. But the platformer follows the hero with b2kCamDeadzone 0,0 + b2kCamFollow gHero,1 -- the camera tracks the body, so the lift over-compensated and floated the art (the 'floating + stuck mid-air' report). Reverted both lines; the duck is back to its prior behaviour. Also removed stTestPlayerDuck: it relied on writing the Kit's script-locals (sPlayPX/PY) and reading sSprBindDY from a harness handler, which OXT does NOT honour across the embedded-Kit boundary (its body teleported to y~13 and the bind read back empty -- the test was measuring nothing real). The L1 crawl-tunnel layout fix is unrelated and kept. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 4 -- examples/box2dxt-demo.livecodescript | 4 -- examples/box2dxt-platformer.livecodescript | 4 -- examples/box2dxt-selftest.livecodescript | 55 +------------------ examples/box2dxt-slingshot.livecodescript | 4 -- examples/box2dxt-spike-gamekit.livecodescript | 4 -- src/box2dxt-kit.livecodescript | 4 -- 7 files changed, 1 insertion(+), 78 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index 7385e84..d5550ed 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -4027,9 +4027,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4054,7 +4051,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 32b8ebf..3f35abc 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -4017,9 +4017,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4044,7 +4041,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 3f0a8fb..750196e 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -8075,9 +8075,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -8102,7 +8099,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index 365fdb2..3b2a01f 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "16" -- bump on EVERY harness change: the report +constant kStHarnessV = "17" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -142,7 +142,6 @@ command stRunAll stRun "stTestPlatformCarry" stRun "stTestDuckReshape" stRun "stTestPlayerHelpers" - stRun "stTestPlayerDuck" stRun "stTestTeardown" try b2kInputInjectOff @@ -1391,54 +1390,6 @@ command stTestPlayerHelpers (abs(stVX(tRef)) < 60) end stTestPlayerHelpers --- Wave 5 DUCK: the crawl shrinks the capsule and drops the body CENTRE to keep --- the FEET planted, so the bound art must be LIFTED by the same amount or it --- sinks through the floor (the platformer's "ducking puts me below ground" --- report). Drives the duck/stand UNITS directly -- the per-frame tick is not --- run here, so it sets the cached centre (sPlayPX/PY) its probe would, and --- reads the bound art's offset (sSprBindDY) to confirm the compensation. -command stTestPlayerDuck - local tArt, tDy0, tH0, tStand, tNewH, tShift, tBot0, tBotD, tBotU - stNewWorld "player: duck plants the feet + lifts the bound art" - if there is an image "st_duckart" then delete image "st_duckart" - create image "st_duckart" - set the visible of image "st_duckart" to false - set the rect of image "st_duckart" to 0, 0, 32, 64 - b2kSheetFromImage "stduck", the long id of image "st_duckart", 32, 64 - b2kPlayerMake 200, 300, 40, 64, "stduck" - put b2kPlayerSprite() into tArt - b2kPlayerSet "duckScale", 0.6 - b2kPlayerTuneCache - put sSprBindDY[tArt] into tDy0 - put b2kPlayerHalfH() into tH0 -- 32 (standing half-height) - put 64 into tStand -- the height passed to b2kPlayerMake - put max(8, round(tStand * 0.6)) into tNewH - put (tStand - tNewH) / 2 into tShift - put stY(b2kPlayer()) + tH0 into tBot0 -- standing hitbox bottom (screen y) - stAssert "player got a bound art sprite", (tArt is not empty) - put stX(b2kPlayer()) into sPlayPX -- the tick's probe would set these - put stY(b2kPlayer()) into sPlayPY - b2kPlayerDuckSet true - put stY(b2kPlayer()) + b2kPlayerHalfH() into tBotD - stAssert "duck shrank the hitbox (halfH " & round(b2kPlayerHalfH()) & " < " & round(tH0) & ")", \ - (b2kPlayerHalfH() < tH0 - 1) - stAssert "duck planted the feet (bottom " & round(tBotD) & " ~ " & round(tBot0) & ")", \ - (abs(tBotD - tBot0) < 3) - stAssert "duck lifted the art by tShift (bindDY " & sSprBindDY[tArt] & " ~ " & (tDy0 - tShift) & ")", \ - (abs(sSprBindDY[tArt] - (tDy0 - tShift)) < 1) - put stX(b2kPlayer()) into sPlayPX -- refresh to the DUCKED centre - put stY(b2kPlayer()) into sPlayPY - b2kPlayerStandUp - put stY(b2kPlayer()) + b2kPlayerHalfH() into tBotU - stAssert "standup restored the hitbox (halfH " & round(b2kPlayerHalfH()) & " ~ " & round(tH0) & ")", \ - (abs(b2kPlayerHalfH() - tH0) < 1) - stAssert "standup planted the feet (bottom " & round(tBotU) & " ~ " & round(tBot0) & ")", \ - (abs(tBotU - tBot0) < 3) - stAssert "standup restored the art bind (bindDY " & sSprBindDY[tArt] & " ~ " & tDy0 & ")", \ - (abs(sSprBindDY[tArt] - tDy0) < 1) - if there is an image "st_duckart" then delete image "st_duckart" -end stTestPlayerDuck - -- b2kSheetPersist: a loaded sheet survives b2kTeardown (so a multi-level -- game loads its atlases ONCE), an identical reload is a no-op, and the -- explicit b2kSheetsWipe still purges it. @@ -5477,9 +5428,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -5504,7 +5452,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index 8665d23..ba1d036 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -4774,9 +4774,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4801,7 +4798,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 9d3edf2..9db385c 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -5354,9 +5354,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -5381,7 +5378,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 7c4df52..8ac2108 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -3991,9 +3991,6 @@ command b2kPlayerDuckSet pWantDuck b2kSetBounce sPlayRef, 0 put tNewH / 2 into sPlayHalfH put true into sPlayDucked - -- the body CENTRE dropped by tShift to keep the FEET planted; lift the - -- bound art by the same amount, or the visible character sinks with it - if sPlayArt is not empty then subtract tShift from sSprBindDY[sPlayArt] b2kPlayerTuneCache -- the probe reach follows the new (shorter) capsule else if sPlayDucked is not true then exit b2kPlayerDuckSet @@ -4018,7 +4015,6 @@ command b2kPlayerStandUp b2kSetBounce sPlayRef, 0 put sPlayStandH / 2 into sPlayHalfH put false into sPlayDucked - if sPlayArt is not empty then add tShift to sSprBindDY[sPlayArt] -- undo the duck's art lift b2kPlayerTuneCache end b2kPlayerStandUp From e2c163d99c039ad35be4bf9397ceab18c45f73f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 21:48:49 +0000 Subject: [PATCH 16/19] Platformer: duckScale=1 (reliable stop-in-place duck); retire the crawl tunnel Per the chosen path: DOWN now brakes the hero to a stop in place with NO hitbox reshape, so the duck physically cannot float, sink, or stick (the reshape crawl was the source of the float/sink trouble, and the Kit's own crawl self-test still fails it). The crawl-tunnel overhang only existed for the crawl move, so it is removed; its coin now sits on the open meadow run (3460,500) and is grabbed just by running past -- no coin lost, no dead end, totals unchanged. Updated the in-game help text and the header docs (controls, the L1 beat list, the Wave 5 move summary). Example-only change; gates pass. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- examples/box2dxt-platformer.livecodescript | 43 +++++++--------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 750196e..735a7b0 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -25,7 +25,7 @@ -- CONTROLS arrows or A/D run · SPACE / UP / W jumps (tap = hop, hold = -- full) · jump AGAIN in mid-air = DOUBLE JUMP · jump off a wall -- you are sliding = WALL JUMP · SHIFT or X = DASH (a flat zip) · --- DOWN/S ducks/CRAWLS (shorter, fits low gaps) - and DOWN+JUMP +-- DOWN/S ducks (brakes to a stop in place) - and DOWN+JUMP -- on a one-way deck (bridge, clouds) DROPS THROUGH it · UP/DOWN -- at the L2 ladder CLIMBS (JUMP lets go) · ride the moving LIFTS -- (they carry you) · the MOUSE DRAGS the crate (chained weights @@ -38,8 +38,7 @@ -- non-bouncers), the BONK ROW (headbutt ?-boxes, SMASH bricks -- into debris), the one-way BRIDGE over a spike slime, the -- slope MOUND, one-way CLOUDS with the fly, the bee, the --- SPIKE PIT, a fast MOUSE, the Wave 5 CRAWL TUNNEL (a low --- overhang - duck/DOWN to crawl under for a hidden coin), a +-- SPIKE PIT, a fast MOUSE, a coin on the open meadow run, a -- flying ladybug - then the rope-bridge finale over a chasm, -- the crusher alley, and the hilltop SWIM POOL to the flag. -- LEVEL 2 THE WORKS (5952px) - the machines: drag the @@ -197,11 +196,10 @@ -- change). DOUBLE JUMP: a second jump fires in mid-air (clears the -- taller beats). WALL JUMP: hug a wall while airborne to slow the -- fall, then jump to launch up-and-away. DASH (SHIFT or X): a flat --- horizontal burst on a cooldown. CRAWL: DOWN reshapes the capsule --- shorter (b2kPlayerHalfH drops) to fit low gaps. PLATFORM CARRY: a --- grounded hero on a moving lift inherits its velocity. The signature --- beats: L1 CRAWL TUNNEL (duck under the 52px overhang for the coin - --- or jump over the bar; never blocks the path); L2 LIFT BAY (jump the +-- horizontal burst on a cooldown. DUCK: DOWN brakes to a stop in place. +-- PLATFORM CARRY: a grounded hero on a moving lift inherits its +-- velocity. The signature beats: L1 has a coin on the open meadow run; +-- L2 LIFT BAY (jump the -- pedestal, RIDE the deck across the grinder for the mid-bay coin, -- step off the far pedestal - a fall onto the grinder is a recoverable -- knockback; the ~200px gap is also double-jumpable); L3 WALL-JUMP @@ -211,7 +209,7 @@ -- the deck over the lava between the crushers for the mid-lava coin - -- or double-jump the 128px strip; a fall onto the lava is a knockback, -- recoverable). EVERY new-move coin is also reachable WITHOUT the --- trick (double-jump/crawl are universal), so no level can dead-end. +-- trick (the double-jump is universal), so no level can dead-end. -- FEEL is unverifiable statically - tune the Wave 5 knobs in OXT and -- confirm: the lifts carry you (stand still and drift with the deck), -- a crouched hero clears the L1 bar a standing one cannot, and the L3 @@ -797,7 +795,7 @@ command pfStartGame b2kPlayerSet "dashSpeed", 560 -- DASH (SHIFT or X): a flat ground/air zip b2kPlayerSet "dashMs", 150 b2kPlayerSet "dashCooldownMs", 420 - b2kPlayerSet "duckScale", 0.6 -- DOWN now CRAWLS (shorter hitbox) under low gaps + b2kPlayerSet "duckScale", 1 -- DOWN brakes to a stop in place (no hitbox reshape) b2kPlayerSet "platformCarry", 1 -- ride the moving platforms (L2 lift bay, L4 lava lift) put 75 into gIntroPan -- a splash beat (~1.2 s), then control -- ===== LEVEL CAST (coins, enemies, checkpoint, the goal flag) ===== @@ -817,7 +815,7 @@ command pfStartGame -- help + the level banner on the splash (visible through the intro -- beat); gCoinsTotal counted itself during the build above if gAssetsOK is true and gLoadNote is empty then - set the text of field "pfHelp" to "Arrows/A-D run, SPACE jumps - press it again in mid-air to DOUBLE JUMP, or off a wall to WALL-JUMP. SHIFT or X = DASH." & cr & "DOWN crawls under low gaps (DOWN+JUMP drops through bridges/clouds); UP/DOWN climbs ladders. R restarts, ESC pauses, M mutes, MOUSE drags the crate." & cr & "LEVEL " & gLevel & " of 4: " & gLevelName & ". Collect ALL " & gCoinsTotal & " coins - the flag turns GOLD - then touch the flag. Four levels to win." + set the text of field "pfHelp" to "Arrows/A-D run, SPACE jumps - press it again in mid-air to DOUBLE JUMP, or off a wall to WALL-JUMP. SHIFT or X = DASH." & cr & "DOWN ducks to a stop (DOWN+JUMP drops through bridges/clouds); UP/DOWN climbs ladders. R restarts, ESC pauses, M mutes, MOUSE drags the crate." & cr & "LEVEL " & gLevel & " of 4: " & gLevelName & ". Collect ALL " & gCoinsTotal & " coins - the flag turns GOLD - then touch the flag. Four levels to win." else if gAssetsOK is true then set the text of field "pfHelp" to "KENNEY CHARACTERS LOADED, but: " & gLoadNote & cr & "Missing atlases fall back to plain shapes." & cr & "Shift+Reset re-asks for the Spritesheets folder." @@ -1767,24 +1765,9 @@ command pfL1Scene set the foregroundColor of it to "70,190,110" b2kCamAdopt the long id of graphic "pf_cloudledgeC" end if - -- the CRAWL TUNNEL (level 1's Wave 5 beat - DOWN now reshapes the capsule - -- shorter, FEET-ANCHORED, so the hero can CRAWL under a low overhang a - -- standing hero cannot clear). The bar bottom is at y515: the standing - -- 76px capsule (head ~y500) is BLOCKED, the ducked ~46px one (head ~y530) - -- slips through the ~61px gap with margin to spare. A coin waits inside - -- (cast). The bar is only ~60px tall, so a jump/double-jump also tops it - -- (no dead end) - but the coin under it is the crawl's reward. Built as a - -- visible earth bar (a solid grass block, like the bat-bar overhang on L4). - pfSlab "pf_crawlbar", 3364, 455, 3556, 515 - set the backgroundColor of graphic "pf_crawlbar" to "90,150,72" - set the visible of graphic "pf_crawlbar" to true - b2kCamAdopt the long id of graphic "pf_crawlbar" - if gAssetsOK is true and b2kSheetHasFrame("tiles", "terrain_grass_block_center") then - set the visible of graphic "pf_crawlbar" to false -- the tiles carry the face - pfTile "terrain_grass_block_center", 3364, 451 - pfTile "terrain_grass_block_center", 3428, 451 - pfTile "terrain_grass_block_center", 3492, 451 - end if + -- (a low CRAWL overhang once sat here for the Wave 5 crawl move; DOWN is now + -- a simple in-place brake, so the overhang is gone and its coin -- cast on + -- the open run below -- is grabbed just by running past it) pfMakeSpikes 2560, 2752 -- the one-way bridge (GHOST RULE: the chain runs one tile past the -- deck on each side - 688..1072 under art 752..1008) @@ -1985,7 +1968,7 @@ command pfL1Cast pfMakeCoin 2876, 500 -- the second act's slime beat pfMakeCoin 3076, 500 -- the skittering mouse's beat pfMakeCoin 3232, 392 -- on the rest cloud - pfMakeCoin 3460, 548 -- inside the CRAWL TUNNEL: duck (DOWN) to slip under for it + pfMakeCoin 3460, 500 -- on the open run (was the crawl-tunnel reward); grab it running past pfMakeCoin 3616, 500 -- the last meadow slime's beat pfMakeCoin 4128, 510 -- mid-bridge: grab it as the planks dip pfMakeCoin 4456, 500 -- the far meadow's ladybug beat From 8dea33342fb6035673cd3a6ec5e8bc87250703a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 22:27:29 +0000 Subject: [PATCH 17/19] Dash: ground-only option (platformer opts in); L4 lava pit breaks the ground DASH (task 1): new player knob airDash (default 1 = current air-dash). The platformer sets airDash 0, so DASH now fires only while grounded -- no more mid-air dash. Gated in b2kPlayerTick's dash trigger. Self-test v18: stTestDash now also asserts the dash does NOT fire when airborne with airDash off (jump, press dash mid-air, confirm no dash state / no burst). LAVA PIT (task 3): L4's ground ran unbroken over the lava strip (3136..3264), so the lava sat hidden under the ground line and its hazard was invisible. The ground now BREAKS there -- pf_ground2 (..3136) + pf_ground3 (3264..) with a gap, and the purple top-tile loop skips the gap -- so the lava tiles fill an open pit you ride the lift or double-jump over (the lava sensor at y548..640 knocks back a fall). pfShowSlabs already lists pf_ground3, so the no-assets fallback shows both halves. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- ...box2dxt-contraption-builder.livecodescript | 5 +++++ examples/box2dxt-demo.livecodescript | 5 +++++ examples/box2dxt-platformer.livecodescript | 14 +++++++++++-- examples/box2dxt-selftest.livecodescript | 20 ++++++++++++++++++- examples/box2dxt-slingshot.livecodescript | 5 +++++ examples/box2dxt-spike-gamekit.livecodescript | 5 +++++ src/box2dxt-kit.livecodescript | 5 +++++ 7 files changed, 56 insertions(+), 3 deletions(-) diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index d5550ed..2c07cdc 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -296,6 +296,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -3426,6 +3427,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -3502,6 +3504,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -4161,6 +4165,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index 3f35abc..9f7019d 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -286,6 +286,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -3416,6 +3417,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -3492,6 +3494,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -4151,6 +4155,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 735a7b0..302a23d 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -792,9 +792,10 @@ command pfStartGame b2kPlayerSet "wallJumpX", 240 -- WALL JUMP: launch away from a wall... b2kPlayerSet "wallJumpY", 430 -- ...and up (matches the ground jump) b2kPlayerSet "wallSlideMax", 120 -- ...after a slowed slide down it - b2kPlayerSet "dashSpeed", 560 -- DASH (SHIFT or X): a flat ground/air zip + b2kPlayerSet "dashSpeed", 560 -- DASH (SHIFT or X): a flat GROUND zip b2kPlayerSet "dashMs", 150 b2kPlayerSet "dashCooldownMs", 420 + b2kPlayerSet "airDash", 0 -- no mid-air dash: DASH only fires while grounded b2kPlayerSet "duckScale", 1 -- DOWN brakes to a stop in place (no hitbox reshape) b2kPlayerSet "platformCarry", 1 -- ride the moving platforms (L2 lift bay, L4 lava lift) put 75 into gIntroPan -- a splash beat (~1.2 s), then control @@ -2572,7 +2573,10 @@ command pfL4Scene put "HAUNTED HOLLOW (spooky!)" into gLevelName pfBounds 6656 pfSlab "pf_ground1", 0, 576, 1856, 640 - pfSlab "pf_ground2", 2048, 576, 6656, 640 + -- the ground BREAKS at the lava pit (3136..3264): two slabs with a gap, so + -- the LAVA strip below is an open hazard you ride the lift / double-jump over + pfSlab "pf_ground2", 2048, 576, 3136, 640 + pfSlab "pf_ground3", 3264, 576, 6656, 640 pfSlab "pf_plat1", 6240, 512, 6432, 576 pfSlab "pf_plat2", 6432, 448, 6560, 576 -- the bat overhang: a stone bar the bats roost under @@ -2582,6 +2586,7 @@ command pfL4Scene pfTile "terrain_purple_block_top", tX, 576 end repeat repeat with tX = 2048 to 6592 step 64 + if tX >= 3136 and tX < 3264 then next repeat -- leave the LAVA PIT open pfTile "terrain_purple_block_top", tX, 576 end repeat pfTile "terrain_purple_block_top_left", 6240, 512 @@ -4327,6 +4332,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -7457,6 +7463,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -7533,6 +7540,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -8192,6 +8201,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then diff --git a/examples/box2dxt-selftest.livecodescript b/examples/box2dxt-selftest.livecodescript index 3b2a01f..ec14e24 100644 --- a/examples/box2dxt-selftest.livecodescript +++ b/examples/box2dxt-selftest.livecodescript @@ -47,7 +47,7 @@ local gRep, gPass, gFail, gFellCount, gRunning local gMsgEnters, gMsgContacts -- message-path counters (vs polling) constant kStUIVersion = "1" -constant kStHarnessV = "17" -- bump on EVERY harness change: the report +constant kStHarnessV = "18" -- bump on EVERY harness change: the report -- header prints it, so a stale paste is -- visible at a glance @@ -1297,6 +1297,19 @@ command stTestDash put round(stVX(tRef)) into tV2 b2kInputInject "" stAssert "cooldown blocks an immediate re-dash (vx " & tV2 & " < 300)", (tV2 < 300) + -- airDash OFF: the dash must NOT fire while airborne (the platformer's + -- "no mid-air dash"). Clear the cooldown, jump, then press dash in the air. + b2kPlayerSet "airDash", 0 + b2kPlayerTuneCache + stStep 40 + b2kPlayerJump 420 + stStep 4 + stAssert "airborne for the mid-air dash test", (b2kPlayerOnGround() is false) + b2kInputInject "x" + stStep 1 + b2kInputInject "" + stAssert "airDash off: no dash mid-air (state " & b2kPlayerState() & ")", (b2kPlayerState() is not "dash") + stAssert "airDash off: no dash burst mid-air (vx " & round(stVX(tRef)) & ")", (abs(stVX(tRef)) < 300) end stTestDash -- Wave 5: a player standing on a horizontally-moving kinematic platform @@ -1697,6 +1710,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -4827,6 +4841,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -4903,6 +4918,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -5562,6 +5579,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then diff --git a/examples/box2dxt-slingshot.livecodescript b/examples/box2dxt-slingshot.livecodescript index ba1d036..729c288 100644 --- a/examples/box2dxt-slingshot.livecodescript +++ b/examples/box2dxt-slingshot.livecodescript @@ -1043,6 +1043,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -4173,6 +4174,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -4249,6 +4251,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -4908,6 +4912,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then diff --git a/examples/box2dxt-spike-gamekit.livecodescript b/examples/box2dxt-spike-gamekit.livecodescript index 9db385c..0726de5 100644 --- a/examples/box2dxt-spike-gamekit.livecodescript +++ b/examples/box2dxt-spike-gamekit.livecodescript @@ -1623,6 +1623,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -4753,6 +4754,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -4829,6 +4831,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -5488,6 +5492,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 8ac2108..65cf0b7 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -260,6 +260,7 @@ local sPlayWall -- airborne wall touch: -1 wall on left, 1 on right, 0 n local sPlayWallSliding -- true while actively wall-sliding (drives state + anim) local sPlayWallLockUntil -- sim-clock through which a wall-jump owns vx (no air steer) local sPlayDashSpd, sPlayDashMS, sPlayDashCool -- dash tune caches +local sPlayAirDash -- true = dash allowed in mid-air (airDash knob; false = grounded only) local sPlayDash -- true while a dash is in flight (gravity parked at 0) local sPlayDashEnd -- sim-clock the dash ends local sPlayDashReady -- sim-clock the next dash may start (the cooldown gate) @@ -3390,6 +3391,7 @@ command b2kPlayerTuneCache put b2kPlayerGet("dashSpeed") into sPlayDashSpd put b2kPlayerGet("dashMs") into sPlayDashMS put b2kPlayerGet("dashCooldownMs") into sPlayDashCool + put (b2kPlayerGet("airDash") is not 0) into sPlayAirDash put b2kPlayerGet("duckScale") into sPlayDuckScale put (b2kPlayerGet("platformCarry") is not 0) into sPlayCarry put cos(b2kPlayerGet("maxSlopeDeg") * kPI / 180) into sPlayCosSlope @@ -3466,6 +3468,8 @@ function b2kPlayerDefault pKey return 160 -- dash duration case "dashcooldownms" return 500 -- minimum gap between dashes + case "airdash" + return 1 -- 1 = dash works mid-air too; 0 = dash only when grounded case "duckscale" return 1 -- ducked capsule height / standing height (1 = no reshape) case "platformcarry" @@ -4125,6 +4129,7 @@ command b2kPlayerTick end if end if if sPlayDash is not true and sPlayDashSpd > 0 and tNow >= sPlayDashReady \ + and (sPlayAirDash is true or sPlayGrounded is true) \ and sPlayClimb is not true and sPlaySwim is not true \ and tInZone is not true and tInWater is not true \ and b2kActionPressed("dash") then From c348a602f953e79fe81d8fb20608d228fa8e6164 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 22:41:33 +0000 Subject: [PATCH 18/19] Platformer: skin the static placeholder slabs with stretched sheet art Task 2 (static platforms): pfTextureSlab lays a COPY of a tile frame, stretched to a slab's exact rect, over the flat-coloured placeholder -- so collision sizes never change and the rect gives way to sheet texture. Applied to the L3 wall-jump pillars (snow block), the L2 lift pedestals (stone), and the swim-pool banks (stone). The texture is a pf_* image so pfWipeStage clears it each build; done at build time (camera scroll 0) so the slab rect reads as world coords. No assets / a missing frame: it no-ops and the flat colour stays (safe fallback). The MOVING lift is left for a follow-up: a static texture can't track it, so it needs a per-frame-followed texture -- I'd rather you eyeball the static ones first (the stretch look is unverifiable here). https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- examples/box2dxt-platformer.livecodescript | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 302a23d..4cda0ec 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -885,6 +885,39 @@ command pfSlab pName, pL, pT, pR, pB b2kAddStatic the long id of graphic pName end pfSlab +-- Skin a STATIC slab with sheet art instead of a flat colour: a COPY of the +-- frame image stretched to the slab's exact rect, so the collision size never +-- changes. The texture is a pf_* image (pfWipeStage clears it next build), laid +-- in at build time only -- the camera scroll is 0 then, so the slab's rect reads +-- as world coords and the adopt preserves the position. No assets or a missing +-- frame: this no-ops and the slab keeps its colour (the placeholder fallback). +command pfTextureSlab pSlab, pFrame + local tSpr, tImg, tName + if gAssetsOK is not true then exit pfTextureSlab + if there is no graphic pSlab then exit pfTextureSlab + if not b2kSheetHasFrame("tiles", pFrame) then exit pfTextureSlab + b2kSpriteNew "tiles", pFrame, -3000, -3000 -- a throwaway sprite carries the sliced frame as its icon + put the result into tSpr + if tSpr is empty then exit pfTextureSlab + put the icon of tSpr into tImg + if tImg is empty or there is no image id tImg then + b2kSpriteRemove tSpr + exit pfTextureSlab + end if + put "pf_tex_" & (the short name of graphic pSlab) into tName + if there is an image tName then delete image tName + lock screen + clone image id tImg + set the name of the last image to tName + set the rect of image tName to the rect of graphic pSlab -- stretch to fit + set the visible of image tName to true -- the sliced frame source is hidden; the copy shows + set the lockLoc of image tName to true + b2kSpriteRemove tSpr + set the visible of graphic pSlab to false -- the texture stands in for the flat fill + b2kCamAdopt the long id of image tName + unlock screen +end pfTextureSlab + command pfShowSlabs pFlag local tC set the itemDelimiter to comma @@ -1939,6 +1972,8 @@ command pfL1Scene set the visible of graphic "pf_pondR" to true b2kCamAdopt the long id of graphic "pf_pondL" b2kCamAdopt the long id of graphic "pf_pondR" + pfTextureSlab "pf_pondL", "terrain_stone_block_center" -- sheet art instead of the flat banks + pfTextureSlab "pf_pondR", "terrain_stone_block_center" if gAssetsOK is true and b2kSheetHasFrame("tiles", "terrain_grass_block_top") then -- grass the bank tops + the far-bank flag approach so the pool reads -- as a basin set into the hill (the floor stays under the water) @@ -2180,6 +2215,8 @@ command pfL2Scene set the visible of graphic "pf_liftpedR" to true b2kCamAdopt the long id of graphic "pf_liftpedL" b2kCamAdopt the long id of graphic "pf_liftpedR" + pfTextureSlab "pf_liftpedL", "terrain_stone_block_center" -- stone pedestals, not flat grey + pfTextureSlab "pf_liftpedR", "terrain_stone_block_center" -- the grinder: a visible danger strip on the floor + a hurt sensor over -- it (the y548 top sits at the floor line, like the lava sensor, so it -- catches a walker but never the deck/rider gliding above at y~500) @@ -2407,6 +2444,8 @@ command pfL3Scene set the visible of graphic "pf_wjShaftR" to true b2kCamAdopt the long id of graphic "pf_wjShaftL" b2kCamAdopt the long id of graphic "pf_wjShaftR" + pfTextureSlab "pf_wjShaftL", "terrain_snow_block_center" -- icy pillars, not flat blue + pfTextureSlab "pf_wjShaftR", "terrain_snow_block_center" -- the glacier run (the level's second act): a snow cloud rest above -- the second sweeping saw's stretch b2kSmoothGround "3008,448" & cr & "2944,448" & cr & "2752,448" & cr & "2688,448" From fe674ab124e6ee8d196dabf4d944692ca1565caa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:03:36 +0000 Subject: [PATCH 19/19] Platformer polish: lift-bay hazard is lava sprite; crate PYRAMID around the keg Fewer GRCs: the L2 lift-bay grinder was a flat red rectangle (pf_grinder) + a hurt sensor. Replaced both with pfMakeLava 3010,3210 -- the same lava sheet tiles + hurt sensor the L4 pit uses -- so the bay hazard is sprite art, not a GRC, and consistent with the rest of the game. Crate pyramid: the powder keg's flat four-crate row is now a small PYRAMID around the barrel -- a four-crate base flanking it, a crate bridging each outer pair, and one capping the barrel. The crates are dynamic + fixed-rotation, so the stack holds its shape and b2kExplode scatters it dramatically. Example-only; gates pass. https://claude.ai/code/session_01H3ZxaY5qDTTPSayfrDg64R --- examples/box2dxt-platformer.livecodescript | 32 +++++++++------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/examples/box2dxt-platformer.livecodescript b/examples/box2dxt-platformer.livecodescript index 4cda0ec..2928a7e 100644 --- a/examples/box2dxt-platformer.livecodescript +++ b/examples/box2dxt-platformer.livecodescript @@ -2217,21 +2217,10 @@ command pfL2Scene b2kCamAdopt the long id of graphic "pf_liftpedR" pfTextureSlab "pf_liftpedL", "terrain_stone_block_center" -- stone pedestals, not flat grey pfTextureSlab "pf_liftpedR", "terrain_stone_block_center" - -- the grinder: a visible danger strip on the floor + a hurt sensor over - -- it (the y548 top sits at the floor line, like the lava sensor, so it - -- catches a walker but never the deck/rider gliding above at y~500) - create graphic "pf_grinder" - set the style of it to "rectangle" - set the rect of it to 3010, 556, 3210, 576 - set the filled of it to true - set the backgroundColor of it to "200,70,70" - b2kCamAdopt the long id of graphic "pf_grinder" - create graphic "pf_grindersens" - set the style of it to "rectangle" - set the rect of it to 3016, 548, 3204, 632 - set the visible of it to false - set the uPfHazardFlag of the long id of graphic "pf_grindersens" to true - b2kAddSensor the long id of graphic "pf_grindersens", "box" + -- the bay's floor hazard, drawn as LAVA (sheet art, not a flat-red GRC): + -- pfMakeLava lays the lava tiles + a hurt sensor whose top sits at the floor + -- line (y548), so it catches a walker but never the deck/rider above at y~500 + pfMakeLava 3010, 3210 -- the bay's moving deck: a kinematic platform shuttling 3020..3200 at -- 60px/s between the pedestals. A grounded hero on it inherits its -- velocity (platformCarry), so it ferries him over the grinder to the far @@ -2677,14 +2666,19 @@ command pfL4Scene -- (no dead end). A slip onto the lava is a knockback (pfOuch), recoverable. pfMakeLift 3150, 510, 80, 22, 3150, 3250, 70 -- the POWDER KEG bay (scenery, so it layers behind the hero): an - -- explosive barrel amid a woodpile of loose crates. Coming near lights + -- explosive barrel ringed by a small PYRAMID of crates. Coming near lights -- the fuse and b2kExplode (the kit's native radial blast, dark in the -- demo until now) scatters the pile and clears the barrel. pfMakeBarrel 3732, 550 - pfMakeWoodCrate 3642, 554 + -- the pyramid: a four-crate base flanking the barrel, a crate bridging each + -- outer pair, and one capping the barrel (dynamic + fixed-rotation, so it holds) + pfMakeWoodCrate 3644, 554 pfMakeWoodCrate 3688, 554 - pfMakeWoodCrate 3778, 554 - pfMakeWoodCrate 3824, 554 + pfMakeWoodCrate 3776, 554 + pfMakeWoodCrate 3820, 554 + pfMakeWoodCrate 3666, 510 + pfMakeWoodCrate 3798, 510 + pfMakeWoodCrate 3732, 502 -- the RED CRUSHER ALLEY (the hollow's marquee gauntlet): a row of danger- -- block thwomps (in the cast) you run beneath, then a two-cloud HOP to the -- purple steps. Both clouds ghost-padded a tile past the art each side.