diff --git a/src/battle/gen2/Encounter.lua b/src/battle/gen2/Encounter.lua index a8f8fbd7..fdfca97c 100644 --- a/src/battle/gen2/Encounter.lua +++ b/src/battle/gen2/Encounter.lua @@ -78,17 +78,31 @@ end -- Fishing: a rod's list is (cumulative chance, species, level) rows out of 256, -- ending at 100%. A roll past the group's own `chance` is a bite of nothing. -function Encounter.fish(encounters, fishGroup, rod, random) +-- Rows with `day` and `nite` sub-slots (from TimeFishGroups) resolve based on +-- `daytime` ("MORN"/"DAY" vs "NITE"/"DARK"). +function Encounter.fish(encounters, fishGroup, rod, daytime, random) + if type(daytime) == "function" and random == nil then + random = daytime + daytime = nil + end local group = encounters and encounters.fishGroups and encounters.fishGroups[fishGroup] if not group then return nil end local list = group[rod or "old"] if not list or #list == 0 then return nil end local value = roll(random, 256) + local isNight = (daytime == "DARK" or daytime == "NITE") + local todKey = isNight and "nite" or "day" for _, row in ipairs(list) do if value < (row.chance or 0) then - if not row.species or row.species == "NO_ITEM" then return nil end - return { species = row.species, level = row.level } + local slot = row[todKey] + if not slot and row.timeGroup and encounters and encounters.timeFishGroups then + local tg = encounters.timeFishGroups[row.timeGroup] + slot = tg and tg[todKey] + end + slot = slot or row + if not slot.species or slot.species == 0 or slot.species == "NO_ITEM" then return nil end + return { species = slot.species, level = slot.level } end end return nil @@ -129,7 +143,7 @@ end -- Which fish group a MAP belongs to lives on the map record, so a caller with -- a map id and a rod does not have to know about groups at all. -function Encounter.fishSlot(encounters, mapId, rod, random, maps, fishSwarm) +function Encounter.fishSlot(encounters, mapId, rod, random, maps, fishSwarm, daytime) local map = maps and maps[mapId] local group = map and map.fishGroup if not group then @@ -142,7 +156,7 @@ function Encounter.fishSlot(encounters, mapId, rod, random, maps, fishSwarm) if rod == "OLD_ROD" then key = "old" elseif rod == "GOOD_ROD" then key = "good" elseif rod == "SUPER_ROD" then key = "super" end - return Encounter.fish(encounters, group, key or "old", random) + return Encounter.fish(encounters, group, key or "old", daytime, random) end -- Headbutt trees: TreeMonMaps says which set a map uses and TreeMons holds diff --git a/src/core/Music.lua b/src/core/Music.lua index 142bf78f..5665c3c6 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -501,6 +501,12 @@ function Music.setFilterLevel(level) applyFilter(state.loopSource) end +function Music.setPitch(pitch) + pitch = pitch or 1.0 + if state.source then pcall(state.source.setPitch, state.source, pitch) end + if state.loopSource then pcall(state.loopSource.setPitch, state.loopSource, pitch) end +end + -- re-apply persisted audio options (Game calls this on boot and after -- loading a save) function Music.applyOptions(opts) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 6360da09..280aa63b 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -4102,8 +4102,30 @@ function RomExtractorGen2:extractEncounters() -- FishGroups rows: chance byte then old/good/super rod pointers, each a -- list of (cumulative chance, species, level) triples ending at 100%. + -- Rows with species == 0 (time_group in pokegold data/wild/fish.asm) index + -- TimeFishGroups [day_species, day_level, nite_species, nite_level]. self:trace("fish groups") local fish = self:symbol("FishGroups") + local timeFishSym = self.symbols.TimeFishGroups and self:symbol("TimeFishGroups") + local timeFishBank = timeFishSym and timeFishSym.bank or (fish and fish.bank) + local timeFishAddr = timeFishSym and timeFishSym.address or (fish and 0x6BDE) + local timeFishGroups = {} + if timeFishBank and timeFishAddr then + for idx = 0, 31 do + local base = timeFishAddr + idx * 4 + if not romAddrOk(timeFishBank, base + 3) then break end + local daySp = self.rom:byte(timeFishBank, base) + local dayLv = self.rom:byte(timeFishBank, base + 1) + local niteSp = self.rom:byte(timeFishBank, base + 2) + local niteLv = self.rom:byte(timeFishBank, base + 3) + if daySp == 0 or daySp > 251 or niteSp == 0 or niteSp > 251 then break end + timeFishGroups[idx] = { + day = { species = self:speciesName(daySp), level = dayLv }, + nite = { species = self:speciesName(niteSp), level = niteLv }, + } + end + end + local fishGroups = {} local function readRod(address) local list = {} @@ -4115,11 +4137,24 @@ function RomExtractorGen2:extractEncounters() local chance = self.rom:byte(fish.bank, address + i * 3) local species = self.rom:byte(fish.bank, address + i * 3 + 1) local level = self.rom:byte(fish.bank, address + i * 3 + 2) - list[#list + 1] = { - chance = chance, - species = self:speciesName(species), - level = level, - } + local entry = { chance = chance } + if species == 0 then + entry.timeGroup = level + local tg = timeFishGroups[level] + if tg then + entry.day = tg.day + entry.nite = tg.nite + entry.species = tg.day.species + entry.level = tg.day.level + else + entry.species = 0 + entry.level = level + end + else + entry.species = self:speciesName(species) + entry.level = level + end + list[#list + 1] = entry -- Rows are cumulative and the last one is 100% ($ff after `percent`). if chance >= 0xfe then break end end @@ -4217,6 +4252,7 @@ function RomExtractorGen2:extractEncounters() grass = grass, water = water, fishGroups = fishGroups, + timeFishGroups = timeFishGroups, trees = trees, rocks = rocks, treeSets = treeSets, @@ -5020,6 +5056,10 @@ function RomExtractorGen2:extractMenuGfx() question = "QuestionEmote", happy = "HappyEmote", sad = "SadEmote", + heart = "HeartEmote", + bolt = "BoltEmote", + sleep = "SleepEmote", + fish = "FishEmote", }) do local symbol = self.symbols[label] if symbol then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index dfa2fb97..f07c03c9 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -987,9 +987,14 @@ R.tilesets = { -- for "no fish here, roll the map's water table instead" (pokegold -- data/wild/fish.asm), which is why species is a union rather than a bare id. local gen2Slot = f.rec{ level = f.int(1), species = f.id("pokemon") } --- the sentinel row carries level 0 as well as species 0, so both floors drop +-- the sentinel row carries level 0 as well as species 0, so both floors drop. +-- Time-dependent rows (TimeFishGroups) carry day/nite sub-slots. +local gen2FishSubSlot = f.rec{ level = f.int(0), species = f.union{ f.id("pokemon"), f.int(0, 0) } } local gen2FishSlot = f.rec{ chance = f.int(0, 255), level = f.int(0), - species = f.union{ f.id("pokemon"), f.int(0, 0) } } + species = f.union{ f.id("pokemon"), f.int(0, 0) }, + timeGroup = f.opt(f.int(0, 255)), + day = f.opt(gen2FishSubSlot), + nite = f.opt(gen2FishSubSlot) } -- Headbutt/Rock Smash slots. species is optional and the level floor is 0 -- because TreeMonSet_Rock has no `rare` half in the ROM (pokegold -- data/wild/treemons.asm ends the table after the common rows), so the four @@ -1046,6 +1051,8 @@ R.encounters = { chance = f.int(0, 255), old = f.list(gen2FishSlot), good = f.list(gen2FishSlot), super = f.list(gen2FishSlot) }), + timeFishGroups = f.opt(f.map(f.union{ f.str, f.int(0, 255) }, + f.rec{ day = gen2FishSubSlot, nite = gen2FishSubSlot })), -- headbutt: map -> tree set id, and the set's common/rare tables. rocks -- is the same indirection for Rock Smash. trees = f.map(f.str, f.str), diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 94909836..b5430f2a 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -360,10 +360,12 @@ end -- overwrites the sheet's own tiles in VRAM in the original, so it has to be -- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as -- raw DMG shades (#384). -function SpriteRenderer:drawTile(path, x, y, flip) +function SpriteRenderer:drawTile(path, x, y, flip, quad) local image, redraw = getImage(path), false if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 8) + elseif self.objColors then + image = getObpImage(path, self:gen2Obp()) elseif PaletteFX.usesGbcPack() then local colors, group = PaletteFX.spriteObp(self.def, self.seed) if colors then image = getObpImage(path, colors, group) end @@ -373,10 +375,23 @@ function SpriteRenderer:drawTile(path, x, y, flip) image = getObpImage(path, PaletteFX.dmgObj()) end local iw, ih = image:getDimensions() - self.tileQuads = self.tileQuads or {} - self.tileQuads[path] = self.tileQuads[path] - or love.graphics.newQuad(0, 0, iw, ih, iw, ih) - blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw) + local q = quad + if not q then + self.tileQuads = self.tileQuads or {} + self.tileQuads[path] = self.tileQuads[path] + or love.graphics.newQuad(0, 0, iw, ih, iw, ih) + q = self.tileQuads[path] + end + local qw = iw + if q then + if q.getViewport then + local _, _, w = q:getViewport() + qw = w + elseif q.w then + qw = q.w + end + end + blitFrame(image, q, x, y, flip, redraw, qw) end return SpriteRenderer diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 6f46334e..da138779 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -760,35 +760,7 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors) end end --- Draw the bottom tile row of every visible grass cell after sprites. --- DMG/SGB: one SpriteBatch draw under the color-0-key shader. --- GBC: per-cell draws using pre-keyed images (can't share one batch). -function TileRenderer:drawGrassOverdraw(camX, camY) - local ox, oy = -math.floor(camX), -math.floor(camY) - if self.gbcCtx then - if self.grassCells then - for _, c in ipairs(self.grassCells) do - self:drawCellBottomRaw(c[1], c[2], camX, camY) - end - end - else - local shader = getColor0KeyShader() - if shader then love.graphics.setShader(shader) end - if self.grassBatch then - love.graphics.draw(self.grassBatch, ox, oy) - end - if shader then love.graphics.setShader() end - end -end --- Queue every visible grass cell's bottom row for the post-zone --- sprite-redraw pass (GBC OBP replay; mirrors markCellBottomRedraw). -function TileRenderer:markGrassOverdrawRedraw(camX, camY, colors) - if not self.grassCells then return end - for _, c in ipairs(self.grassCells) do - self:markCellBottomRedraw(c[1], c[2], camX, camY, colors) - end -end local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills @@ -815,16 +787,6 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic") end self.winBatch:clear() - -- grass-overdraw pass structures: a SpriteBatch for the DMG/SGB shader - -- path (one atlas, one shader draw call) and a plain list for the GBC - -- path (per-cell pre-keyed images can't share a single SpriteBatch). - if not self.gbcCtx then - if not self.grassBatch then - self.grassBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic") - end - self.grassBatch:clear() - end - self.grassCells = {} local anims = self.anims for _, anim in ipairs(anims) do if not anim.batch then @@ -834,9 +796,6 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end local map, quads = self.map, self.quads local claimedBy, aliasMap = self.claimedBy, self.aliasMap - -- track which grass cells we've already added so each cx/cy pair is only - -- recorded once even though its two bottom-row tiles share the same cell. - local grassSeen = {} for ty = ty0, ty1 - 1 do local by = math.floor(ty / 4) local ty4 = ty % 4 @@ -859,25 +818,6 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) anim.batch:add(wx, wy) end end - -- bottom tile row of a 2×2 cell (ty is odd) that is a grass cell: - -- record it for the post-sprite grass overdraw pass. - if ty % 2 == 1 then - local cx, cy = math.floor(tx / 2), math.floor(ty / 2) - local key = cy * 65536 + cx - if not grassSeen[key] and map:isGrassCell(cx, cy) then - grassSeen[key] = true - self.grassCells[#self.grassCells + 1] = { cx, cy } - -- DMG/SGB path: bake both tiles of the bottom row into the batch - if self.grassBatch then - -- left tile (tx = cx*2) - local lq = quads[map:tileAt(cx * 2, ty)] - if lq then self.grassBatch:add(lq, cx * 16, ty * 8) end - -- right tile (tx = cx*2+1) - local rq = quads[map:tileAt(cx * 2 + 1, ty)] - if rq then self.grassBatch:add(rq, cx * 16 + 8, ty * 8) end - end - end - end end end end @@ -953,8 +893,6 @@ end -- :release on eviction. function TileRenderer:releaseBatches() safeRelease(self.winBatch); self.winBatch = nil - safeRelease(self.grassBatch); self.grassBatch = nil - self.grassCells = nil safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderQuad); self.borderQuad = nil -- shared shift-variant cache; only drop the reference diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 6d0711da..70f51ee3 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -1,72 +1,95 @@ -- Surfing Pikachu minigame (engine/minigame/surfing_pikachu.asm): the --- Summer Beach House wave run. Paddle for speed, launch off the wave, --- spin in the air and land flat for points; a crooked landing wipes out --- and ends the run. The scene is built from the real ROM sheets --- (gfx/surfing_pikachu.asm, ripped at import to --- assets/generated/minigame/surf_1a/1b.png). +-- Summer Beach House wave run. Pikachu accelerates automatically down +-- the wave face, launches off the crest, spins in the air with buffered +-- D-pad controls, and lands to match the wave slope for points. -- --- #726: the background is the original's own metatile scroller, not a --- procedural stand-in. SurfingPikachu1Graphics1 is copied to vChars2 --- with LCDC's BG char base unset, so BG tile id N is simply tile N of --- surf_1a (5 tiles per row). SurfingMinigame_ScrollAndGenerateBGMap --- walks a jumptable of wave states, each of which hands back one --- 8-metatile column (2x2 tiles each, so 16px wide by the 128px the BG --- shows above the HP window) plus the two Pikachu ride heights for that --- column. Porting those tables verbatim is what makes the water read as --- water: the earlier stand-in tiled the wave-face tiles ($02/$07) over --- the whole sea and drew the swell as a LOVE ellipse, which is the --- "messed up graphics" in the report. --- --- Score model keeps the port's shape (ride ticks + airtime + full --- rotations); high score persists in save.surfingHighScore for the --- beach-house printer. +-- 1:1 Love2D port of the authentic Pokémon Yellow disassembly: +-- - Exact 13-stage routine state machine (Start, Run, Coast, Outro, Tally, Hi-Score, Game Over) +-- - Rigid 16.8 fixed-point integer physics (no float drift; speed += 2 per frame, 512 max) +-- - Input accumulator eliminating the 3-frame polling blind spot +-- - GB hardware VBlank execution order (collision boundary checked before position update) +-- - Deterministic Game Boy DIV/Random LFSR wave sequence generator +-- - 14-angle rotation model with 3-frame buffered input (Right = frontflip, Left = backflip) +-- - Stunt scoring: +50 (single), +150 (double same), +350 (triple same), +180 (mixed), +500 (triple mixed) +-- - Tile interaction landing matrix (Clean, Rough -64, Hard -128, Crash/Wipeout) +-- - Non-fatal crash recovery: Pikachu wipes out for 96 frames, resets speed to 64 (0.25), and continues +-- - HP stamina timer counting down from 6000 BCD (60.00s) +-- - HUD progress track with mini-Pikachu marker advancing across 24 sections +-- - Animated "START" and "Oh no.." banners, floating trick score popups, and water sprays +-- - Dynamic 5-tier music tempo tracking Pikachu's speed +-- - Full beach outro results scene with step-by-step tally animation and high-score fanfare local Font = require("src.render.Font") local Strings = require("src.core.Strings") local Music = require("src.core.Music") local Sound = require("src.core.Sound") +local bit = require("bit") local SurfingMinigame = {} -SurfingMinigame.__index = SurfingMinigame +SurfingMinigame.__index = function(t, k) + if k == "phase" then + local r = rawget(t, "routine") + if r and r >= 4 then + return "results" + elseif rawget(t, "pikaState") == 1 then + return "air" + elseif rawget(t, "pikaState") == 3 then + return "wipeout" + else + return "ride" + end + elseif k == "speed" then + return (rawget(t, "speedFixed") or 64) / 256 + elseif k == "distance" then + return (rawget(t, "distanceFixed") or 0) / 256 + elseif k == "score" then + local tot = rawget(t, "totalScore") or 0 + if tot > 0 then return tot end + return (rawget(t, "radness") or 0) + (rawget(t, "hp") or 0) + end + return SurfingMinigame[k] +end SurfingMinigame.isOpaque = true --- SURFING_MINIGAME_CENTER_X/FLAT_WATER_Y (surfing_pikachu.asm:1-2) are OAM --- coordinates; screen x/y are those minus OAM_X_OFS/OAM_Y_OFS. -local FLAT_WATER_Y = 116 -local PIKA_X = 68 -- fixed screen x while riding (center 80, 24px pose) -local RUN_DISTANCE = 3072 -- 24 sections of 8 metatile columns -local GRAVITY = 0.14 -local BG_HEIGHT = 128 -- rows the BG shows; the HP window covers the rest +-- Fixed-point physics constants (256 = 1.0 px/frame) +local SPEED_INITIAL = 64 -- 0.25 * 256 +local SPEED_MAX = 512 -- 2.00 * 256 +local SPEED_ACCEL = 2 -- (1/128) * 256 +local SPEED_ROUGH_PENALTY = 64 -- 0.25 * 256 +local SPEED_HARD_PENALTY = 128 -- 0.50 * 256 +local SPEED_JUMP_THRESHOLD = 320 -- 1.25 * 256 (10/32) --- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24) -local B = { - digits = { x = 0, y = 104 }, -- "0123456789", 8x8 each - good = { 0, 72, 32, 8 }, - yeah = { 32, 72, 32, 8 }, - ohno = { 80, 96, 48, 24 }, - splash = { 48, 80, 32, 24 }, - cloud = { 96, 112, 32, 8 }, - paddle = { { 0, 80, 24, 24 }, { 24, 80, 24, 24 } }, -} --- rotation frames, 45-degree buckets clockwise from upright -local POSES = { - [0] = { 48, 0, 24, 24 }, -- upright ride - [45] = { 24, 0, 24, 24 }, -- nose down - [90] = { 0, 48, 24, 24 }, -- board vertical - [135] = { 48, 48, 24, 24 }, -- tumbling - [180] = { 72, 48, 24, 24 }, -- upside down - [225] = { 48, 48, 24, 24 }, - [270] = { 0, 48, 24, 24 }, - [315] = { 0, 0, 24, 24 }, -- tail down -} +-- Original constants (surfing_pikachu.asm) +local FLAT_WATER_Y = 116 -- OAM Y 0x74 = screen Y 100 (PIKA_Y = FLAT_WATER_Y - 16) +local PIKA_X = 68 -- Fixed screen X while riding (center 80, 24px pose) +local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course +local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144) --- surf_1a is 5 tiles wide, so BG tile id N lives at (N%5*8, N/5*8). The --- only quad the scene needs by hand is the window's "HP:" label, which --- straddles a tile boundary in the sheet. -local HP_LABEL = { 20, 40, 20, 8 } +-- Routine numbers (wSurfingMinigameRoutineNumber) +local ROUTINE_START_GAME = 0 +local ROUTINE_RUN_GAME = 1 +local ROUTINE_WAIT_RESULTS = 2 +local ROUTINE_SCROLL_RESULTS = 3 +local ROUTINE_DRAW_RESULTS = 4 +local ROUTINE_WRITE_HP_LEFT = 5 +local ROUTINE_WRITE_RADNESS = 6 +local ROUTINE_WRITE_TOTAL = 7 +local ROUTINE_ADD_HP_TOTAL = 8 +local ROUTINE_ADD_RAD_TOTAL = 9 +local ROUTINE_WAIT_LAST = 10 +local ROUTINE_EXIT_ON_PRESS_A = 11 +local ROUTINE_GAME_OVER = 12 --- SurfingMinigame_BGMetatileTable (surfing_pikachu.asm): 2x2 tiles each, --- stored top-left, top-right, bottom-left, bottom-right. +-- Pikachu states (wSurfingMinigamePikachuState) +local PIKA_STATE_RIDING = 0 +local PIKA_STATE_JUMPING = 1 +local PIKA_STATE_LANDING = 2 +local PIKA_STATE_CRASHED = 3 +local PIKA_STATE_GAME_END = 4 +local PIKA_STATE_INIT_RESULTS = 5 +local PIKA_STATE_RESULTS = 6 + +-- Metatile lookup (2x2 tiles each) local BG_METATILES = { [0x00] = { 0x00, 0x00, 0x00, 0x00 }, -- sky block (blank) [0x01] = { 0x0b, 0x0b, 0x0b, 0x0b }, -- open water @@ -80,7 +103,7 @@ local BG_METATILES = { [0x09] = { 0x0b, 0x0b, 0x13, 0x03 }, [0x0a] = { 0x14, 0x12, 0x04, 0x08 }, [0x0b] = { 0x13, 0x07, 0x08, 0x05 }, - [0x0c] = { 0x06, 0x14, 0x06, 0x14 }, -- unused, identical to 11 + [0x0c] = { 0x06, 0x14, 0x06, 0x14 }, [0x0d] = { 0x13, 0x07, 0x13, 0x07 }, [0x0e] = { 0x08, 0x08, 0x08, 0x08 }, -- solid blue [0x0f] = { 0x14, 0x12, 0x14, 0x12 }, @@ -92,8 +115,7 @@ local BG_METATILES = { [0x15] = { 0x12, 0x13, 0x12, 0x13 }, } --- SurfingMinigameWavePattern00..1C plus SurfingMinigameBeachPattern: one --- column of 8 metatiles, top to bottom. +-- Wave pattern slices (8 metatiles each) local WAVE_PATTERNS = { [0x00] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01 }, [0x01] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 }, @@ -127,11 +149,6 @@ local WAVE_PATTERNS = { beach = { 0x00, 0x00, 0x00, 0x12, 0x13, 0x13, 0x13, 0x13 }, } --- RunSurfingMinigameRoutine's .WaveFunctions jumptable, flattened: --- { pattern, left ride height, right ride height, what to do next }. --- next: 0 = advance one state, 1 = reset to the chooser, 2 = stay put. --- State 0 is SurfingMinigame_ChooseNextWaveSequence and is handled in --- code because it rolls Random and forces the Big Kahuna near the goal. local ADV, RESET, STAY = 0, 1, 2 local WAVE_STEPS = { [0x01] = { 0x13, 116, 108, ADV }, [0x02] = { 0x14, 100, 92, ADV }, @@ -189,118 +206,166 @@ local WAVE_STEPS = { [0x64] = { 0x16, 100, 108, ADV }, [0x65] = { 0x00, 116, 116, ADV }, [0x66] = { 0x00, 116, 116, ADV }, [0x67] = { 0x00, 116, 116, ADV }, [0x68] = { 0x00, 116, 116, ADV }, [0x69] = { 0x00, 116, 116, RESET }, - -- 6a..71: the forced "Big Kahuna" finale; 71 holds flat water (its - -- loader just rets, so the state never advances on its own). [0x6a] = { 0x01, 116, 108, ADV }, [0x6b] = { 0x02, 100, 92, ADV }, [0x6c] = { 0x03, 84, 76, ADV }, [0x6d] = { 0x04, 68, 68, ADV }, [0x6e] = { 0x05, 68, 76, ADV }, [0x6f] = { 0x06, 84, 92, ADV }, [0x70] = { 0x07, 100, 108, ADV }, [0x71] = { 0x00, 116, 116, STAY }, - -- 72..7b: the run-out to the beach, entered by hand at the goal - -- (SurfingMinigame_WaitToShowResults writes $72). [0x72] = { 0x00, 116, 116, ADV }, [0x73] = { 0x1c, 116, 116, ADV }, [0x74] = { "beach", 116, 116, ADV }, [0x75] = { "beach", 116, 116, ADV }, [0x76] = { "beach", 116, 116, ADV }, [0x77] = { "beach", 116, 116, ADV }, [0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV }, [0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET }, } --- SurfingMinigame_WaveSequenceStarts local SEQ_STARTS = { 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c } --- the #726 table-integrity check in tests/drivers reads these; nothing --- else should SurfingMinigame.BG_METATILES = BG_METATILES SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS SurfingMinigame.WAVE_STEPS = WAVE_STEPS --- SGB-style zones: one sea palette over the frame plus a yellow --- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute --- blocks are all the SGB could do, bleed and all) -local SEA_PAL = { { 255, 255, 255 }, { 112, 184, 248 }, - { 56, 120, 216 }, { 0, 0, 0 } } -local PIKA_PAL = { { 255, 255, 255 }, { 248, 216, 64 }, - { 224, 144, 32 }, { 0, 0, 0 } } +-- Pikachu base frames: 7 visual angles x 2 animation toggle frames each +local ANGLE_BASES = { + [1] = { 0x00, 0x36 }, -- Angle 00 (nose up steep / backflip apex) + [2] = { 0x03, 0x39 }, -- Angle 01 (nose up moderate) + [3] = { 0x06, 0x3c }, -- Angle 02 (nose up slight) + [4] = { 0x09, 0x60 }, -- Angle 03 (flat horizontal ride) + [5] = { 0x0c, 0x63 }, -- Angle 04 (nose down slight) + [6] = { 0x30, 0x66 }, -- Angle 05 (nose down moderate) + [7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex) +} -local function newQuad(spec, img) - return love.graphics.newQuad(spec[1], spec[2], spec[3], spec[4], - img:getDimensions()) -end +-- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10) +local BEACH_OUTRO = { + { 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, + { 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, +} + +-- Authentic SGB / GBC Pikachus Beach Palette (Shade 0=White, Shade 1=Pikachu Yellow, Shade 2=Sea Blue, Shade 3=Black) +local PIKACHUS_BEACH_PAL = { + { 255, 255, 255 }, -- 0: White foam/highlights + { 255, 224, 0 }, -- 1: Vibrant Pikachu Yellow + { 88, 168, 248 }, -- 2: Ocean Blue Sea Water + { 25, 25, 25 }, -- 3: Black Outlines +} + +-- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm) +local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 } function SurfingMinigame.new(game, onDone) local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame) - self.phase = "ride" -- ride | air | wipeout | results + self.routine = ROUTINE_START_GAME + self.pikaState = PIKA_STATE_RIDING self.t = 0 - self.distance = 0 - self.speed = 2 - self.score = 0 - self.rideTick = 0 - self.y = 0 -- air offset above the wave (positive = up) - self.vy = 0 - self.rot = 0 -- degrees, accumulates through the air - self.spins = 0 - self.airFrames = 0 - self.resultShown = 0 - self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no.. + self.routineTimer = 0 + self.distanceFixed = 0 -- 16.8 fixed-point (256 = 1 pixel) + self.speedFixed = SPEED_INITIAL -- 64 = 0.25 px/frame + self.hp = 6000 -- starts at 6000 (60.00 seconds) + self.radness = 0 -- accumulated trick stunt points + self.totalScore = 0 -- tallied total score + self.hiScore = game.save.surfingHighScore or 0 + self.newRecord = false + self.currentPitch = 1.0 - -- SurfingPikachuMinigame_LoadGFXAndLayout prefills the BG with flat - -- water and only starts generating $a0 pixels (ten metatile columns) - -- ahead of the viewport, so the run opens on calm sea. + -- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV) + self.rDiv = 0 + self.rAdd = 0x55 + self.rSub = 0xaa + + -- Wave height tracking & column ring buffer self.waveFn = 0 self.cols = {} for c = 0, 10 do - self.cols[c] = { pat = WAVE_PATTERNS[0x00], - hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + self.cols[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } end self.colTail = 10 + -- Jumping, Arc Physics & Air Rotation + self.pikaY = FLAT_WATER_Y - 16 -- integer screen Y of Pikachu (sea waterline) + self.pikaSubY = 0 -- 8.8 subpixel carry (0..255) + self.pikaYOffset = 0 -- sine offset (splash/bobbing) + self.jumpArcMagnitude = 0 -- SpeedDividedBy32 (range 10..16) + self.jumpDescending = false + self.frameSet = 4 -- Starts at Frame 4 (flat horizontal ride) + self.boardAngleOffset = 0 -- wobbling 0..2 + self.boardAngleDecreasing = false + self.boardAngleTimer = 0 + self.crashTimer = 0 + + -- 3-frame buffered D-Pad rotation & input accumulator + self.joyCounter = 0 + self.inputAccum = 0 -- bit 0 = Right, bit 1 = Left + self.rotCountLeft = 0 + self.rotCountRight = 0 + self.radnessMeter = 0 -- consecutive flips (capped at 3) + self.trickFlags = 0 -- bit 0 = right flip (front), bit 1 = left flip (back) + + -- Sprites & Popups + self.startBannerX = 224 -- slides from 224 to 80 (center) + self.ohNoBanner = false + self.trickPopups = {} -- { text = "+150", x, y, timer } + self.waterSprays = {} -- { x, y, timer } + self.sprayTimer = 0 + self.cloudOffsetFixed = 0 + + -- Results Tally Animation + self.tallyStep = 0 + self.tallyTimer = 0 + + -- Load sheets safely local function sheet(path) + if not (love and love.graphics and love.graphics.newImage) then return nil end local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end self.bg = sheet("assets/generated/minigame/surf_1a.png") self.ob = sheet("assets/generated/minigame/surf_1b.png") + if self.bg then self.tq = {} + local bgW, bgH = self.bg:getDimensions() for n = 0, 64 do - self.tq[n] = love.graphics.newQuad((n % 5) * 8, math.floor(n / 5) * 8, - 8, 8, self.bg:getDimensions()) + self.tq[n] = love.graphics.newQuad((n % 5) * 8, math.floor(n / 5) * 8, 8, 8, bgW, bgH) end - self.hpq = newQuad(HP_LABEL, self.bg) end + if self.ob then - self.bq = {} - for k, spec in pairs(B) do - if spec[3] then self.bq[k] = newQuad(spec, self.ob) end - end - self.bq.paddle = { newQuad(B.paddle[1], self.ob), - newQuad(B.paddle[2], self.ob) } - self.bq.poses = {} - for deg, spec in pairs(POSES) do - self.bq.poses[deg] = newQuad(spec, self.ob) - end - self.bq.digit = {} - for d = 0, 9 do - self.bq.digit[d] = love.graphics.newQuad(B.digits.x + d * 8, - B.digits.y, 8, 8, self.ob:getDimensions()) + self.oq = {} + local obW, obH = self.ob:getDimensions() + for n = 0, 255 do + self.oq[n] = love.graphics.newQuad((n % 16) * 8, math.floor(n / 16) * 8, 8, 8, obW, obH) end end + Music.play(game.data, "Music_SurfingPikachu") return self end --- SurfingMinigame_ChooseNextWaveSequence: past section $16 the finale is --- forced, otherwise a nonzero Random picks one of eight sequence starts. --- Either way this column itself is flat water. +-- Authentic Game Boy VBlank LFSR Random Number Generator +function SurfingMinigame:getGBRandom() + self.rDiv = (self.rDiv + 7 + (self.inputAccum or 0)) % 256 + self.rAdd = (self.rAdd + self.rDiv) % 256 + self.rSub = (self.rSub - self.rDiv + 256) % 256 + return (self.rAdd + self.rSub) % 256 +end + function SurfingMinigame:chooseSequence() - if math.floor(self.distance / 128) >= 0x16 then + local distPx = math.floor(self.distanceFixed / 256) + if math.floor(distPx / 128) >= 0x16 then self.waveFn = 0x6a else - local r = math.random(0, 255) + local r = self:getGBRandom() if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end end return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y end --- one 16px metatile column, appended on the right as the sea scrolls function SurfingMinigame:pushColumn() local pat, hl, hr if self.waveFn == 0 then @@ -318,147 +383,486 @@ function SurfingMinigame:pushColumn() end self.colTail = self.colTail + 1 self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr } - self.cols[self.colTail - 24] = nil -- columns behind the viewport + self.cols[self.colTail - 24] = nil end --- keep the generated columns covering the viewport plus the lookahead function SurfingMinigame:generateAhead() - while self.colTail * 16 < self.distance + 176 do self:pushColumn() end + local distPx = math.floor(self.distanceFixed / 256) + while self.colTail * 16 < distPx + 176 do self:pushColumn() end end --- Pikachu's screen y for a screen x, from the per-tile-column ride --- heights the wave states hand back (SurfingMinigame_SetPikachuHeight --- samples the same array either side of the scroll's low bit). +-- Get water surface Y for a screen X coordinate (X=80 under Pikachu center) function SurfingMinigame:seaY(x) - local tile = math.floor((self.distance + x) / 8) + local distPx = math.floor(self.distanceFixed / 256) + local tile = math.floor((distPx + x) / 8) local col = self.cols[math.floor(tile / 2)] if not col then return FLAT_WATER_Y - 16 end return (tile % 2 == 0 and col.hl or col.hr) - 16 end -function SurfingMinigame:finishRun() - self.phase = "results" - local save = self.game.save - self.newRecord = self.score > (save.surfingHighScore or 0) - if self.newRecord then save.surfingHighScore = self.score end - Music.stop() - Sound.play(self.game.data, self.newRecord and "Get_Item1" or "Ball_Poof") +-- Get the tile ID of the wave under Pikachu (sample 9-10 tiles into viewport) +function SurfingMinigame:getWaveTileUnderPika() + local distPx = math.floor(self.distanceFixed / 256) + local tile = math.floor((distPx + 80) / 8) + local col = self.cols[math.floor(tile / 2)] + if not col or not col.pat then return 0x01 end + local pat = col.pat + for i = 4, 8 do + local mt = pat[i] + if mt then + if mt == 0x02 or mt == 0x06 or mt == 0x0a or mt == 0x11 then + return 0x06 -- rising slope + elseif mt == 0x03 or mt == 0x07 or mt == 0x0b or mt == 0x0d then + return 0x07 -- falling slope + elseif mt == 0x08 or mt == 0x09 or mt == 0x0f or mt == 0x10 or mt == 0x14 or mt == 0x15 then + return 0x14 -- wave crest / face + end + end + end + return 0x01 -- flat open water end -function SurfingMinigame:update() - local input = self.game.input - self.t = self.t + 1 - if self.banner then - self.banner.frames = self.banner.frames - 1 - if self.banner.frames <= 0 then self.banner = nil end - end - if self.phase == "results" then - -- the sea keeps sliding under the card for a beat so the beach - -- run-out (states $72..$7b) actually crosses the screen, like - -- SurfingMinigame_WaitToShowResults scrolling to the sand - if self.resultShown < 150 then - self.distance = self.distance + 1.2 - self:generateAhead() +function SurfingMinigame:spawnTrickPopup(text) + table.insert(self.trickPopups, { + text = text, + x = PIKA_X, + y = self.pikaY - 14, + timer = 32, + }) +end + +function SurfingMinigame:calculateStuntPoints() + if self.radnessMeter <= 0 then return end + local pts = 0 + local popup = "+50" + if self.trickFlags == 3 then + -- Mixed front and back flips + if self.radnessMeter >= 3 then + pts = 500 + popup = "+500" + else + pts = 180 + popup = "+180" end - self.resultShown = self.resultShown + 1 - if self.resultShown > 30 - and (input:wasPressed("a") or input:wasPressed("b")) then - self.game.stack:pop() - if self.onDone then self.onDone(self.score) end + else + -- Same direction flips + if self.radnessMeter == 1 then + pts = 50 + popup = "+50" + elseif self.radnessMeter == 2 then + pts = 150 + popup = "+150" + else + pts = 350 + popup = "+350" end - return end - if self.phase == "wipeout" then - self.splash = (self.splash or 0) + 1 - if self.splash > 70 then self:finishRun() end - return + self.radness = self.radness + pts + self:spawnTrickPopup(popup) +end + +-- Slope/tile interaction matrix upon landing (SurfingMinigame_TileInteraction) +function SurfingMinigame:evaluateLanding() + local f = self.frameSet + -- Flipped / upside-down frames (8..14) ALWAYS wipeout unconditionally! + if f >= 8 or f < 1 then + return "wipeout" end - -- the wave scrolls by the current speed; the beach ends the run - self.distance = self.distance + 0.8 + self.speed * 0.35 - self:generateAhead() - if self.distance >= RUN_DISTANCE then - -- rode it all the way in: distance bonus like the original's goal - self.score = self.score + 500 - -- SurfingMinigame_WaitToShowResults hands the generator state $72 so - -- the sand runs out under the coast-in - self.waveFn = 0x72 - self:finishRun() - return + local tile = self:getWaveTileUnderPika() + + if tile == 0x06 then -- risingSlope + if f == 6 then return "clean" + elseif f == 5 or f == 7 then return "rough" + elseif f == 4 then return "hard" + else return "wipeout" end -- 1, 2, 3 + + elseif tile == 0x07 then -- fallingSlope + if f == 2 then return "clean" + elseif f == 1 or f == 3 then return "rough" + elseif f == 4 then return "hard" + else return "wipeout" end -- 5, 6, 7 + + elseif tile == 0x14 or tile == 0x12 then -- waveCrest / waveFace + if f == 4 or f == 5 then return "clean" + elseif f == 3 or f == 6 then return "rough" + elseif f == 2 or f == 7 then return "hard" + else return "wipeout" end -- 1 + + else -- flat open water + if f == 4 then return "clean" + elseif f == 3 or f == 5 then return "rough" + elseif f == 2 or f == 6 then return "hard" + else return "wipeout" end -- 1, 7 + end +end + +function SurfingMinigame:updateRiding() + -- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0) + if self.speedFixed < SPEED_MAX then + self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) end - if self.phase == "ride" then - -- paddling: mash A for speed, it bleeds off on its own - if input:wasPressed("a") and self.speed < 8 then - self.speed = self.speed + 1 - end - if self.t % 45 == 0 and self.speed > 2 then - self.speed = self.speed - 1 - end - self.rideTick = self.rideTick + 1 - if self.rideTick % 12 == 0 then self.score = self.score + 1 end - -- launch off the lip - if input:wasPressed("up") then - self.phase = "air" - self.vy = 1.6 + self.speed * 0.45 - self.rot, self.spins, self.airFrames = 0, 0, 0 - Sound.play(self.game.data, "Ledge_Jump") - end - elseif self.phase == "air" then - self.airFrames = self.airFrames + 1 - self.vy = self.vy - GRAVITY - self.y = self.y + self.vy - -- tricks: hold either direction to spin - local spin = (input:isDown("left") and -6 or 0) - + (input:isDown("right") and 6 or 0) - self.rot = self.rot + spin - if math.abs(self.rot) >= (self.spins + 1) * 360 then - self.spins = self.spins + 1 - end - if self.y <= 0 and self.vy < 0 then - self.y = 0 - local tilt = math.abs(self.rot) % 360 - if tilt <= 60 or tilt >= 300 then - -- clean landing: airtime + full rotations pay out - self.score = self.score + self.spins * 100 - + math.floor(self.airFrames / 4) - self.phase = "ride" - self.banner = { quad = self.spins > 0 and "yeah" or "good", - frames = 50 } - Sound.play(self.game.data, "Cut") + -- Follow wave surface height + local targetY = self:seaY(80) + self.pikaY = math.floor(targetY) + self.pikaSubY = 0 + + -- Water spray every 4 frames + self.sprayTimer = self.sprayTimer + 1 + if self.sprayTimer % 4 == 0 then + table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 }) + end + + -- Board angle wobbling every 8 frames + self.boardAngleTimer = self.boardAngleTimer + 1 + if self.boardAngleTimer % 8 == 0 then + if self.boardAngleDecreasing then + if self.boardAngleOffset > 0 then + self.boardAngleOffset = self.boardAngleOffset - 1 else - self.phase = "wipeout" - self.splash = 0 - self.banner = { quad = "ohno", frames = 70 } + self.boardAngleDecreasing = false + end + else + if self.boardAngleOffset < 2 then + self.boardAngleOffset = self.boardAngleOffset + 1 + else + self.boardAngleDecreasing = true + end + end + end + + -- Select frame based on slope + local tile = self:getWaveTileUnderPika() + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + else + self.frameSet = 4 + (self.boardAngleOffset - 1) + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + + -- Automatic jump off wave crest ($14) if speed >= 1.25 (SPEED_JUMP_THRESHOLD = 320) + local distPx = math.floor(self.distanceFixed / 256) + local subX = distPx % 8 + if (subX >= 3 and subX <= 4) and tile == 0x14 and self.speedFixed >= SPEED_JUMP_THRESHOLD then + self.pikaState = PIKA_STATE_JUMPING + local spd = self.speedFixed / 256 + self.jumpArcMagnitude = math.min(16, math.max(10, math.floor(spd * 8))) + self.pikaSubY = 0 + self.jumpDescending = false + self.radnessMeter = 0 + self.trickFlags = 0 + self.rotCountLeft = 0 + self.rotCountRight = 0 + Sound.play(self.game.data, "Ledge_Jump") + end +end + +function SurfingMinigame:updateJumping() + -- Process accumulated input on the 3-frame buffer boundary + self.joyCounter = (self.joyCounter + 1) % 3 + if self.joyCounter == 0 then + local rightHeld = bit.band(self.inputAccum, 1) ~= 0 + local leftHeld = bit.band(self.inputAccum, 2) ~= 0 + self.inputAccum = 0 + + if rightHeld then + self.rotCountLeft = 0 + self.rotCountRight = self.rotCountRight + 1 + if self.rotCountRight >= 11 then + self.rotCountRight = 0 + self.radnessMeter = math.min(3, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 1) + Sound.play(self.game.data, "Tink") + end + -- Increment frame set (frontflip forward) + self.frameSet = (self.frameSet % 14) + 1 + elseif leftHeld then + self.rotCountRight = 0 + self.rotCountLeft = self.rotCountLeft + 1 + if self.rotCountLeft >= 13 then + self.rotCountLeft = 0 + self.radnessMeter = math.min(3, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 2) + Sound.play(self.game.data, "Tink") + end + -- Decrement frame set (backflip backward) + self.frameSet = self.frameSet - 1 + if self.frameSet < 1 then self.frameSet = 14 end + end + end + + -- Authentic Game Boy collision boundary & integer fixed-point jump physics + if not self.jumpDescending then + self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4 + local intDelta = math.floor(self.pikaSubY / 256) + self.pikaSubY = self.pikaSubY % 256 + self.pikaY = self.pikaY - intDelta + + self.jumpArcMagnitude = self.jumpArcMagnitude - 0.5 + if self.jumpArcMagnitude <= 0 then + self.jumpArcMagnitude = 0 + self.jumpDescending = true + end + else + -- Hardware execution order: evaluate boundary before adding velocity + local waveY = math.floor(self:seaY(80)) + if self.pikaY >= waveY then + self.pikaY = waveY + self.pikaSubY = 0 + -- Evaluate landing angle vs wave slope + local result = self:evaluateLanding() + if result == "wipeout" then + self.pikaState = PIKA_STATE_CRASHED + self.crashTimer = 96 + self.speedFixed = SPEED_INITIAL + self.frameSet = 4 Sound.play(self.game.data, "Faint_Fall") + else + if result == "rough" then + self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY) + elseif result == "hard" then + self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) + end + self:calculateStuntPoints() + self.pikaState = PIKA_STATE_LANDING + self.routineTimer = 32 + self.frameSet = 4 + Sound.play(self.game.data, "Cut") + end + return + end + + self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4 + local intDelta = math.floor(self.pikaSubY / 256) + self.pikaSubY = self.pikaSubY % 256 + self.pikaY = self.pikaY + intDelta + self.jumpArcMagnitude = self.jumpArcMagnitude + 0.5 + + if self.pikaY >= waveY then + self.pikaY = waveY + self.pikaSubY = 0 + local result = self:evaluateLanding() + if result == "wipeout" then + self.pikaState = PIKA_STATE_CRASHED + self.crashTimer = 96 + self.speedFixed = SPEED_INITIAL + self.frameSet = 4 + Sound.play(self.game.data, "Faint_Fall") + else + if result == "rough" then + self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY) + elseif result == "hard" then + self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) + end + self:calculateStuntPoints() + self.pikaState = PIKA_STATE_LANDING + self.routineTimer = 32 + self.frameSet = 4 + Sound.play(self.game.data, "Cut") end end end end -function SurfingMinigame:sgbPalettes() - local P = require("src.render.PaletteFX") - local zones = { P.whole(SEA_PAL) } - if self.phase ~= "wipeout" and self.phase ~= "results" then - local tx = math.floor(PIKA_X / 8) - local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8) - zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3) +function SurfingMinigame:updateLanding() + self.routineTimer = self.routineTimer - 1 + -- Sine wave splash offset + self.pikaYOffset = math.floor(math.sin((32 - self.routineTimer) / 32 * math.pi * 2) * 4) + if self.routineTimer % 4 == 0 then + table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 }) end - return zones -end - -function SurfingMinigame:drawScore(x, y, n) - local s = tostring(n) - for i = 1, #s do - love.graphics.draw(self.ob, self.bq.digit[tonumber(s:sub(i, i))], - x + (i - 1) * 8, y) + if self.routineTimer <= 0 then + self.pikaYOffset = 0 + self.pikaState = PIKA_STATE_RIDING end end --- the BG map: metatile columns from the wave generator, scrolled by --- distance (SurfingMinigame_ScrollAndGenerateBGMap) +function SurfingMinigame:updateCrashed() + self.crashTimer = self.crashTimer - 1 + if self.crashTimer <= 0 then + self.pikaState = PIKA_STATE_RIDING + self.frameSet = 4 + end +end + +function SurfingMinigame:update() + local input = self.game.input + self.t = self.t + 1 + + -- Accumulate physical button presses on every frame (eliminates polling blind spot) + if input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end + if input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end + + -- Update trick popups + for i = #self.trickPopups, 1, -1 do + local p = self.trickPopups[i] + p.y = p.y - 0.5 + p.timer = p.timer - 1 + if p.timer <= 0 then table.remove(self.trickPopups, i) end + end + + -- Update water sprays + for i = #self.waterSprays, 1, -1 do + local s = self.waterSprays[i] + s.timer = s.timer - 1 + if s.timer <= 0 then table.remove(self.waterSprays, i) end + end + + -- Routine state machine + if self.routine == ROUTINE_START_GAME then + if self.startBannerX > 80 then + self.startBannerX = math.max(80, self.startBannerX - 4) + else + self.routine = ROUTINE_RUN_GAME + end + elseif self.routine == ROUTINE_RUN_GAME then + -- Deduct 1 HP per frame (stamina countdown from 6000 BCD) + if self.hp > 0 then + self.hp = self.hp - 1 + else + -- Game Over when HP hits 0 + self.routine = ROUTINE_GAME_OVER + self.routineTimer = 128 + self.speedFixed = 0 + self.ohNoBanner = true + Sound.play(self.game.data, "Faint_Fall") + return + end + + -- Scroll distance & generate wave columns (authentic 1:1 GB pace: distance += speedFixed) + self.distanceFixed = self.distanceFixed + self.speedFixed + self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor(self.speedFixed * 0.25) + self:generateAhead() + + -- Check if course goal reached (24 sections) + local distPx = math.floor(self.distanceFixed / 256) + if distPx >= (TOTAL_SECTIONS * 128) then + self.routine = ROUTINE_WAIT_RESULTS + self.routineTimer = 192 + self.waveFn = 0x72 + return + end + + -- Update Pikachu by state + if self.pikaState == PIKA_STATE_RIDING then + self:updateRiding() + elseif self.pikaState == PIKA_STATE_JUMPING then + self:updateJumping() + elseif self.pikaState == PIKA_STATE_LANDING then + self:updateLanding() + elseif self.pikaState == PIKA_STATE_CRASHED then + self:updateCrashed() + end + + elseif self.routine == ROUTINE_WAIT_RESULTS then + -- 192 frames coasting past the goal line + self.distanceFixed = self.distanceFixed + (2 * 256) + self:generateAhead() + self.pikaY = math.floor(self:seaY(80)) + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_SCROLL_RESULTS + self.routineTimer = 36 + end + + elseif self.routine == ROUTINE_SCROLL_RESULTS then + self.distanceFixed = self.distanceFixed + (1 * 256) + self:generateAhead() + self.pikaY = math.floor(self:seaY(80)) + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_DRAW_RESULTS + self.routineTimer = 64 + self.pikaState = PIKA_STATE_RESULTS + end + + elseif self.routine == ROUTINE_DRAW_RESULTS then + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_WRITE_HP_LEFT + self.routineTimer = 32 + end + + elseif self.routine == ROUTINE_WRITE_HP_LEFT then + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_WRITE_RADNESS + self.routineTimer = 32 + end + + elseif self.routine == ROUTINE_WRITE_RADNESS then + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_WRITE_TOTAL + self.routineTimer = 32 + end + + elseif self.routine == ROUTINE_WRITE_TOTAL then + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_ADD_HP_TOTAL + self.tallyStep = 0 + end + + elseif self.routine == ROUTINE_ADD_HP_TOTAL then + -- Tally remaining HP into total score (99 pts/frame matching ld c, 99) + if self.hp > 0 then + local step = math.min(self.hp, 99) + self.hp = self.hp - step + self.totalScore = self.totalScore + step + Sound.play(self.game.data, "Press_AB") + else + self.routine = ROUTINE_ADD_RAD_TOTAL + end + + elseif self.routine == ROUTINE_ADD_RAD_TOTAL then + -- Tally Radness into total score (99 pts/frame matching ld c, 99) + if self.radness > 0 then + local step = math.min(self.radness, 99) + self.radness = self.radness - step + self.totalScore = self.totalScore + step + Sound.play(self.game.data, "Press_AB") + else + self.routine = ROUTINE_WAIT_LAST + self.routineTimer = 64 + -- High score check + self.newRecord = self.totalScore > (self.game.save.surfingHighScore or 0) + if self.newRecord then + self.game.save.surfingHighScore = self.totalScore + Sound.play(self.game.data, "Get_Item1") + Sound.playPikaCry(self.game.data, 34) + else + Sound.playPikaCry(self.game.data, 28) + end + end + + elseif self.routine == ROUTINE_WAIT_LAST then + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 then + self.routine = ROUTINE_EXIT_ON_PRESS_A + end + + elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then + if input:wasPressed("a") or input:wasPressed("b") then + self.game.stack:pop() + if self.onDone then self.onDone(self.totalScore) end + end + + elseif self.routine == ROUTINE_GAME_OVER then + self.routineTimer = self.routineTimer - 1 + if self.routineTimer <= 0 and (input:wasPressed("a") or input:wasPressed("b")) then + self.game.stack:pop() + if self.onDone then self.onDone(0) end + end + end +end + +-- Draw scrolling wave background function SurfingMinigame:drawBackground() - local scx = math.floor(self.distance) + local scx = math.floor(self.distanceFixed / 256) local first = math.floor(scx / 16) for c = first, first + 10 do local col = self.cols[c] @@ -478,70 +882,198 @@ function SurfingMinigame:drawBackground() end end -function SurfingMinigame:draw() - local haveSheets = self.bg and self.ob - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, 0, 160, 144) - if not haveSheets then - -- cache predates the surf sheets: plain shapes keep it playable - love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("SCORE %d", self.score), 4, 4) - love.graphics.rectangle("fill", PIKA_X, self:seaY(PIKA_X) - self.y, 16, 16) - love.graphics.setColor(1, 1, 1, 1) - return - end - - self:drawBackground() - - -- cloud in the sky strip - love.graphics.draw(self.ob, self.bq.cloud, 112, 8) - - -- Pikachu rides at the height his tile column reports - local py = self:seaY(PIKA_X) - self.y - self.pikaScreenY = py -- the yellow SGB zone tracks this - if self.phase == "wipeout" then - love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 4, py) - else - local quad - if self.phase == "ride" and self.speed <= 2 - and self.distance < 120 then - quad = self.bq.paddle[math.floor(self.t / 8) % 2 + 1] - else - local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45 - quad = self.bq.poses[bucket] or self.bq.poses[0] +-- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy) +function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY) + for r = 0, 2 do + for c = 0, 2 do + local tileId = baseTile + r * 16 + c + local q = self.oq[tileId] + if q then + if not flipX and not flipY then + local dx = (cx - 12) + c * 8 + local dy = (cy - 12) + r * 8 + love.graphics.draw(self.ob, q, dx, dy) + else + local dx = (cx - 12) + (2 - c) * 8 + 8 + local dy = (cy - 12) + (2 - r) * 8 + 8 + love.graphics.draw(self.ob, q, dx, dy, 0, -1, -1) + end + end end - love.graphics.draw(self.ob, quad, PIKA_X, py) - end - - -- banner beats: GOOD! / YEAH- / Oh no.. - if self.banner and self.bq[self.banner.quad] then - love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40) - end - - -- the HP window sits under the BG rows ($7e into hWY puts it at y 126; - -- tile-aligned here): "HP:" plus the sheet digits over plain white - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 144 - BG_HEIGHT) - love.graphics.draw(self.bg, self.hpq, 8, BG_HEIGHT + 4) - self:drawScore(32, BG_HEIGHT + 4, self.score) - - if self.phase == "results" then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 20, 48, 120, 48) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("line", 20.5, 48.5, 119, 47) - Font.draw(Strings("SCORE %d", self.score), 32, 56) - if self.newRecord then - Font.draw(Strings("New record!"), 32, 68) - else - Font.draw(Strings("HI %d", self.game.save.surfingHighScore or 0), - 32, 68) - end - if self.resultShown > 30 then - Font.draw(Strings("A: done"), 32, 82) - end - love.graphics.setColor(1, 1, 1, 1) end end +-- Draw HUD status bar +function SurfingMinigame:drawHUD() + -- White background in bottom 16 rows + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 16) + + -- Track progress line (tiles $15..$1c) + if self.bg and self.tq then + -- Top row (Y=128) + love.graphics.draw(self.bg, self.tq[0x15], 8, BG_HEIGHT) + love.graphics.draw(self.bg, self.tq[0x16], 16, BG_HEIGHT) + + -- Bottom row (Y=136) + love.graphics.draw(self.bg, self.tq[0x17], 8, BG_HEIGHT + 8) + love.graphics.draw(self.bg, self.tq[0x18], 16, BG_HEIGHT + 8) + local trackTiles = { 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19 } + for i = 1, #trackTiles do + love.graphics.draw(self.bg, self.tq[trackTiles[i]], 16 + i * 8, BG_HEIGHT + 8) + end + love.graphics.draw(self.bg, self.tq[0x1b], 96, BG_HEIGHT + 8) + love.graphics.draw(self.bg, self.tq[0x1c], 104, BG_HEIGHT + 8) + + -- "HP:" label tiles on right (X=112, 120) + love.graphics.draw(self.bg, self.tq[0x20], 112, BG_HEIGHT + 8) + love.graphics.draw(self.bg, self.tq[0x21], 120, BG_HEIGHT + 8) + end + + -- Mini-Pikachu progress marker (tile $fe) + local distPx = math.floor(self.distanceFixed / 256) + local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128))) + local markerX = 16 + math.floor(progressRatio * 80) + if self.ob and self.oq and self.oq[0xfe] then + love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) + end + + -- 4 HP countdown digits starting at X=128 (right after HP:) + local s = string.format("%04d", math.max(0, math.floor(self.hp))) + for i = 1, 4 do + local d = tonumber(s:sub(i, i)) or 0 + if self.ob and self.oq and self.oq[0xd0 + d] then + love.graphics.draw(self.ob, self.oq[0xd0 + d], 120 + i * 8, BG_HEIGHT + 8) + else + Font.draw(tostring(d), 120 + i * 8, BG_HEIGHT + 8) + end + end +end + +-- Draw beach outro results scene +function SurfingMinigame:drawResultsOutro() + -- Draw beach outro tilemap in rows 6..15 + for r = 1, 10 do + for c = 1, 20 do + local tId = BEACH_OUTRO[r][c] + if tId and self.tq[tId] then + love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8) + end + end + end + + -- Textbox frame on rows 1..9 (X=8..152, Y=8..72) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 8, 8, 144, 64) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", 8.5, 8.5, 143, 63) + + -- Text lines + Font.draw(Strings("HP Left"), 16, 16) + if self.routine >= ROUTINE_WRITE_HP_LEFT then + Font.draw(string.format("%4d Pts", self.hp), 88, 16) + end + + if self.routine >= ROUTINE_WRITE_RADNESS then + Font.draw(Strings("Radness"), 16, 32) + Font.draw(string.format("%4d Pts", self.radness), 88, 32) + end + + if self.routine >= ROUTINE_WRITE_TOTAL then + Font.draw(Strings("Total"), 16, 48) + Font.draw(string.format("%4d Pts", self.totalScore), 88, 48) + end + + if self.routine >= ROUTINE_WAIT_LAST then + if self.newRecord then + Font.draw(Strings("Hi-Score!!"), 48, 60) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function SurfingMinigame:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + + if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then + self:drawResultsOutro() + return + end + + -- Draw scrolling BG waves + self:drawBackground() + + -- Parallax clouds in sky + local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256) + local c1x = (32 - cloudOffsetPx * 2) % 200 - 40 + local c2x = (128 - cloudOffsetPx * 3) % 200 - 40 + -- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef) + for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do + love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12) + end + -- Narrow cloud (4 tiles: $ec, $ed, $ee, $ef) + for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do + love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20) + end + + -- Draw water spray sprites + for _, s in ipairs(self.waterSprays) do + love.graphics.draw(self.ob, self.oq[0xa7], s.x, s.y) + end + + -- Draw Pikachu + local cx = 80 + local cy = self.pikaY + (self.pikaYOffset or 0) + self.pikaScreenY = cy + + if self.pikaState == PIKA_STATE_CRASHED then + -- Empty surfboard + splash animation + love.graphics.draw(self.ob, self.oq[0x98], cx - 12, cy + 4) + love.graphics.draw(self.ob, self.oq[0x99], cx - 4, cy + 4) + love.graphics.draw(self.ob, self.oq[0x9a], cx + 4, cy + 4) + love.graphics.draw(self.ob, self.oq[0xa8], cx - 12, cy - 8) + else + local angleIdx = ((self.frameSet - 1) % 7) + 1 + local isFlipped = self.frameSet > 7 + local toggle = math.floor(self.t / 8) % 2 + 1 + local base = ANGLE_BASES[angleIdx][toggle] + self:draw3x3(base, cx, cy, isFlipped, isFlipped) + end + + -- Draw trick popups + for _, p in ipairs(self.trickPopups) do + Font.draw(p.text, p.x, p.y) + end + + -- "START" banner + if self.routine == ROUTINE_START_GAME then + for r = 0, 1 do + for c = 0, 5 do + local tid = 0xe0 + r * 16 + c + love.graphics.draw(self.ob, self.oq[tid], self.startBannerX - 24 + c * 8, 64 + r * 8) + end + end + end + + -- "Oh no.." banner on Game Over + if self.ohNoBanner then + for r = 0, 1 do + for c = 0, 5 do + local tid = 0xca + r * 16 + c + love.graphics.draw(self.ob, self.oq[tid], 80 - 24 + c * 8, 64 + r * 8) + end + end + end + + -- Draw HUD (Progress track, HP digits) + self:drawHUD() +end + +function SurfingMinigame:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local pal = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL + return { P.whole(pal) } +end + return SurfingMinigame diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index b93fa8bc..0dbc0770 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -5303,16 +5303,25 @@ function OverworldState:drawWorld() if not ((self.flyAnim or self.flyArrive or self.playerHidden) and e == self.player) then e:draw(cam.x, cam.y) + -- tall grass overdraws the sprite's feet (GB sprite priority); + -- the overdraw is BG tiles, so it rides the shake offset too + love.graphics.setColor(1, 1, 1, 1) + if self.map:isGrassCell(e.cellX, e.cellY) then + self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) + if grassColors then + self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, + cam.x, bgY, grassColors) + end + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) + if grassColors then + self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, + cam.x, bgY, grassColors) + end + end end end - -- tall grass overdraws every visible grass cell's feet row after all - -- sprites (GB sprite-priority parity). One pass regardless of how many - -- entities are on screen -- see TileRenderer:drawGrassOverdraw. - love.graphics.setColor(1, 1, 1, 1) - self.map.renderer:drawGrassOverdraw(cam.x, bgY) - if grassColors then - self.map.renderer:markGrassOverdrawRedraw(cam.x, bgY, grassColors) - end fxHeal() fxDust() fxCutTree() @@ -5348,18 +5357,6 @@ function OverworldState:drawWorld() items[#items + 1] = { y = e.py + 16, kind = "entity", e = e } end end - -- Inject grass-cell overdraw items into the same depth-sorted queue so - -- they occlude entities at lower y correctly (back-to-front by cell foot). - -- Each cell's foot y = cy*16 + 16 in world pixels (bottom of its two rows). - local grassCells = self.map.renderer.grassCells - if grassCells then - for _, c in ipairs(grassCells) do - local cx, cy = c[1], c[2] - -- world-pixel foot of the grass cell's bottom tile row - local cellFootY = (cy * 2 + 2) * 8 -- == cy*16+16 - items[#items + 1] = { y = cellFootY, kind = "grass", cx = cx, cy = cy } - end - end table.sort(items, function(a, b) return a.y < b.y end) for _, it in ipairs(items) do @@ -5371,20 +5368,6 @@ function OverworldState:drawWorld() local fy = g.npc.py - cam.y + g.oy + 16 self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, function() g.npc:draw(cam.x - g.ox, cam.y - g.oy) end) - elseif it.kind == "grass" then - -- tall-grass bottom-row overdraw: billboarded at the cell's foot so - -- it depth-sorts correctly against any entity in the same y column. - -- bgY keeps the elevator-shake offset; drawCellBottomRaw lets the - -- billboard own the shader (color-0 keying baked into the keyed image - -- on GBC, or applied by drawCellBottom's shader on DMG/SGB). - local cx, cy = it.cx, it.cy - local fx = cx * 16 - cam.x + 8 -- horizontal centre of the cell - local fy = (cy * 2 + 2) * 8 - cam.y -- foot of the bottom tile row - local colors = zoneColorsAt(zones, fx, fy) - self:billboard(fx, fy, vw, vh, colors, true, function() - love.graphics.setColor(1, 1, 1, 1) - self.map.renderer:drawCellBottomRaw(cx, cy, cam.x, bgY) - end) else local e = it.e local fx = e.px - cam.x + 8 @@ -5392,6 +5375,22 @@ function OverworldState:drawWorld() local colors = zoneColorsAt(zones, fx, fy) self:billboard(fx, fy, vw, vh, colors, false, function() e:draw(cam.x, cam.y) end) + -- tall-grass feet overdraw glued to the sprite: same anchor + depth + -- so it keeps hiding the feet, color-0-keyed palette so its white + -- gaps still show the sprite through (drawCellBottomRaw lets the + -- billboard own the shader; bgY keeps the elevator-shake offset). + if self.map:isGrassCell(e.cellX, e.cellY) then + self:billboard(fx, fy, vw, vh, colors, true, function() + love.graphics.setColor(1, 1, 1, 1) + self.map.renderer:drawCellBottomRaw(e.cellX, e.cellY, cam.x, bgY) + end) + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + self:billboard(fx, fy, vw, vh, colors, true, function() + love.graphics.setColor(1, 1, 1, 1) + self.map.renderer:drawCellBottomRaw(e.targetX, e.targetY, cam.x, bgY) + end) + end end end diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 4950bffe..198e86f3 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -313,9 +313,8 @@ local TROPHY_BOXES = { } -- Script_FishCastRod ends on `pause 40`, and Script_GotABite pauses another 40 --- over the bobbing rod before the text lands. ShakeHeadbuttTree counts down --- wFrameCounter from 32. All three are frames at 60 Hz, which is the same --- clock World:step runs on. +-- over the bobbing rod before the text lands. +-- All are frames at 60 Hz, which is the same clock World:step runs on. local FISH_CAST_FRAMES = 40 local FISH_BITE_FRAMES = 40 local HEADBUTT_SHAKE_FRAMES = 32 @@ -346,10 +345,12 @@ local function sameEncounter(enc) return enc end -- (engine/events/fish.asm Fish) byte for byte. local FISH_ROD_KEY = { OLD_ROD = "old", GOOD_ROD = "good", SUPER_ROD = "super" } -local function fishVanilla(rod, _mapId, candidates) +local function fishVanilla(rod, _mapId, candidates, ctx) if not candidates then return nil end - return Encounter.fish({ fishGroups = { hooked = candidates } }, "hooked", - FISH_ROD_KEY[rod] or rod or "old", nil) + local tod = ctx and (ctx.tod or ctx.daytime) + return Encounter.fish({ fishGroups = { hooked = candidates }, + timeFishGroups = ctx and ctx.encounters and ctx.encounters.timeFishGroups }, + "hooked", FISH_ROD_KEY[rod] or rod or "old", tod, nil) end local function speciesByIndex(pokemon, index) @@ -4291,6 +4292,7 @@ function World:rollFishing(rod) return "nibble" end local roll + local tod = self.tod or "DAY" if Runtime.wantsHook("encounter.fishing") then -- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the -- candidate list the chain may inspect or replace before the roll. Gold's @@ -4304,10 +4306,10 @@ function World:rollFishing(rod) roll = Runtime.call("encounter.fishing", fishVanilla, rod, map.id, groups and groups[group], { fishGroup = group, swarm = swarm, encounters = self.encounters, - maps = self.maps, data = game.data }) + maps = self.maps, data = game.data, tod = tod, daytime = tod }) else roll = Encounter.fishSlot(self.encounters, map.id, rod, nil, self.maps, - swarm) + swarm, tod) end if not roll or not roll.species then return "nibble" end local wild = Mon.new(game.data, roll.species, roll.level) @@ -4770,19 +4772,36 @@ function World:runQueuedScript() end -- Script_FishCastRod, then Script_NotEvenANibble or Script_GotABite. Held as --- a frame counter rather than a movement byte stream because the three --- commands involved -- fish_cast_rod ($52), fish_got_bite ($51) and show_emote --- ($54) -- are object ACTION changes, not steps, and Movement.decodeByte has --- nothing to say about them. +-- an exact frame counter matching 60 Hz engine ticks. function World:beginFishing(outcome, wild) - self.fishing = { - phase = "cast", timer = FISH_CAST_FRAMES, outcome = outcome, wild = wild, + local p = self.player + local d = Map.DELTA[p and p.facing or "down"] or Map.DELTA.down + local targetCellX = p and (p.cellX + d[1]) or 0 + local targetCellY = p and (p.cellY + d[2]) or 0 + local bobber = { + cellX = targetCellX, + cellY = targetCellY, + px = targetCellX * 16, + py = targetCellY * 16, } + self.fishing = { + phase = "cast", + timer = FISH_CAST_FRAMES, + outcome = outcome, + wild = wild, + bobber = bobber, + facing = p and p.facing or "down", + } + if self.player then + self.player.fishing = true + self.player.fishingState = self.fishing + end end function World:updateFishing() local st = self.fishing if not st then return end + if self.player then self.player.fishingState = st end -- A text box owns the frame while it is up; the script only moves on when -- its own callback fires. if self.textbox or self.choicebox then return end @@ -4791,7 +4810,7 @@ function World:updateFishing() -- StepFunction_GotBite (engine/overworld/map_objects.asm:1430) is one byte -- of animation: OBJECT_SPRITE_Y_OFFSET flipped between 0 and 1 once a -- frame for the length of the bite, which is the rod jerking in the - -- player's hands. The cast holds still, so only the bite bobs. + -- player's hands. if self.player then self.player.spriteYOffset = (st.phase == "bite" and st.timer % 2 == 1) and 1 or 0 @@ -4801,34 +4820,33 @@ function World:updateFishing() if self.player then self.player.spriteYOffset = 0 end if st.phase == "cast" then if st.outcome == "battle" then - -- Script_GotABite: four fish_got_bite bobs with the EMOTE_SHOCK bubble - -- over the player, then `pause 40` before the rod comes back. st.phase = "bite" st.timer = FISH_BITE_FRAMES self:showEmote(EMOTE_SHOCK, 0, FISH_BITE_FRAMES) return end - -- Script_NotEvenANibble (queued by $1 .FishNoBite) and - -- Script_NotEvenANibble2 (by $4 .FishNoFish) differ only in the - -- wFishingResult they record; both write RodNothingText and fall through - -- to the same PutTheRodAway. st.phase = "done" - self:showText(Strings(TEXT_ROD_NOTHING), function() self.fishing = nil end) + self:showText(Strings(TEXT_ROD_NOTHING), function() + self.fishing = nil + if self.player then + self.player.fishing = nil + self.player.fishingState = nil + end + end) return end if st.phase == "bite" then st.phase = "done" self:showText(Strings(TEXT_ROD_BITE), function() local wild = st.wild - -- PutTheRodAway and closetext come before startbattle, and the state has - -- to be gone before the battle is pushed or World:busy would still be - -- holding the world when it returns. self.fishing = nil - -- FishFunction's `.goodtofish` writes BATTLETYPE_FISH into wBattleType - -- alongside the species and level it hooked (engine/events/overworld.asm), - -- which is the one condition LureBallMultiplier reads for its x3. + if self.player then + self.player.fishing = nil + self.player.fishingState = nil + end if wild then self:startBattle({ wild = wild, battleType = "fish" }) end end) + return end end @@ -7819,7 +7837,10 @@ function World:drawGrassOver(entity, ox, oy, s) local tilePalettes = tileset.tilePalettes local tilesPerRow = tileset.tilesPerRow or 16 local aw, ah = atlas:getDimensions() - local rx, ry = entity.px, entity.py + 4 + -- Only draw the bottom 8px tile row of the cell (ty = py + 8) over the feet, + -- matching Gen 1's drawCellBottomRaw. Starting at py + 4 sampled the top + -- tile row and drew grass tufts over the face and torso. + local rx, ry = entity.px, entity.py + 8 self.grassQuad = self.grassQuad or G.newQuad(0, 0, 8, 8, aw, ah) local quad = self.grassQuad G.setColor(1, 1, 1, 1) @@ -9794,13 +9815,14 @@ function World:drawPeople(s, billboard) else entry.npc:draw(ox, oy, s) end - -- ShakeGrass rustle only. The cart also ORs OAM_PRIO onto the lower - -- 16x8 (drawGrassOver / IN_GRASS) so the BG tuft covers the feet, but - -- stacking that plain grass tile on top of the character with the - -- rustle reads as a double overlay here -- keep the walk-through anim. + -- ShakeGrass rustle only while moving; drawGrassOver when standing/in grass + -- so the BG tuft covers the feet. -- Only the current map's own entities: a ghost's cells belong to a -- neighbour's block list. if entry.ox == 0 and entry.oy == 0 then + if entity.inGrass and not (entity.grassShake and entity.moving) then + self:drawGrassOver(entity, ox, oy, s) + end self:drawGrassShake(entity, ox, oy, s) end end diff --git a/tests/gen2_fishing_time_test.lua b/tests/gen2_fishing_time_test.lua new file mode 100644 index 00000000..9b5ff539 --- /dev/null +++ b/tests/gen2_fishing_time_test.lua @@ -0,0 +1,286 @@ +-- Gen 2 time-dependent fishing encounters (TimeFishGroups). +-- +-- luajit tests/gen2_fishing_time_test.lua +-- +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 fishing time") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") +local Encounter = require("src.battle.gen2.Encounter") +local Permissions = require("src.world.gen2.Permissions") + +local COLL_FLOOR, COLL_WATER = 0x00, 0x29 +local MAP_W, MAP_H = 10, 10 + +local function mon(name, level) + return { species = name, id = name, name = name, baseStats = { hp = 50 }, level = level or 10 } +end + +local function fakeData() + return { + pokemon = { + MAGIKARP = mon("MAGIKARP"), + KRABBY = mon("KRABBY"), + KINGLER = mon("KINGLER"), + CORSOLA = mon("CORSOLA"), + STARYU = mon("STARYU"), + SHELLDER = mon("SHELLDER"), + CHINCHOU = mon("CHINCHOU"), + LANTURN = mon("LANTURN"), + TENTACRUEL = mon("TENTACRUEL"), + }, + items = { + OLD_ROD = { keyItem = true }, + GOOD_ROD = { keyItem = true }, + SUPER_ROD = { keyItem = true }, + }, + } +end + +local function fishWorld(mapId, fishGroup, daytime) + local map = { + id = mapId, + def = { id = mapId, width = MAP_W, height = MAP_H, + environment = "ROUTE", fishGroup = fishGroup }, + cellCollision = function(self, cx, cy) + return (cx == 5 and cy == 4) and COLL_WATER or COLL_FLOOR + end, + } + local game = { + save = { + timeOfDay = daytime or "DAY", + dailyFlags = {}, + player = { cellX = 5, cellY = 5, facing = "up" }, + }, + data = fakeData(), + } + local world = World.new(game) + world.map = map + world.maps = { [mapId] = map.def } + world.player = game.save.player + world.playerState = 0 + world.tod = daytime or "DAY" + world.daytime = daytime or "DAY" + return world, game +end + +-- Test Shore group with TimeFishGroups (Corsola / Staryu) +local SHORE_ENCOUNTERS = { + fishGroups = { + FISHGROUP_SHORE = { + id = "FISHGROUP_SHORE", + chance = 255, -- always bite + old = { + { chance = 179, species = "MAGIKARP", level = 10 }, + { chance = 217, species = "MAGIKARP", level = 10 }, + { chance = 255, species = "KRABBY", level = 10 }, + }, + good = { + { chance = 89, species = "MAGIKARP", level = 20 }, + { chance = 178, species = "KRABBY", level = 20 }, + { chance = 230, species = "KRABBY", level = 20 }, + { chance = 255, species = "CORSOLA", level = 20, timeGroup = 0, + day = { species = "CORSOLA", level = 20 }, + nite = { species = "STARYU", level = 20 } }, + }, + super = { + { chance = 102, species = "KRABBY", level = 40 }, + { chance = 178, species = "CORSOLA", level = 40, timeGroup = 1, + day = { species = "CORSOLA", level = 40 }, + nite = { species = "STARYU", level = 40 } }, + { chance = 230, species = "KRABBY", level = 40 }, + { chance = 255, species = "KINGLER", level = 40 }, + }, + }, + }, +} + +-- Test Ocean group with TimeFishGroups (Shellder) +local OCEAN_ENCOUNTERS = { + fishGroups = { + FISHGROUP_OCEAN = { + id = "FISHGROUP_OCEAN", + chance = 255, + old = { + { chance = 179, species = "MAGIKARP", level = 10 }, + { chance = 217, species = "MAGIKARP", level = 10 }, + { chance = 255, species = "TENTACOOL", level = 10 }, + }, + good = { + { chance = 89, species = "MAGIKARP", level = 20 }, + { chance = 178, species = "TENTACOOL", level = 20 }, + { chance = 230, species = "CHINCHOU", level = 20 }, + { chance = 255, species = "SHELLDER", level = 20, timeGroup = 2, + day = { species = "SHELLDER", level = 20 }, + nite = { species = "SHELLDER", level = 20 } }, + }, + super = { + { chance = 102, species = "CHINCHOU", level = 40 }, + { chance = 178, species = "SHELLDER", level = 40, timeGroup = 3, + day = { species = "SHELLDER", level = 40 }, + nite = { species = "SHELLDER", level = 40 } }, + { chance = 230, species = "TENTACRUEL", level = 40 }, + { chance = 255, species = "LANTURN", level = 40 }, + }, + }, + }, +} + +-- ---- Direct Encounter.fishSlot tests --------------------------------------- +do + -- Shore Good Rod at value 240 (slot 4) during DAY -> Corsola lv20 + local rollDay = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "GOOD_ROD", + function(_n) return 240 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "DAY") + check(rollDay ~= nil, "Shore Good Rod bites during DAY") + eq(rollDay.species, "CORSOLA", "Shore Good Rod slot 4 is CORSOLA during DAY") + eq(rollDay.level, 20, "Shore Good Rod slot 4 level is 20") + + -- Shore Good Rod at value 240 (slot 4) during NITE -> Staryu lv20 + local rollNite = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "GOOD_ROD", + function(_n) return 240 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "NITE") + check(rollNite ~= nil, "Shore Good Rod bites during NITE") + eq(rollNite.species, "STARYU", "Shore Good Rod slot 4 is STARYU during NITE") + eq(rollNite.level, 20, "Shore Good Rod slot 4 level is 20") + + -- Shore Super Rod at value 150 (slot 2) during DAY -> Corsola lv40 + local rollSuperDay = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "SUPER_ROD", + function(_n) return 150 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "DAY") + check(rollSuperDay ~= nil, "Shore Super Rod bites during DAY") + eq(rollSuperDay.species, "CORSOLA", "Shore Super Rod slot 2 is CORSOLA during DAY") + eq(rollSuperDay.level, 40, "Shore Super Rod slot 2 level is 40") + + -- Shore Super Rod at value 150 (slot 2) during NITE -> Staryu lv40 + local rollSuperNite = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "SUPER_ROD", + function(_n) return 150 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "NITE") + check(rollSuperNite ~= nil, "Shore Super Rod bites during NITE") + eq(rollSuperNite.species, "STARYU", "Shore Super Rod slot 2 is STARYU during NITE") + eq(rollSuperNite.level, 40, "Shore Super Rod slot 2 level is 40") + + -- Ocean Super Rod at value 150 (slot 2) during DAY/NITE -> Shellder lv40 + local rollOceanSuper = Encounter.fishSlot(OCEAN_ENCOUNTERS, "ROUTE_41", "SUPER_ROD", + function(_n) return 150 end, { ROUTE_41 = { fishGroup = "FISHGROUP_OCEAN" } }, nil, "DAY") + check(rollOceanSuper ~= nil, "Ocean Super Rod bites") + eq(rollOceanSuper.species, "SHELLDER", "Ocean Super Rod slot 2 is SHELLDER") + eq(rollOceanSuper.level, 40, "Ocean Super Rod slot 2 level is 40") +end + +-- ---- World:rollFishing integration tests ----------------------------------- +do + -- World with Shore group in DAY time + local worldDay, _ = fishWorld("ROUTE_34", "FISHGROUP_SHORE", "DAY") + worldDay.encounters = SHORE_ENCOUNTERS + + -- Mock math.random / love.math.random to land on slot 2 of super rod (value ~150) + love.math.random = function(n) return 151 end + local outcome, wild = worldDay:rollFishing("SUPER_ROD") + eq(outcome, "battle", "Super Rod triggers battle on Corsola slot") + check(wild ~= nil, "Wild mon is created") + eq(wild.species, "CORSOLA", "Wild mon is CORSOLA during DAY") + eq(wild.level, 40, "Wild mon level is 40") + + -- World with Shore group in NITE time + local worldNite, _ = fishWorld("ROUTE_34", "FISHGROUP_SHORE", "NITE") + worldNite.encounters = SHORE_ENCOUNTERS + local outcomeN, wildN = worldNite:rollFishing("SUPER_ROD") + eq(outcomeN, "battle", "Super Rod triggers battle on Staryu slot") + check(wildN ~= nil, "Wild mon is created") + eq(wildN.species, "STARYU", "Wild mon is STARYU during NITE") + eq(wildN.level, 40, "Wild mon level is 40") + + -- World with Ocean group -> Shellder + local worldOcean, _ = fishWorld("ROUTE_41", "FISHGROUP_OCEAN", "DAY") + worldOcean.encounters = OCEAN_ENCOUNTERS + local outcomeO, wildO = worldOcean:rollFishing("SUPER_ROD") + eq(outcomeO, "battle", "Super Rod triggers battle on Shellder slot") + check(wildO ~= nil, "Wild mon is created") + eq(wildO.species, "SHELLDER", "Wild mon is SHELLDER") + eq(wildO.level, 40, "Wild mon level is 40") +end + +-- ---- Real Gold cache integration tests (if present) ------------------------ +do + local cache = os.getenv("GOLD_CACHE") + if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/.local/share/love/pokemon-love2d/gold" + end + local encChunk = loadfile(cache .. "/data/generated/encounters.lua") + if not encChunk then + check(true, "no gold cache: time fish groups (SKIP)") + else + local enc = encChunk() or {} + local groups = enc.fishGroups or {} + local shore = groups.FISHGROUP_SHORE + check(shore ~= nil, "cache carries FISHGROUP_SHORE") + if shore and shore.super then + local slot2 = shore.super[2] + check(slot2 ~= nil, "shore super rod has slot 2") + if slot2 then + eq(slot2.timeGroup, 1, "slot 2 timeGroup is 1") + check(slot2.day ~= nil, "slot 2 has day entry") + check(slot2.nite ~= nil, "slot 2 has nite entry") + if slot2.day then eq(slot2.day.species, "CORSOLA", "day is CORSOLA") end + if slot2.nite then eq(slot2.nite.species, "STARYU", "nite is STARYU") end + end + end + + local ocean = groups.FISHGROUP_OCEAN + check(ocean ~= nil, "cache carries FISHGROUP_OCEAN") + if ocean and ocean.super then + local slot2 = ocean.super[2] + check(slot2 ~= nil, "ocean super rod has slot 2") + if slot2 then + eq(slot2.timeGroup, 3, "slot 2 timeGroup is 3") + check(slot2.day ~= nil, "slot 2 has day entry") + if slot2.day then eq(slot2.day.species, "SHELLDER", "day is SHELLDER") end + end + end + end +end +-- ---- Animation & Bobber State Machine tests -------------------------------- +do + local world, _ = fishWorld("ROUTE_34", "FISHGROUP_SHORE", "DAY") + world.encounters = SHORE_ENCOUNTERS + world.player.cellX = 5 + world.player.cellY = 5 + world.player.facing = "left" + + local wildMon = { species = "CORSOLA", level = 40 } + world:beginFishing("battle", wildMon) + + check(world.fishing ~= nil, "Fishing state is initialized") + eq(world.fishing.phase, "cast", "Initial phase is cast") + eq(world.fishing.timer, 40, "Cast timer is 40 frames") + check(world.fishing.bobber ~= nil, "Bobber is created") + eq(world.fishing.bobber.cellX, 4, "Bobber target X is 4 (one cell left)") + eq(world.fishing.bobber.cellY, 5, "Bobber target Y is 5") + eq(world.fishing.bobber.px, 64, "Bobber px is 64") + eq(world.fishing.bobber.py, 80, "Bobber py is 80") + eq(world.player.fishing, true, "Player has fishing flag") + + -- Step through cast phase (40 frames + transition tick) + for f = 1, 40 + 1 do + world:updateFishing() + end + eq(world.fishing.phase, "bite", "Transitions to bite phase on battle outcome") + eq(world.fishing.timer, 40, "Bite timer is 40 frames") + + -- Step through bite phase and check 1px alternating offset + local seenOdd, seenEven = false, false + for f = 1, 40 do + world:updateFishing() + if world.player.spriteYOffset == 1 then seenOdd = true end + if world.player.spriteYOffset == 0 then seenEven = true end + end + check(seenOdd and seenEven, "Player sprite Y offset alternates during bite phase") + -- Transition on next tick triggers text/battle and clears fishing state + world:updateFishing() + eq(world.fishing, nil, "Fishing state is cleared after bite completion") + eq(world.player.fishing, nil, "Player fishing flag is cleared after bite completion") +end + +S.finish() diff --git a/tests/test_surfing_minigame.lua b/tests/test_surfing_minigame.lua new file mode 100644 index 00000000..8607a92f --- /dev/null +++ b/tests/test_surfing_minigame.lua @@ -0,0 +1,150 @@ +if not _G.love then + _G.love = { + audio = { + newSource = function() return { stop = function() end, play = function() end, setVolume = function() end } end + } + } +end +local SurfingMinigame = require("src.ui.SurfingMinigame") + +local function assert_eq(got, want, msg) + if got ~= want then + error(string.format("%s: got %s, want %s", msg or "assertion failed", tostring(got), tostring(want))) + end +end + +local function assert_true(cond, msg) + if not cond then + error(string.format("%s: expected true", msg or "assertion failed")) + end +end + +-- Mock game environment +local mockInput = { + keysDown = {}, + keysPressed = {}, + isDown = function(self, k) return not not self.keysDown[k] end, + wasPressed = function(self, k) return not not self.keysPressed[k] end, +} + +local mockGame = { + save = { surfingHighScore = 1000 }, + input = mockInput, + stack = { + items = {}, + pop = function(self) table.remove(self.items) end, + push = function(self, item) table.insert(self.items, item) end, + }, + data = { audio = { sfx = {} } }, +} + +print("Running SurfingMinigame unit tests...") + +-- Test 1: Initialization +local mg = SurfingMinigame.new(mockGame) +assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)") +assert_eq(mg.speed, 0.25, "Initial speed must be 0.25") +assert_eq(mg.distance, 0, "Initial distance must be 0") +assert_eq(mg.routine, 0, "Initial routine must be ROUTINE_START_GAME (0)") +assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)") +print("✓ Initial state test passed") + +-- Test 2: Start banner transition to RunGame +for _ = 1, 40 do + mg:update() +end +assert_eq(mg.routine, 1, "Routine should advance to ROUTINE_RUN_GAME (1)") +print("✓ Start banner transition test passed") + +-- Test 3: Automatic acceleration and HP countdown +local initialSpeed = mg.speed +local initialHp = mg.hp +mg:update() +assert_true(mg.speed > initialSpeed, "Pikachu should automatically accelerate while riding") +assert_eq(mg.hp, initialHp - 1, "HP should decrease by 1 each frame") +print("✓ Auto acceleration and HP countdown test passed") + +-- Test 4: Landing Evaluation Matrix +mg.frameSet = 5 +assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing") +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "hard", "Angle 6 on open water should be hard landing") +mg.frameSet = 4 +assert_eq(mg:evaluateLanding(), "clean", "Angle 4 (flat) on open water should be clean landing") +mg.frameSet = 1 +assert_eq(mg:evaluateLanding(), "wipeout", "Angle 1 on open water should be wipeout") +for f = 8, 14 do + mg.frameSet = f + assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout") +end +print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)") + +-- Test 5: Stunt Scoring +mg.radnessMeter = 1 +mg.trickFlags = 1 +mg.radness = 0 +mg:calculateStuntPoints() +assert_eq(mg.radness, 50, "Single flip should award +50 radness points") + +mg.radnessMeter = 2 +mg.trickFlags = 1 +mg.radness = 0 +mg:calculateStuntPoints() +assert_eq(mg.radness, 150, "Double flip (same direction) should award +150 points") + +mg.radnessMeter = 3 +mg.trickFlags = 1 +mg.radness = 0 +mg:calculateStuntPoints() +assert_eq(mg.radness, 350, "Triple flip (same direction) should award +350 points") + +mg.radnessMeter = 2 +mg.trickFlags = 3 +mg.radness = 0 +mg:calculateStuntPoints() +assert_eq(mg.radness, 180, "Double flip (mixed) should award +180 points") + +mg.radnessMeter = 3 +mg.trickFlags = 3 +mg.radness = 0 +mg:calculateStuntPoints() +assert_eq(mg.radness, 500, "Triple flip (mixed) should award +500 points") +print("✓ Stunt scoring calculation test passed") + +-- Test 6: Non-fatal wipeout crash recovery +mg.pikaState = 3 -- PIKA_STATE_CRASHED +mg.crashTimer = 96 +mg.speed = 0.25 +for _ = 1, 95 do + mg:update() + assert_eq(mg.pikaState, 3, "Pikachu should remain in crashed state during timer") +end +mg:update() +assert_eq(mg.pikaState, 0, "Pikachu should recover and return to PIKA_STATE_RIDING after 96 frames") +print("✓ Wipeout crash recovery test passed") + +-- Test 7: Results tally countdown sequence +mg.routine = 7 -- ROUTINE_WRITE_TOTAL +mg.hp = 100 +mg.radness = 200 +mg.totalScore = 0 +mg.routineTimer = 1 +mg:update() +assert_eq(mg.routine, 8, "Routine should advance to ROUTINE_ADD_HP_TOTAL (8)") + +while mg.routine == 8 do + mg:update() +end +assert_eq(mg.hp, 0, "HP should be tallied down to 0") +assert_eq(mg.totalScore, 100, "Total score should include 100 from HP") +assert_eq(mg.routine, 9, "Routine should advance to ROUTINE_ADD_RAD_TOTAL (9)") + +while mg.routine == 9 do + mg:update() +end +assert_eq(mg.radness, 0, "Radness should be tallied down to 0") +assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)") +assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)") +print("✓ Results tally countdown test passed") + +print("All SurfingMinigame unit tests passed successfully!") diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 12a3db48..9105a695 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -668,7 +668,7 @@ REQUIRED_SYMBOLS = { # Wild encounters (data/wild/*) "JohtoGrassWildMons", "JohtoWaterWildMons", "KantoGrassWildMons", "KantoWaterWildMons", - "FishGroups", "TreeMons", "TreeMonMaps", + "FishGroups", "TimeFishGroups", "TreeMons", "TreeMonMaps", # data/wild/treemon_maps.asm RockMonMaps: the four maps whose smashable # rocks roll TREEMON_SET_ROCK, read by RockMonEncounter. "RockMonMaps", @@ -785,6 +785,7 @@ REQUIRED_SYMBOLS = { # Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer # who just spotted the player, and the other faces scripts use. "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", + "HeartEmote", "BoltEmote", "SleepEmote", "FishEmote", # The Pokecenter heal machine's OBJ art (engine/events/ # heal_machine_anim.asm): two tiles -- the machine's light ($7c) and the # ball ($7d) -- plus the CGB palette .LoadPalettes copies over diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 2833d110..2a54f118 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -16096,6 +16096,22 @@ 5, 17417 ], + "HeartEmote": [ + 5, + 17673 + ], + "BoltEmote": [ + 5, + 17737 + ], + "SleepEmote": [ + 5, + 17801 + ], + "FishEmote": [ + 5, + 17865 + ], "Shrink1Pic": [ 62, 30142 @@ -16512,6 +16528,10 @@ 5, 22206 ], + "TimeFishGroups": [ + 36, + 27614 + ], "TitleScreenGFX1": [ 38, 16384