mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
236 lines
8.6 KiB
Lua
236 lines
8.6 KiB
Lua
-- Talking to overworld objects: the Route 30 roadblock's Rattata turn to face
|
|
-- the player (ObjectEvent is `jumptextfaceplayer`, home/map.asm, and
|
|
-- SPRITE_MONSTER is a WALKING_SPRITE so ApplyObjectFacing really turns it),
|
|
-- and the talked-to / engaged object holds still for the conversation the way
|
|
-- FreezeAllOtherObjects + EndScript's UnfreezeAllObjects bracket it on the
|
|
-- cart -- a SPINRANDOM trainer must not roll a new facing under his own
|
|
-- sighting text.
|
|
--
|
|
-- luajit tests/gen2_npc_interact_test.lua (ROM-free)
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 npc interact")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = require("tests.love_stub")
|
|
|
|
local World = require("src.world.gen2.World")
|
|
local NPC = require("src.world.gen2.Npc")
|
|
local Vm = require("src.script.gen2.Vm")
|
|
local Permissions = require("src.world.gen2.Permissions")
|
|
|
|
local COLL_FLOOR = 0x00
|
|
|
|
-- The gen2_world_test rig, trimmed: a flat map, a fake player, and NPCs built
|
|
-- as NPC-shaped tables (a real NPC.new needs a sprite sheet and a graphics
|
|
-- device) so facePlayer / update / scriptFace are the shipped methods.
|
|
local MAP_W, MAP_H = 10, 10
|
|
|
|
local function fakeMap()
|
|
local map
|
|
map = {
|
|
id = "TEST_MAP",
|
|
width = MAP_W, height = MAP_H,
|
|
def = { bgEvents = {}, objects = {}, width = MAP_W, height = MAP_H },
|
|
cellCollision = function() return COLL_FLOOR end,
|
|
inBounds = function(_, x, y)
|
|
return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2
|
|
end,
|
|
isWalkable = function() return true end,
|
|
warpAt = function() return nil end,
|
|
}
|
|
return map
|
|
end
|
|
|
|
local function fakePlayer(x, y, facing)
|
|
return {
|
|
cellX = x, cellY = y, px = x * 16, py = y * 16,
|
|
facing = facing, moving = false, turnArmed = true,
|
|
update = function() return false end,
|
|
setSprite = function() end,
|
|
}
|
|
end
|
|
|
|
local function npcAt(def, x, y, facing, kind)
|
|
return setmetatable({
|
|
def = def, id = "npc_" .. tostring(def.index),
|
|
cellX = x, cellY = y, homeX = x, homeY = y,
|
|
px = x * 16, py = y * 16,
|
|
facing = facing, moving = false, progress = 0, stepFlip = false,
|
|
frozen = false, kind = kind or "stand",
|
|
roamDirs = { "up", "down", "left", "right" },
|
|
radiusX = 0, radiusY = 0, spinLo = 1, spinHi = 1, timer = 1,
|
|
}, NPC)
|
|
end
|
|
|
|
local function talkWorld(opts)
|
|
opts = opts or {}
|
|
local game = {
|
|
data = {},
|
|
save = { player = { name = "GOLD" }, party = opts.party or {},
|
|
inventory = {} },
|
|
}
|
|
local world = World.new(game)
|
|
game.world = world
|
|
world.map = fakeMap()
|
|
world.maps = { TEST_MAP = world.map.def }
|
|
world.player = fakePlayer(opts.px or 4, opts.py or 5, opts.facing or "right")
|
|
world.pollTimeOfDay = function() end
|
|
local log = {}
|
|
world.log = log
|
|
world.showText = function(self, body, onDone)
|
|
log[#log + 1] = body
|
|
self.textbox = true
|
|
self.pendingText = function()
|
|
self.textbox = nil
|
|
if onDone then onDone() end
|
|
end
|
|
end
|
|
world.vm = Vm.new(opts.scripts or {}, opts.texts or {}, world.events, {
|
|
showText = function(body, onDone) world:showText(body, onDone) end,
|
|
-- The shipped hook body from World:load, restated over this rig: the
|
|
-- talked-to NPC turns through the real NPC:facePlayer.
|
|
facePlayer = function()
|
|
if world.talkNpc and world.player then
|
|
world.talkNpc:facePlayer(world.player)
|
|
end
|
|
end,
|
|
showEmote = function() end,
|
|
encounterMusic = function() end,
|
|
trainerApproach = function(onDone) world:trainerApproach(onDone) end,
|
|
lookupTrainer = function()
|
|
return { name = "DON", class = 36, member = 1 }
|
|
end,
|
|
startBattle = function(_, _, onDone) onDone("win") end,
|
|
reloadMap = function() end,
|
|
})
|
|
return world
|
|
end
|
|
|
|
local function advanceText(world)
|
|
local fn = world.pendingText
|
|
world.pendingText = nil
|
|
if fn then fn() end
|
|
end
|
|
|
|
-- ---- the roadblock monsters turn to the player -----------------------------
|
|
-- maps/Route30.asm: both SPRITE_MONSTER rows point at ObjectEvent, one
|
|
-- STANDING_DOWN and one STANDING_UP -- they face each other until spoken to.
|
|
do
|
|
local OBJECT_EVENT = "00:2812"
|
|
local monster = { index = 7, sprite = "SPRITE_MONSTER", movement = 7,
|
|
scriptKey = OBJECT_EVENT }
|
|
local world = talkWorld({
|
|
scripts = { [OBJECT_EVENT] = {
|
|
{ op = "jumptextfaceplayer", text = "00:2815" },
|
|
} },
|
|
texts = { ["00:2815"] = "Object event." },
|
|
})
|
|
eq(NPC.new and select(1, "x"), "x", "NPC module loaded")
|
|
local rattata = npcAt(monster, 5, 5, "up")
|
|
world.npcs = { rattata }
|
|
world.entities = { world.player, rattata }
|
|
check(world:interact(), "A from the west starts the ObjectEvent script")
|
|
eq(rattata.facing, "left", "the spoken-to Rattata turns to face the player")
|
|
eq(world.log[1], "Object event.", "and its line prints")
|
|
check(rattata.frozen, "it holds still while its text is up")
|
|
advanceText(world)
|
|
for _ = 1, 3 do world:step() end
|
|
check(not rattata.frozen, "and is released when the interaction ends")
|
|
end
|
|
|
|
-- ---- a spinning trainer cannot spin under his own sighting text ------------
|
|
local BEAT_FLAG = 1000
|
|
local record = { event = BEAT_FLAG, class = 36, member = 1,
|
|
seenText = "t:seen", winText = "t:win", scriptKey = "s:after" }
|
|
|
|
local function trainerWorld(px, py, facing, npcFacing)
|
|
local world = talkWorld({
|
|
px = px, py = py, facing = facing,
|
|
party = { { species = "RATTATA", hp = 10 } },
|
|
scripts = { ["s:after"] = { { op = "end" } } },
|
|
texts = {
|
|
["t:seen"] = "Instead of a bug\nPOKéMON, I found\va trainer!",
|
|
["t:win"] = "Deary me.",
|
|
},
|
|
})
|
|
local don = { index = 4, sprite = "SPRITE_BUG_CATCHER", movement = 10,
|
|
trainer = record, sight = 3 }
|
|
local npc = npcAt(don, 4, 7, npcFacing, "spin")
|
|
world.npcs = { npc }
|
|
world.entities = { world.player, npc }
|
|
return world, npc
|
|
end
|
|
|
|
do
|
|
-- Player one cell below the trainer, trainer facing down: engagement.
|
|
local world, npc = trainerWorld(4, 8, "up", "down")
|
|
check(world:checkTrainerBattle(), "inside the cone the trainer engages")
|
|
check(npc.frozen, "and freezes for the whole engagement")
|
|
local before = npc.facing
|
|
npc.timer = 1
|
|
for _ = 1, 30 do world:updatePeople() end
|
|
eq(npc.facing, before, "his spin cannot roll a new facing mid-script")
|
|
-- Drain the engagement: the emote's 30-frame hold, the seen text, the
|
|
-- battle, the after script. updatePeople keeps running under it, which is
|
|
-- exactly when an unfrozen spinner would drift.
|
|
local drifted = false
|
|
for _ = 1, 60 do
|
|
world:step()
|
|
world:updatePeople()
|
|
if world.vm:running() and npc.facing ~= before then drifted = true end
|
|
advanceText(world)
|
|
end
|
|
eq(world.log[1], "Instead of a bug\nPOKéMON, I found\va trainer!",
|
|
"the sighting text is the struct's seen text, verbatim")
|
|
check(not drifted, "and the spin never rolled while the script ran")
|
|
check(not world.vm:running(), "the engagement script finished")
|
|
check(world.events:get(BEAT_FLAG), "and the trainer is marked beaten")
|
|
for _ = 1, 3 do world:step() end
|
|
check(not npc.frozen, "the freeze lifts once everything settles")
|
|
end
|
|
|
|
do
|
|
-- Player BEHIND the trainer (he faces down, player stands above): nothing.
|
|
local world = trainerWorld(4, 6, "down", "down")
|
|
check(not world:checkTrainerBattle(),
|
|
"standing behind his facing never engages")
|
|
eq(#world.log, 0, "and no sighting text prints")
|
|
end
|
|
|
|
do
|
|
-- Off his column entirely: nothing, whatever the range.
|
|
local world = trainerWorld(6, 8, "up", "down")
|
|
check(not world:checkTrainerBattle(), "off the sight line never engages")
|
|
end
|
|
|
|
do
|
|
-- Facing him but beyond sight 3: nothing.
|
|
local world, npc = trainerWorld(4, 11, "up", "down")
|
|
npc.cellY = 7
|
|
check(not world:checkTrainerBattle(), "past the sight range never engages")
|
|
end
|
|
|
|
do
|
|
-- CheckTrainerEvent is PlayerEvents' FIRST test (engine/overworld/events.asm:
|
|
-- 245) and, unlike every arm of CheckTileEvent, it is NOT behind
|
|
-- wEnabledPlayerEvents -- MapEvents clears that byte every pass
|
|
-- (events.asm:168) and CheckPlayerState only re-sets it on a step that
|
|
-- landed (events.asm:210-221). So the sight cone is polled on every
|
|
-- overworld frame, not only on the frame a step lands: a spinner that
|
|
-- rotates onto a STANDING player engages there and then. Nothing is called
|
|
-- directly here -- that is the point, the call SITE is what was wrong.
|
|
local world, npc = trainerWorld(4, 8, "up", "up")
|
|
world.updatePeople = function() end
|
|
world:step()
|
|
check(not world.vm:running(),
|
|
"a trainer facing away leaves the standing player alone")
|
|
npc.facing = "down"
|
|
for _ = 1, 3 do world:step() end
|
|
check(world.vm:running(),
|
|
"and the frame he turns onto the player's line, he engages -- no step needed")
|
|
check(npc.frozen, "the engagement freezes him the same way")
|
|
end
|
|
|
|
S.finish()
|