8-bit panda

From XPUB & Lens-Based wiki
Revision as of 08:59, 1 February 2021 by Michael Murtaugh (talk | contribs)

TIC-80 game by Bruno Oliveira

Find the cheat code !


-- title:  8 Bit Panda
-- author: Bruno Oliveira
-- desc:   A panda platformer
-- script: lua
-- saveid: eightbitpanda
--
-- WARNING: this file must be kept under
-- 64kB (TIC-80 limit)!

NAME="8-BIT PANDA"
C=8
ROWS=17
COLS=30
SCRW=240
SCRH=136

-- jump sequence (delta y at each frame)
JUMP_DY={-3,-3,-3,-3,-2,-2,-2,-2,
  -1,-1,0,0,0,0,0}

-- swimming seq (implemented as a "jump")
SWIM_JUMP_DY={-2,-2,-1,-1,-1,-1,0,0,0,0,0}
RESURF_DY={-3,-3,-2,-2,-1,-1,0,0,0,0,0}

-- attack sequence (1=preparing,
-- 2=attack,3=recovery)
ATK_SEQ={1,1,1,1,2,3,3,3}

-- die sequence (dx,dy)
DIE_SEQ={{-1,-1},{-2,-2},{-3,-3},{-4,-4},
 {-5,-5},{-6,-5},{-7,-4},{-8,-3},{-8,-2},
 {-8,1},{-8,3},{-8,5},{-8,9},{-8,13},
 {-8,17},{-8,21},{-8,26},{-8,32},{-8,39}
}

-- display x coords in which
-- to keep the player (for scrolling)
SX_MIN=50
SX_MAX=70

-- entity/tile solidity
SOL={
 NOT=0,  -- not solid
 HALF=1, -- only when going down,
         -- allows movement upward.
 FULL=2, -- fully solid
}

FIRE={
 -- duration of fire powerup
 DUR=1000,
 -- time between successive fires.
 INTERVAL=20,
 -- offset from player pos
 OFFY=2,OFFX=7,OFFX_FLIP=-2,
 OFFX_PLANE=14,OFFY_PLANE=8,
 -- projectile collision rect
 COLL={x=0,y=0,w=3,h=3},
}

-- Tiles
-- 0: empty
-- 1-79: static solid blocks
-- 80-127: decorative
-- 128-239: entities
-- 240-255: special markers
T={
 EMPTY=0,
 -- platform that's only solid when
 -- going down, but allows upward move
 HPLAF=4,
 
 SURF=16,
 WATER=32,
 WFALL=48,

 TARMAC=52, -- (where plane can land).

 -- sprite id above which tiles are
 -- non-solid decorative elements
 FIRST_DECO=80,

 -- level-end gate components
 GATE_L=110,GATE_R=111,
 GATE_L2=142,GATE_R=143,

 -- tile id above which tiles are
 -- representative of entities, not bg
 FIRST_ENT=128,
 
 -- tile id above which tiles have special
 -- meanings
 FIRST_META=240,
 
 -- number markers (used for level
 -- packing and annotations).
 META_NUM_0=240,
   -- followed by nums 1-9.
 
 -- A/B markers (entity-specific meaning)
 META_A=254,
 META_B=255
}

-- Autocomplete of tiles patterns.
-- Auto filled when top left map tile
-- is present.
TPAT={
 [85]={w=2,h=2},
 [87]={w=2,h=2},
 [94]={w=2,h=2},
 [89]={w=2,h=2},
}

-- solidity of tiles (overrides)
TSOL={
 [T.EMPTY]=SOL.NOT,
 [T.HPLAF]=SOL.HALF,
 [T.SURF]=SOL.NOT,
 [T.WATER]=SOL.NOT,
 [T.WFALL]=SOL.NOT,
}

-- animated tiles
TANIM={
 [T.SURF]={T.SURF,332},
 [T.WFALL]={T.WFALL,333,334,335},
}

-- sprites
S={
 PLR={  -- player sprites
  STAND=257,
  WALK1=258,
  WALK2=259,
  JUMP=273,
  SWING=276,
  SWING_C=260,
  HIT=277,
  HIT_C=278,
  DIE=274,
  SWIM1=267,SWIM2=268,
  -- overlays for fire powerup
  FIRE_BAMBOO=262, -- bamboo powerup
  FIRE_F=265,  -- suit, front
  FIRE_P=266,  -- suit, profile
  FIRE_S=284,  -- suit, swimming
  -- overlays for super panda powerup
  SUPER_F=281, -- suit, front
  SUPER_P=282, -- suit, profile
  SUPER_S=283, -- suit, swimming
 },
 EN={  -- enemy sprites
  A=176,
  B=177,
  DEMON=178,
  DEMON_THROW=293,
  SLIME=180,
  BAT=181,
  HSLIME=182, -- hidden slime
  DASHER=183,
  VBAT=184,
  SDEMON=185, -- snow demon
  SDEMON_THROW=300,
  PDEMON=188,  -- plasma demon
  PDEMON_THROW=317,
  FISH=189,
  FISH2=190,
 },
 -- crumbling block
 CRUMBLE=193,CRUMBLE_2=304,CRUMBLE_3=305,
 FIREBALL=179,
 FIRE_1=263,FIRE_2=264,
 LIFT=192,
 PFIRE=263, -- player fire (bamboo)
 FIRE_PWUP=129,
 -- background mountains
 BGMNT={DIAG=496,FULL=497},
 SCRIM=498, -- also 499,500
 SPIKE=194,
 CHEST=195,CHEST_OPEN=311,
 -- timed platform (opens and closes)
 TPLAF=196,TPLAF_HALF=312,TPLAF_OFF=313,
 SUPER_PWUP=130,
 SIGN=197,
 SNOWBALL=186,
 FLAG=198,
 FLAG_T=326,  -- flag after taken
 ICICLE=187,   -- icicle while hanging
 ICICLE_F=303, -- icicle falling
 PLANE=132,  -- plane (item)
 AVIATOR=336, -- aviator sprite (3x2)
 AVIATOR_PROP_1=339, -- propeller anim
 AVIATOR_PROP_2=340, -- propeller anim
 PLASMA=279,  -- plasma ball
 SICICLE=199,   -- stone-themed icicle,
                -- while hanging
 SICICLE_F=319, -- stone-themed icicle,
                -- while falling
 FUEL=200,      -- fuel item
 IC_FUEL=332,   -- icon for HUD
 TINY_NUM_00=480, -- "00" sprite
 TINY_NUM_50=481, -- "50" sprite
 TINY_NUM_R1=482, -- 1-10, right aligned
 
 -- food items
 FOOD={LEAF=128,A=133,B=134,C=135,D=136},
 
 SURF1=332,SURF2=333, -- water surface fx

 -- world map tiles
 WLD={
  -- tiles that player can walk on
  ROADS={13,14,15,30,31,46,47},
  -- level tiles
  LVL1=61,LVL2=62,LVL3=63,
  LVLF=79, -- finale level
  -- "cleared level" tile
  LVLC=463,
 },
 
 -- Special EIDs that don't correspond to
 -- sprites. ID must be > 512
 POP=600, -- entity that dies immediately
          -- with a particle effect
}

-- Sprite numbers also function as entity
-- IDs. For readability we write S.FOO
-- when it's a sprite but EID.FOO when
-- it identifies an entity type.
EID=S

-- anims for each entity ID
ANIM={
 [EID.EN.A]={S.EN.A,290},
 [EID.EN.B]={S.EN.B,291},
 [EID.EN.DEMON]={S.EN.DEMON,292},
 [EID.EN.SLIME]={S.EN.SLIME,295},
 [EID.EN.BAT]={S.EN.BAT,296},
 [EID.FIREBALL]={S.FIREBALL,294},
 [EID.FOOD.LEAF]={S.FOOD.LEAF,288,289},
 [EID.PFIRE]={S.PFIRE,264},
 [EID.FIRE_PWUP]={S.FIRE_PWUP,306,307},
 [EID.EN.HSLIME]={S.EN.HSLIME,297},
 [EID.SPIKE]={S.SPIKE,308},
 [EID.CHEST]={S.CHEST,309,310},
 [EID.EN.DASHER]={S.EN.DASHER,314},
 [EID.EN.VBAT]={S.EN.VBAT,298},
 [EID.SUPER_PWUP]={S.SUPER_PWUP,320,321},
 [EID.EN.SDEMON]={S.EN.SDEMON,299},
 [EID.SNOWBALL]={S.SNOWBALL,301},
 [EID.FOOD.D]={S.FOOD.D,322,323,324},
 [EID.FLAG]={S.FLAG,325},
 [EID.ICICLE]={S.ICICLE,302},
 [EID.SICICLE]={S.SICICLE,318},
 [EID.PLANE]={S.PLANE,327,328,329},
 [EID.EN.PDEMON]={S.EN.PDEMON,316},
 [EID.PLASMA]={S.PLASMA,280},
 [EID.FUEL]={S.FUEL,330,331},
 [EID.EN.FISH]={S.EN.FISH,368},
 [EID.EN.FISH2]={S.EN.FISH2,369},
}

PLANE={
 START_FUEL=2000,
 MAX_FUEL=4000,
 FUEL_INC=1000,
 FUEL_BAR_W=50
}

-- modes
M={
 BOOT=0,
 TITLE=1,    -- title screen
 TUT=2,      -- instructions
 RESTORE=3,  -- prompting to restore game
 WLD=4,      -- world map
 PREROLL=5,  -- "LEVEL X-Y" banner
 PLAY=6,
 DYING=7,    -- die anim
 EOL=8,      -- end of level
 GAMEOVER=9,
 WIN=10,     -- beat entire game
}

-- collider rects
CR={
 PLR={x=2,y=0,w=4,h=8},
 AVIATOR={x=-6,y=2,w=18,h=10},
 -- default
 DFLT={x=2,y=0,w=4,h=8},
 FULL={x=0,y=0,w=8,h=8},
 -- small projectiles
 BALL={x=2,y=2,w=3,h=3},
 -- just top rows
 TOP={x=0,y=0,w=8,h=2},
 -- player attack
 ATK={x=6,y=0,w=7,h=8},
 -- what value to use for x instead if
 -- player is flipped (facing left)
 ATK_FLIP_X=-5,
 FOOD={x=1,y=1,w=6,h=6},
}

-- max dist entity to update it
ENT_MAX_DIST=220

-- EIDs to always update regardless of
-- distance.
ALWAYS_UPDATED_EIDS={
 -- lifts need to always be updated for
 -- position determinism.
 [EID.LIFT]=true
}

-- player damage types
DMG={
 MELEE=0,      -- melee attack
 FIRE=1,       -- fire from fire powerup
 PLANE_FIRE=2, -- fire from plane
}

-- default palette
PAL={
 [0]=0x000000,  [1]=0x402434,
 [2]=0x30346d,  [3]=0x4a4a4a,
 [4]=0x854c30,  [5]=0x346524,
 [6]=0xd04648,  [7]=0x757161,
 [8]=0x34446d,  [9]=0xd27d2c,
 [10]=0x8595a1, [11]=0x6daa2c,
 [12]=0x1ce68d, [13]=0x6dc2ca,
 [14]=0xdad45e, [15]=0xdeeed6,
}

-- music tracks
BGM={A=0,B=1,EOL=2,C=3,WLD=4,TITLE=5,
 FINAL=6,WIN=7}

-- bgm for each mode (except M.PLAY, which
-- is special)
BGMM={
 [M.TITLE]=BGM.TITLE,
 [M.WLD]=BGM.WLD,
 [M.EOL]=BGM.EOL,
 [M.WIN]=BGM.WIN,
}

-- map data is organized in pages.
-- Each page is 30x17. TIC80 has 64 map
-- pages laid out as an 8x8 grid. We
-- number them in reading order, so 0
-- is top left, 63 is bottom right.

-- Level info.
-- Levels in the cart are packed
-- (RLE compressed). When a level is loaded,
-- it gets unpacked to the top 8 map pages
-- (0,0-239,16).
--  palor: palette overrides
--  pkstart: map page where packed
--    level starts.
--  pklen: length of level. Entire level
--    must be on same page row, can't
--    span multiple page rows.
LVL={
 {
  name="1-1",bg=2,
  palor={},
  pkstart=8,pklen=3,
  mus=BGM.A,
 },
 {
  name="1-2",bg=0,
  palor={[8]=0x102428},
  pkstart=11,pklen=2,
  mus=BGM.B,
 },
 {
  name="1-3",bg=2,
  pkstart=13,pklen=3,
  mus=BGM.C,
  save=true,
 },
 {
  name="2-1",bg=1,
  palor={[8]=0x553838},
  pkstart=16,pklen=3,
  mus=BGM.A,
 },
 {
  name="2-2",bg=0,
  palor={[8]=0x553838},
  pkstart=19,pklen=2,
  snow={clr=2},
  mus=BGM.B,
 },
 {
  name="2-3",bg=1,
  palor={[8]=0x553838},
  pkstart=21,pklen=3,
  mus=BGM.C,
  save=true,
 },
 {
  name="3-1",bg=2,
  palor={[8]=0x7171ae},
  pkstart=24,pklen=3,
  snow={clr=10},
  mus=BGM.A,
 },
 {
  name="3-2",bg=0,
  palor={[8]=0x3c3c50},
  pkstart=27,pklen=2,
  snow={clr=10},
  mus=BGM.B,
 },
 {
  name="3-3",bg=2,
  palor={[8]=0x7171ae},
  pkstart=29,pklen=3,
  mus=BGM.C,
  save=true,
 },
 {
  name="4-1",bg=2,
  palor={[2]=0x443c14,[8]=0x504410},
  pkstart=32,pklen=3,
  mus=BGM.A,
 },
 {
  name="4-2",bg=2,
  palor={[2]=0x443c14,[8]=0x504410},
  pkstart=35,pklen=2,
  mus=BGM.B,
 },
 {
  name="4-3",bg=2,
  palor={[2]=0x443c14,[8]=0x504410},
  pkstart=37,pklen=3,
  mus=BGM.C,
  save=true,
 },
 {
  name="5-1",bg=1,
  palor={[8]=0x553838},
  pkstart=40,pklen=3,
  mus=BGM.A,
 },
 {
  name="5-2",bg=1,
  palor={[8]=0x553838},
  pkstart=43,pklen=2,
  mus=BGM.B,
 },
 {
  name="5-3",bg=1,
  palor={[8]=0x553838},
  pkstart=45,pklen=3,
  mus=BGM.C,
  save=true,
 },
 {
  name="6-1",bg=0,
  palor={[8]=0x303030},
  pkstart=48,pklen=3,
  mus=BGM.FINAL,
  snow={clr=8},
 },
 {
  name="6-2",bg=0,
  palor={[8]=0x303030},
  pkstart=51,pklen=5,
  mus=BGM.FINAL,
  snow={clr=8},
 },
}

-- length of unpacked level, in cols
-- 240 means the top 8 map pages
LVL_LEN=240

-- sound specs
SND={
 KILL={sfxid=62,note=30,dur=5},
 JUMP={sfxid=61,note=30,dur=4},
 SWIM={sfxid=61,note=50,dur=3},
 ATTACK={sfxid=62,note=40,dur=4},
 POINT={sfxid=60,note=60,dur=5,speed=3},
 DIE={sfxid=63,note=18,dur=20,speed=-1},
 HURT={sfxid=63,note="C-4",dur=4},
 PWUP={sfxid=60,note=45,dur=15,speed=-2},
 ONEUP={sfxid=60,note=40,dur=60,speed=-3},
 PLANE={sfxid=59,note="C-4",dur=70,speed=-3},
 OPEN={sfxid=62,note="C-3",dur=4,speed=-2},
}

-- world map consts
WLD={
 -- foreground tile page
 FPAGE=61,
 -- background tile page
 BPAGE=62,
}

-- WLD point of interest types
POI={
 LVL=0,
}

-- settings
Sett={
 snd=true,
 mus=true
}

-- game state
Game={
 -- mode
 m=M.BOOT,
 -- ticks since mode start
 t=0,
 -- current level# we're playing
 lvlNo=0,
 lvl=nil,  -- shortcut to LVL[lvlNo]
 -- scroll offset in current level
 scr=0,
 -- auto-generated background mountains
 bgmnt=nil,
 -- snow flakes (x,y pairs). These don't
 -- change, we just shift when rendering.
 snow=nil,
 -- highest level cleared by player,
 -- -1 if no level cleared
 topLvl=-1,
}

-- world map state
Wld={
 -- points of interest (levels, etc)
 pois={},
 -- savegame start pos (maps start level
 -- to col,row)
 spos={},
 plr={
  -- start pos
  x0=-1,y0=-1,
  -- player pos, in pixels not row/col
  x=0,y=0,
  -- player move dir, if moving. Will move
  -- until plr arrives at next cell
  dx=0,dy=0,
  -- last move dir
  ldx=0,ldy=0,
  -- true iff player facing left
  flipped=false,
 }
}

-- player
Plr={} -- deep-copied from PLR_INIT_STATE
PLR_INIT_STATE={
 lives=3,
 x=0,y=0, -- current pos
 dx=0,dy=0, -- last movement
 flipped=false, -- if true, is facing left
 jmp=0, -- 0=not jumping, otherwise
        -- it's the cur jump frame
 jmpSeq=JUMP_DY, -- set during jump
 grounded=false,
 swim=false,
 -- true if plr is near surface of water
 surf=false,
 -- attack state. 0=not attacking,
 -- >0 indexes into ATK_SEQ
 atk=0,
 -- die animation frame, 0=not dying
 -- indexes into DIE_SEQ
 dying=0,
 -- nudge (movement resulting from
 -- collisions)
 nudgeX=0,nudgeY=0,
 -- if >0, has fire bamboo powerup
 -- and this is the countdown to end
 firePwup=0,
 -- if >0 player has fired bamboo.
 -- This is ticks until player can fire
 -- again.
 fireCd=0,
 -- if >0, is invulnerable for this
 -- many ticks, 0 if not invulnerable
 invuln=0,
 -- if true, has the super panda powerup
 super=false,
 -- if != 0, player is being dragged
 -- horizontally (forced to move in that
 -- direction -- >0 is right, <0 is left)
 -- The abs value is how many frames
 -- this lasts for.
 drag=0,
 -- the sign message (index) the player
 -- is currently reading.
 signMsg=0,
 -- sign cycle counter: 1 when just
 -- starting to read sign, increases.
 -- when player stops reading sign,
 -- decreases back to 0.
 signC=0,
 -- respawn pos, 0,0 if unset
 respX=-1,respY=-1,
 -- if >0, the player is on the plane
 -- and this is the fuel left (ticks).
 plane=0,
 -- current score
 score=0,
 -- for performance, we keep the
 -- stringified score ready for display
 scoreDisp={text=nil,value=-1},
 -- time (Game.t) when score last changed
 scoreMt=-999,
 -- if >0, player is blocked from moving
 -- for that many frames.
 locked=0,
}

-- max cycle counter for signs
SIGN_C_MAX=10

-- sign texts.
SIGN_MSGS={
 [0]={
  l1="Green bamboo protects",
  l2="against one enemy attack.",
 },
 [1]={
  l1="Yellow bamboo allows you to throw",
  l2="bamboo shoots (limited time).",
 },
 [2]={
  l1="Pick up leaves and food to get",
  l2="points. 10,000 = extra life.",
 },
 [4]={
  l1="Bon voyage!",
  l2="Don't run out of fuel.",
 },
}

-- entities
Ents={}

-- particles
Parts={}

-- score toasts
Toasts={}

-- animated tiles, for quick lookup
-- indexed by COLUMN.
-- Tanims[c] is a list of integers
-- indicating rows of animated tiles.
Tanims={}

function SetMode(m)
 Game.m=m
 Game.t=0
 if m~=M.PLAY and m~=M.DYING and
   m~=M.EOL then
  ResetPal()
 end
 UpdateMus()
end

function UpdateMus()
 if Game.m==M.PLAY then
  PlayMus(Game.lvl.mus)
 else
  PlayMus(BGMM[Game.m] or -1)
 end
end

function TIC()
 CheckDbgMenu()
 if Plr.dbg then
  DbgTic()
  return
 end
 Game.t=Game.t+1
 TICF[Game.m]()
end

function CheckDbgMenu()
 if not btn(6) then
  Game.dbgkc=0
  return
 end
 if btnp(0) then
  Game.dbgkc=10+(Game.dbgkc or 0)
 end
 if btnp(1) then
  Game.dbgkc=1+(Game.dbgkc or 0)
 end
 if Game.dbgkc==42 then Plr.dbg=true end
end

function Boot()
 ResetPal()
 WldInit()
 SetMode(M.TITLE)
end

-- restores default palette with
-- the given overrides.
function ResetPal(palor)
 for c=0,15 do
  local clr=PAL[c]
  if palor and palor[c] then
   clr=palor[c]
  end
  poke(0x3fc0+c*3+0,(clr>>16)&255)
  poke(0x3fc0+c*3+1,(clr>>8)&255)
  poke(0x3fc0+c*3+2,clr&255)
 end
end

function TitleTic()
 ResetPal()
 cls(2)
 local m=MapPageStart(63)
 map(m.c,m.r,30,17,0,0,0)

 spr(S.PLR.WALK1+(time()//128)%2,16,104,0)
 rect(0,0,240,24,5)
 print(NAME,88,10)
 rect(0,24,240,1,15)
 rect(0,26,240,1,5)
 rect(0,SCRH-8,SCRW,8,0)
 print("github.com/btco/panda",60,SCRH-7,7)
 
 if (time()//512)%2>0 then
  print("- PRESS 'Z' TO START -",65,84,15)
 end

 RendSpotFx(COLS//2,ROWS//2,Game.t)
 if btnp(4) then
  SetMode(M.RESTORE)
 end
end

function RestoreTic()
 local saveLvl=pmem(0) or 0
 if saveLvl<1 then
  StartGame(1)
  return
 end
 
 Game.restoreSel=Game.restoreSel or 0
 
 cls(0)
 local X=40
 local Y1=30
 local Y2=60
 print("CONTINUE (LEVEL "..
   LVL[saveLvl].name ..")",X,Y1)
 print("START NEW GAME",X,Y2)
 spr(S.PLR.STAND,X-20,
  Iif(Game.restoreSel>0,Y2,Y1))
 if btnp(0) or btnp(1) then
  Game.restoreSel=
   Iif(Game.restoreSel>0,0,1)
 elseif btnp(4) then
  StartGame(Game.restoreSel>0 and
    1 or saveLvl)
 end
end

function TutTic()
 cls(0)
 if Game.tutdone then
  StartLvl(1)
  return
 end
 local p=MapPageStart(56)
 map(p.c,p.r,COLS,ROWS)

 print("CONTROLS",100,10)
 print("JUMP",56,55);
 print("ATTACK",72,90);
 print("MOVE",160,50);
 print("10,000 PTS = EXTRA LIFE",60,110,3);

 if Game.t>150 and 0==((Game.t//16)%2) then
   print("- Press Z to continue -",60,130)
 end

 if Game.t>150 and btnp(4) then
  Game.tutdone=true
  StartLvl(1)
 end
end

function WldTic()
 WldUpdate()
 WldRend()
end

function PrerollTic()
 cls(0)
 print("LEVEL "..Game.lvl.name,100,40)
 spr(S.PLR.STAND,105,60)
 print("X " .. Plr.lives,125,60)
 if Game.t>60 then
  SetMode(M.PLAY)
 end
end

function PlayTic()
 if Plr.dbgFly then
  UpdateDbgFly()
 else
  UpdatePlr()
  UpdateEnts()
  UpdateParts()
  DetectColl()
  ApplyNudge()
  CheckEndLvl()
 end
 AdjustScroll() 
 Rend()
 if Game.m==M.PLAY then
  RendSpotFx((Plr.x-Game.scr)//C,
    Plr.y//C,Game.t)
 end
end

function EolTic()
 if Game.t>160 then
  AdvanceLvl()
  return
 end
 Rend()
 print("LEVEL CLEAR",85,20)
end

function DyingTic()
 Plr.dying=Plr.dying+1

 if Game.t>100 then
  if Plr.lives>1 then
   Plr.lives=Plr.lives-1
   SetMode(M.WLD)
  else
   SetMode(M.GAMEOVER)
  end
 else
  Rend()
 end
end

function GameOverTic()
 cls(0)
 print("GAME OVER!",92,50)
 if Game.t>150 then
  SetMode(M.TITLE)
 end
end

function WinTic()
 cls(0)
 Game.scr=0
 local m=MapPageStart(57)
 map(m.c,m.r,
   math.min(30,(Game.t-300)//8),17,0,0,0)
 print("THE END!",100,
   math.max(20,SCRH-Game.t//2))
 print("Thanks for playing!",70,
   math.max(30,120+SCRH-Game.t//2))
 
 if Game.t%100==0 then
  SpawnParts(PFX.FW,Rnd(40,SCRW-40),
   Rnd(40,SCRH-40),Rnd(2,15))
 end

 UpdateParts()
 RendParts()

 if Game.t>1200 and btnp(4) then
  SetMode(M.TITLE)
 end
end

TICF={
 [M.BOOT]=Boot,
 [M.TITLE]=TitleTic,
 [M.TUT]=TutTic,
 [M.RESTORE]=RestoreTic,
 [M.WLD]=WldTic,
 [M.PREROLL]=PrerollTic,
 [M.PLAY]=PlayTic,
 [M.DYING]=DyingTic,
 [M.GAMEOVER]=GameOverTic,
 [M.EOL]=EolTic,
 [M.WIN]=WinTic,
}

function StartGame(startLvlNo)
 Game.topLvl=startLvlNo-1
 Plr=DeepCopy(PLR_INIT_STATE)
 -- put player at the right start pos
 local sp=Wld.spos[startLvlNo] or 
   {x=Wld.plr.x0,y=Wld.plr.y0}
 Wld.plr.x=sp.x
 Wld.plr.y=sp.y
 SetMode(M.WLD)
end

function StartLvl(lvlNo)
 local oldLvlNo=Game.lvlNo
 Game.lvlNo=lvlNo
 Game.lvl=LVL[lvlNo]
 Game.scr=0
 local old=Plr
 Plr=DeepCopy(PLR_INIT_STATE)
 -- preserve lives, score
 Plr.lives=old.lives
 Plr.score=old.score
 Plr.super=old.super
 if oldLvlNo==lvlNo then
  Plr.respX=old.respX
  Plr.respY=old.respY
 end
 SetMode(M.PREROLL)
 Ents={}
 Parts={}
 Toasts={}
 Tanims={}
 UnpackLvl(lvlNo,UMODE.GAME)
 GenBgMnt()
 GenSnow()
 ResetPal(Game.lvl.palor)
 AdjustRespawnPos()
end

function AdjustRespawnPos()
 if Plr.respX<0 then return end
 for i=1,#Ents do
  local e=Ents[i]
  if e.eid==EID.FLAG and e.x<Plr.respX then
   EntRepl(e,EID.FLAG_T)
  end
 end
 Plr.x=Plr.respX
 Plr.y=Plr.respY
end

-- generates background mountains.
function GenBgMnt()
 local MAX_Y=12
 local MIN_Y=2
 -- min/max countdown to change direction:
 local MIN_CD=2
 local MAX_CD=6
 Game.bgmnt={}
 RndSeed(Game.lvlNo)
 local y=Rnd(MIN_Y,MAX_Y)
 local dy=1
 local cd=Rnd(MIN_CD,MAX_CD)
 for i=1,LVL_LEN do
  Ins(Game.bgmnt,{y=y,dy=dy})
  cd=cd-1
  if cd<=0 or y+dy<MIN_Y or y+dy>MAX_Y then
   -- keep same y but change direction
   cd=Rnd(MIN_CD,MAX_CD)
   dy=-dy
  else
   y=y+dy
  end
 end
 RndSeed(time())
end

function GenSnow()
 if not Game.lvl.snow then
  Game.snow=nil
  return
 end
 Game.snow={}
 for r=0,ROWS-1,2 do
  for c=0,COLS-1,2 do
   Ins(Game.snow,{
    x=c*C+Rnd(-8,8),
    y=r*C+Rnd(-8,8)
   })
  end
 end
end

-- Whether player is on solid ground.
function IsOnGround()
 return not CanMove(Plr.x,Plr.y+1)
end

-- Get level tile at given point
function LvlTileAtPt(x,y)
 return LvlTile(x//C,y//C)
end

-- Get level tile.
function LvlTile(c,r)
 if c<0 or c>=LVL_LEN then return 0 end
 if r<0 then return 0 end
 -- bottom-most tile repeats infinitely
 -- below (to allow player to swim
 -- when bottom tile is water).
 if r>=ROWS then r=ROWS-1 end
 return mget(c,r)
end

function SetLvlTile(c,r,t)
 if c<0 or c>=LVL_LEN then return false end
 if r<0 or r>=ROWS then return false end
 mset(c,r,t)
end

function UpdatePlr()
 local oldx=Plr.x
 local oldy=Plr.y

 Plr.plane=Max(Plr.plane-1,0)
 Plr.fireCd=Max(Plr.fireCd-1,0)
 Plr.firePwup=Max(Plr.firePwup-1,0)
 Plr.invuln=Max(Plr.invuln-1,0)
 Plr.drag=Iif2(Plr.drag>0,Plr.drag-1,
   Plr.drag<0,Plr.drag+1,0)
 Plr.signC=Max(Plr.signC-1,0)
 Plr.locked=Max(Plr.locked-1,0)
 UpdateSwimState()
 
 local swimmod=Plr.swim and Game.t%2 or 0

 if (Plr.plane==0 and Plr.jmp==0 and
   not IsOnGround()) then
  -- fall
  Plr.y=Plr.y+1-swimmod
 end
 
 -- check if player fell into pit
 if Plr.y>SCRH+8 then
  StartDying()
  return
 end

 -- horizontal movement
 local dx=0
 local dy=0
 local wantLeft=Plr.locked==0 and
   Iif(Plr.drag==0,btn(2),Plr.drag<0)
 local wantRight=Plr.locked==0 and
   Iif(Plr.drag==0,btn(3),Plr.drag>0)
 local wantJmp=Plr.locked==0 and
   Plr.plane==0 and btnp(4) and Plr.drag==0
 local wantAtk=Plr.locked==0 and
   btnp(5) and Plr.drag==0

 if wantLeft then
  dx=-1+swimmod
  -- plane doesn't flip
  Plr.flipped=true
 elseif wantRight then
  dx=1-swimmod
  Plr.flipped=false
 end

 -- vertical movement (plane only)
 dy=dy+Iif2(Plr.plane>0 and btn(0) and
   Plr.y>8,-1,
   Plr.plane>0 and btn(1) and
   Plr.y<SCRH-16,1,0)

 -- is player flipped (facing left?)
 Plr.flipped=Iif3(
   Plr.plane>0,false,btn(2),true,
   btn(3),false,Plr.flipped)

 TryMoveBy(dx,dy)
 
 Plr.grounded=Plr.plane==0 and IsOnGround()
 
 local canJmp=Plr.grounded or Plr.swim
 -- jump
 if wantJmp and canJmp then
  Plr.jmp=1
  Plr.jmpSeq=Plr.surf and
    RESURF_DY or
    (Plr.swim and SWIM_JUMP_DY or
    JUMP_DY)
  Snd(Plr.surf and SND.JUMP or
    Plr.swim and SND.SWIM or SND.JUMP)
  -- TODO play swim snd if swim
 end

 if Plr.jmp>#Plr.jmpSeq then
  -- end jump
  Plr.jmp=0
 elseif Plr.jmp>0 then
  local ok=TryMoveBy(
    0,Plr.jmpSeq[Plr.jmp])
  -- if blocked, cancel jump
  Plr.jmp=ok and Plr.jmp+1 or 0
 end
 
 -- attack
 if Plr.atk==0 then
  if wantAtk then
   -- start attack sequence
   if Plr.plane==0 then Plr.atk=1 end
   Snd(SND.ATTACK)
   TryFire()
  end
 elseif Plr.atk>#ATK_SEQ then
  -- end of attack sequence
  Plr.atk=0
 else
  -- advance attack sequence
  Plr.atk=Plr.atk+1
 end

 -- check plane landing
 if Plr.plane>0 then CheckTarmac() end

 Plr.dx=Plr.x-oldx
 Plr.dy=Plr.y-oldy
end

function IsWater(t)
 return t==T.WATER or t==T.SURF or
   t==T.WFALL
end

function UpdateSwimState()
 local wtop=IsWater(
   LvlTileAtPt(Plr.x+4,Plr.y+1))
 local wbottom=IsWater(
   LvlTileAtPt(Plr.x+4,Plr.y+7))
 local wtop2=IsWater(
   LvlTileAtPt(Plr.x+4,Plr.y-8))
 Plr.swim=wtop and wbottom
 -- is plr near surface?
 Plr.surf=wbottom and not wtop2
end

function UpdateDbgFly()
 local d=Iif(btn(4),5,1)
 if btn(0) then Plr.y=Plr.y-d end
 if btn(1) then Plr.y=Plr.y+d end
 if btn(2) then Plr.x=Plr.x-d end
 if btn(3) then Plr.x=Plr.x+d end
 if btn(5) then Plr.dbgFly=false end
end

function TryFire()
 if Plr.firePwup<1 and Plr.plane==0 then
  return
 end
 if Plr.fireCd>0 then return end
 Plr.fireCd=FIRE.INTERVAL
 local x=Plr.x
 if Plr.plane==0 then
  x=x+(Plr.flipped and
    FIRE.OFFX_FLIP or FIRE.OFFX)
 else
  -- end of plane
  x=x+FIRE.OFFX_PLANE
 end
 local y=Plr.y+Iif(Plr.plane>0,
   FIRE.OFFY_PLANE,FIRE.OFFY)
 local e=EntAdd(EID.PFIRE,x,y)
 e.moveDx=Plr.plane>0 and 2 or
   (Plr.flipped and -1 or 1)
 e.ttl=Plr.plane>0 and e.ttl//2 or e.ttl
end

function ApplyNudge()
 Plr.y=Plr.y+Plr.nudgeY
 Plr.x=Plr.x+Plr.nudgeX
 Plr.nudgeX=0
 Plr.nudgeY=0
end

function TryMoveBy(dx,dy)
 if CanMove(Plr.x+dx,Plr.y+dy) then
  Plr.x=Plr.x+dx
  Plr.y=Plr.y+dy
  return true
 end
 return false
end

function GetPlrCr()
 return Iif(Plr.plane>0,CR.AVIATOR,CR.PLR)
end

-- Check if plr can move to given pos.
function CanMove(x,y)
 local dy=y-Plr.y
 local pcr=GetPlrCr()
 local r=CanMoveEx(x,y,pcr,dy)
 if not r then return false end

 -- check if would bump into solid ent
 local pr=RectXLate(pcr,x,y)
 for i=1,#Ents do
  local e=Ents[i]
  local effSolid=(e.sol==SOL.FULL) or
   (e.sol==SOL.HALF and dy>0 and
   Plr.y+5<e.y) -- (HACK)
  if effSolid then
   local er=RectXLate(e.coll,e.x,e.y)
   if RectIsct(pr,er) then
    return false
   end
  end
 end
 return true
end

function EntCanMove(e,x,y)
 return CanMoveEx(x,y,e.coll,y-e.y)
end

function GetTileSol(t)
 local s=TSOL[t]
 -- see if an override is present.
 if s~=nil then return s end
 -- default:
 return Iif(t>=T.FIRST_DECO,SOL.NOT,SOL.FULL)
end

-- x,y: candidate pos; cr: collision rect
-- dy: y direction of movement
function CanMoveEx(x,y,cr,dy)
 local x1=x+cr.x
 local y1=y+cr.y
 local x2=x1+cr.w-1
 local y2=y1+cr.h-1
 -- check all tiles touched by the rect
 local startC=x1//C
 local endC=x2//C
 local startR=y1//C
 local endR=y2//C
 for c=startC,endC do
  for r=startR,endR do
   local sol=GetTileSol(LvlTile(c,r))
   if sol==SOL.FULL then return false end
  end
 end

 -- special case: check for half-solidity
 -- tiles. Only solid when standing on
 -- top of them (y2%C==0) and going
 -- down (dy>0).
 local sA=GetTileSol(LvlTileAtPt(x1,y2))
 local sB=GetTileSol(LvlTileAtPt(x2,y2))
 if dy>0 and (sA==SOL.HALF or
   sB==SOL.HALF) and
   y2%C==0 then return false end
 
 return true
end

function EntWouldFall(e,x)
 return EntCanMove(e,x,e.y+1)
end

-- check if player landed plane on tarmac
function CheckTarmac()
 local pr=RectXLate(
   CR.AVIATOR,Plr.x,Plr.y)
 local bottom=pr.y+pr.h+1
 local t1=LvlTileAtPt(pr.x,bottom)
 local t2=LvlTileAtPt(pr.x+pr.w,bottom)
 if t1==T.TARMAC and t2==T.TARMAC then
  -- landed
  Plr.plane=0
  SpawnParts(PFX.POP,Plr.x+4,Plr.y,14)
  -- TODO: more vfx, sfx
 end
end

function AdjustScroll()
 local dispx=Plr.x-Game.scr
 if dispx>SX_MAX then
  Game.scr=Plr.x-SX_MAX
 elseif dispx<SX_MIN then
  Game.scr=Plr.x-SX_MIN
 end
end

function AddToast(points,x,y)
 local rem=points%100
 if points>1000 or (rem~=50 and rem~=0) then
  return
 end
 local sp2=rem==50 and S.TINY_NUM_50 or
   S.TINY_NUM_00
 local sp1=points>=100 and 
   (S.TINY_NUM_R1-1+points//100) or 0
 Ins(Toasts,{
    x=Iif(points>=100,x-8,x-12),
    y=y,ttl=40,sp1=sp1,sp2=sp2})
end

-- tx,ty: position where to show toast
-- (optional)
function AddScore(points,tx,ty)
 local old=Plr.score
 Plr.score=Plr.score+points
 Plr.scoreMt=Game.t
 if (old//10000)<(Plr.score//10000) then
  Snd(SND.ONEUP)
  Plr.lives=Plr.lives+1
  -- TODO: vfx
 else
  Snd(SND.POINT)
 end
 if tx and ty then
  AddToast(points,tx,ty)
 end
end

function StartDying()
 SetMode(M.DYING)
 Snd(SND.DIE)
 Plr.dying=1 -- start die anim
 Plr.super=false
 Plr.firePwup=0
 Plr.plane=0
end

function EntAdd(newEid,newX,newY)
 local e={
  eid=newEid,
  x=newX,
  y=newY
 }
 Ins(Ents,e)
 EntInit(e)
 return e
end

function EntInit(e)
 -- check if we have an animation for it
 if ANIM[e.eid] then
  e.anim=ANIM[e.eid]
  e.sprite=e.anim[1]
 else
  -- default to static sprite image
  e.sprite=e.eid
 end
 -- whether ent sprite is flipped
 e.flipped=false
 -- collider rect
 e.coll=CR.DFLT
 -- solidity (defaults to not solid)
 e.sol=SOL.NOT
 -- EBT entry
 local ebte=EBT[e.eid]
 -- behaviors
 e.beh=ebte and ebte.beh or {}
 -- copy initial behavior data to entity
 for _,b in pairs(e.beh) do
  ShallowMerge(e,b.data)
 end
 -- overlay the entity-defined data.
 if ebte and ebte.data then
  ShallowMerge(e,ebte.data)
 end
 -- call the entity init funcs
 for _,b in pairs(e.beh) do
  if b.init then b.init(e) end
 end
end

function EntRepl(e,eid,data)
 e.dead=true
 local newE=EntAdd(eid,e.x,e.y)
 if data then
  ShallowMerge(newE,data)
 end
end

function EntHasBeh(e,soughtBeh)
 for _,b in pairs(e.beh) do
  if b==soughtBeh then return true end
 end
 return false
end

function EntAddBeh(e,beh)
 if EntHasBeh(e,beh) then return end
 -- note: can't mutate the original
 -- e.beh because it's a shared ref.
 e.beh=DeepCopy(e.beh)
 ShallowMerge(e,beh.data,true)
 Ins(e.beh,beh)
end

function UpdateEnts()
 -- iterate backwards so we can delete
 for i=#Ents,1,-1 do
  local e=Ents[i]
  UpdateEnt(e)
  if e.dead then
   -- delete
   Rem(Ents,i)
  end  
 end
end

function UpdateEnt(e)
 if not ALWAYS_UPDATED_EIDS[e.eid] and
   Abs(e.x-Plr.x)>ENT_MAX_DIST then
  -- too far, don't update
  return
 end
 -- update anim frame
 if e.anim then
  e.sprite=e.anim[1+(time()//128)%#e.anim]  
 end
 -- run update behaviors
 for _,b in pairs(e.beh) do
  if b.update then b.update(e) end
 end
end

function GetEntAt(x,y)
 for i=1,#Ents do
  local e=Ents[i]
  if e.x==x and e.y==y then return e end
 end
 return nil
end

-- detect collisions
function DetectColl()
 -- player rect
 local pr=RectXLate(GetPlrCr(),
  Plr.x,Plr.y)
  
 -- attack rect
 local ar=nil
 if ATK_SEQ[Plr.atk]==2 then
  -- player is attacking, so check if
  -- entity was hit by attack
  ar=RectXLate(CR.ATK,Plr.x,Plr.y)
  if Plr.flipped then
   ar.x=Plr.x+CR.ATK_FLIP_X
  end
 end

 for i=1,#Ents do
  local e=Ents[i]
  local er=RectXLate(e.coll,e.x,e.y)
  if RectIsct(pr,er) then
   -- collision between player and ent
   HandlePlrColl(e)
  elseif ar and RectIsct(ar,er) then
   -- ent hit by player attack
   HandleDamage(e,DMG.MELEE)
  end
 end
end

function CheckEndLvl()
 local t=LvlTileAtPt(
   Plr.x+C//2,Plr.y+C//2)
 if t==T.GATE_L or t==T.GATE_R or
    t==T.GATE_L2 or t==T.GATE_R2 then
  EndLvl()
 end
end

function EndLvl()
 Game.topLvl=Max(
   Game.topLvl,Game.lvlNo)
 SetMode(M.EOL)
end

function AdvanceLvl()
 -- save game if we should
 if Game.lvl.save then
  pmem(0,Max(pmem(0) or 0,
    Game.lvlNo+1))
 end
 if Game.lvlNo>=#LVL then
  -- end of game.
  SetMode(M.WIN)
 else
  -- go back to map.
  SetMode(M.WLD)
 end
end

-- handle collision w/ given ent
function HandlePlrColl(e)
 for _,b in pairs(e.beh) do
  if b.coll then b.coll(e) end
  if e.dead then break end
 end
end

function HandleDamage(e,dtype)
 for _,b in pairs(e.beh) do
  if b.dmg then b.dmg(e,dtype) end
  if e.dead then
   SpawnParts(PFX.POP,e.x+4,e.y+4,e.clr)
   Snd(SND.KILL)
   break
  end
 end
end

function HandlePlrHurt()
 if Plr.invuln>0 then return end
 if Plr.plane==0 and Plr.super then
  Snd(SND.HURT)
  Plr.super=false
  Plr.invuln=100
  Plr.drag=Iif(Plr.dx>=0,-10,10)
  Plr.jmp=0
 else
  StartDying()
 end
end

function Snd(spec)
 if not Sett.snd then return end
 sfx(spec.sfxid,spec.note,spec.dur,
   0,spec.vol or 15,spec.speed or 0)
end

function PlayMus(musid)
 if Sett.mus or musid==-1 then
  music(musid)
 end
end


---------------------------------------
-- PARTICLES
---------------------------------------

-- possible effects
PFX={
 POP={
  rad=4,
  count=15,
  speed=4,
  fall=true,
  ttl=15
 },
 FW={ -- fireworks
  rad=3,
  count=40,
  speed=1,
  fall=false,
  ttl=100
 }
}

-- fx=one of the effects in PFX
-- cx,cy=center, clr=the color
function SpawnParts(fx,cx,cy,clr)
 for i=1,fx.count do
  local r=Rnd01()*fx.rad
  local phi=Rnd01()*math.pi*2
  local part={
   x=cx+r*Cos(phi),
   y=cy+r*Sin(phi),
   vx=fx.speed*Cos(phi),
   vy=fx.speed*Sin(phi),
   fall=fx.fall,
   ttl=fx.ttl,
   age=0,
   clr=clr
  }
  Ins(Parts,part)
 end
end

function UpdateParts()
 -- iterate backwards so we can delete
 for i=#Parts,1,-1 do
  local p=Parts[i]
  p.age=p.age+1
  if p.age>=p.ttl then
   -- delete
   Rem(Parts,i)
  else
   p.x=p.x+p.vx
   p.y=p.y+p.vy+(p.fall and p.age//2 or 0)
  end
 end
end

function RendParts()
 for i,p in pairs(Parts) do
  pix(p.x-Game.scr,p.y,p.clr)
 end
end

---------------------------------------
-- WLD MAP
---------------------------------------
-- convert "World W-L" into index
function Wl(w,l) return (w-1)*3+l end

-- Init world (runs once at start of app).
function WldInit()
 for r=0,ROWS-1 do
  for c=0,COLS-1 do
   local t=WldFgTile(c,r)
   local lval=WldLvlVal(t)
   if t==T.META_A then
    -- player start pos
    Wld.plr.x0=c*C
    Wld.plr.y0=(r-1)*C
   elseif t==T.META_B then
    local mv=WldGetTag(c,r)
    -- savegame start pos
    Wld.spos[Wl(mv,1)]={
      x=c*C,y=(r-1)*C}
   elseif lval>0 then
    local mv=WldGetTag(c,r)
    -- It's a level tile.
    local poi={c=c,r=r,
     t=POI.LVL,lvl=Wl(mv,lval)}
    Ins(Wld.pois,poi)
   end
  end
 end
end

-- Looks around tc,tr for a numeric tag.
function WldGetTag(tc,tr)
 for r=tr-1,tr+1 do
  for c=tc-1,tc+1 do
   local mv=MetaVal(WldFgTile(c,r),0)
   if mv>0 then
    return mv
   end
  end
 end
 trace("No WLD tag @"..tc..","..tr)
 return 0
end

-- Returns the value (1, 2, 3) of a WLD
-- level tile.
function WldLvlVal(t)
 return Iif4(t==S.WLD.LVLF,1,
   t==S.WLD.LVL1,1,
   t==S.WLD.LVL2,2,
   t==S.WLD.LVL3,3,0)
end

function WldFgTile(c,r)
 return MapPageTile(WLD.FPAGE,c,r)
end

function WldBgTile(c,r)
 return MapPageTile(WLD.BPAGE,c,r)
end

function WldPoiAt(c,r)
 for i=1,#Wld.pois do
  local poi=Wld.pois[i]
  if poi.c==c and poi.r==r then
   return poi
  end
 end
 return nil
end

function WldHasRoadAt(c,r)
 local t=WldFgTile(c,r)
 for i=1,#S.WLD.ROADS do
  if S.WLD.ROADS[i]==t then
   return true
  end
 end
 return false
end

function WldUpdate()
 local p=Wld.plr  -- shorthand

 if p.dx~=0 or p.dy~=0 then
  -- Just move.
  p.x=p.x+p.dx
  p.y=p.y+p.dy
  if p.x%C==0 and p.y%C==0 then
   -- reached destination.
   p.ldx=p.dx
   p.ldy=p.dy
   p.dx=0
   p.dy=0
  end
  return
 end

 if btn(0) then WldTryMove(0,-1) end
 if btn(1) then WldTryMove(0,1) end
 if btn(2) then WldTryMove(-1,0) end
 if btn(3) then WldTryMove(1,0) end

 Wld.plr.flipped=Iif(
   Iif(Wld.plr.flipped,btn(3),btn(2)),
   not Wld.plr.flipped,
   Wld.plr.flipped) -- wtf

 if btnp(4) then
  local poi=WldPoiAt(p.x//C,p.y//C)
  if poi and poi.lvl>Game.topLvl then
   if poi.lvl==1 then
    SetMode(M.TUT)
   else
    StartLvl(poi.lvl)
   end
  end
 end
end

function WldTryMove(dx,dy)
 local p=Wld.plr  -- shorthand

 -- if we are in locked POI, we can only
 -- come back the way we came.
 local poi=WldPoiAt(p.x//C,p.y//C)
 if not Plr.dbgFly and poi and
   poi.lvl>Game.topLvl and
   (dx ~= -p.ldx or dy ~= -p.ldy) then
  return 
 end

 -- target row,col
 local tc=p.x//C+dx
 local tr=p.y//C+dy
 if WldHasRoadAt(tc,tr) or
    WldPoiAt(tc,tr) then
  -- Destination is a road or level.
  -- Move is valid.
  p.dx=dx
  p.dy=dy
  return
 end
end

function WldFgRemapFunc(t)
 return t<T.FIRST_META and t or 0 
end

function WldRend()
 if Game.m~=M.WLD then return end
 cls(2)
 rect(0,SCRH-8,SCRW,8,0)
 local fp=MapPageStart(WLD.FPAGE)
 local bp=MapPageStart(WLD.BPAGE)
 -- render map bg
 map(bp.c,bp.r,COLS,ROWS,0,0,0,1)
 -- render map fg, excluding markers
 map(fp.c,fp.r,COLS,ROWS,0,0,0,1,
   WldFgRemapFunc)

 -- render the "off" version of level
 -- tiles on top of cleared levels.
 for _,poi in pairs(Wld.pois) do
  if poi.lvl<=Game.topLvl then
   spr(S.WLD.LVLC,poi.c*C,poi.r*C,0)
  end
 end

 print("SELECT LEVEL TO PLAY",
   70,10)
 print("= MOVE",34,SCRH-6)
 print("= ENTER LEVEL",98,SCRH-6)

 RendSpotFx(Wld.plr.x//C,
   Wld.plr.y//C,Game.t)
 if 0==(Game.t//16)%2 then
  rectb(Wld.plr.x-3,Wld.plr.y-3,13,13,15)
 end
 spr(S.PLR.STAND,Wld.plr.x,Wld.plr.y,0,
   1,Wld.plr.flipped and 1 or 0)
 RendHud()
end

---------------------------------------
-- LEVEL UNPACKING
---------------------------------------
-- unpack modes
UMODE={
 GAME=0, -- unpack for gameplay
 EDIT=1, -- unpack for editing
}

function MapPageStart(pageNo)
 return {c=(pageNo%8)*30,r=(pageNo//8)*17} 
end

function MapPageTile(pageNo,c,r,newVal)
 local pstart=MapPageStart(pageNo)
 if newVal then
  mset(c+pstart.c,r+pstart.r,newVal)
 end
 return mget(c+pstart.c,r+pstart.r)
end

-- Unpacked level is written to top 8
-- map pages (cells 0,0-239,16).
function UnpackLvl(lvlNo,mode)
 local lvl=LVL[lvlNo]
 local start=MapPageStart(lvl.pkstart)
 local offc=start.c
 local offr=start.r
 local len=lvl.pklen*30
 local endc=FindLvlEndCol(offc,offr,len)

 MapClear(0,0,LVL_LEN,ROWS)
 
 -- next output col
 local outc=0
 
 -- for each col in packed map
 for c=offc,endc do
  local cmd=mget(c,offr)
  local copies=MetaVal(cmd,1)
  -- create that many copies of this col
  for i=1,copies do
   CreateCol(c,outc,offr,mode==UMODE.GAME)
   -- advance output col
   outc=outc+1
   if outc>=LVL_LEN then
    trace("ERROR: level too long: "..lvlNo)
    return
   end
  end
 end

 -- if in gameplay, expand patterns and
 -- remove special markers
 -- (first META_A is player start pos)
 if mode==UMODE.GAME then
  for c=0,LVL_LEN-1 do
   for r=0,ROWS-1 do
    local t=mget(c,r)
    local tpat=TPAT[t]
    if tpat then ExpandTpat(tpat,c,r) end
    if Plr.x==0 and Plr.y==0 and
      t==T.META_A then
     -- player start position.
     Plr.x=c*C
     Plr.y=r*C
    end
    if t>=T.FIRST_META then
     mset(c,r,0)
    end
   end
  end
  if Plr.x==0 and Plr.y==0 then
   trace("*** start pos UNSET L"..lvlNo)
  end
  FillWater()
  SetUpTanims()
 end
end

-- expand tile pattern at c,r
function ExpandTpat(tpat,c,r)
 local s=mget(c,r)
 for i=0,tpat.w-1 do
  for j=0,tpat.h-1 do
   mset(c+i,r+j,s+j*16+i)
  end
 end
end

-- Sets up tile animations.
function SetUpTanims()
 for c=0,LVL_LEN-1 do
  for r=0,ROWS-1 do
   local t=mget(c,r)
   if TANIM[t] then
    TanimAdd(c,r)
   end
  end
 end
end

function FindLvlEndCol(c0,r0,len)
 -- iterate backwards until we find a
 -- non-empty col.
 for c=c0+len-1,c0,-1 do
  for r=r0,r0+ROWS-1 do
   if mget(c,r)>0 then
    -- rightmost non empty col
    return c
   end
  end
 end
 return c0
end

function FillWater()
 -- We fill downward from surface tiles,
 -- Downward AND upward from water tiles.
 local surfs={}  -- surface tiles
 local waters={} -- water tiles
 for c=LVL_LEN-1,0,-1 do
  for r=ROWS-1,0,-1 do
   if mget(c,r)==T.SURF then
    Ins(surfs,{c=c,r=r})
   elseif mget(c,r)==T.WATER then
    Ins(waters,{c=c,r=r})
   end
  end
 end
 
 for i=1,#surfs do
  local s=surfs[i]
  -- fill water below this tile
  FillWaterAt(s.c,s.r,1)
 end
 for i=1,#waters do
  local s=waters[i]
  -- fill water above AND below this tile
  FillWaterAt(s.c,s.r,-1)
  FillWaterAt(s.c,s.r,1)
 end
end

-- Fill water starting (but not including)
-- given tile, in the given direction
-- (1:down, -1:up)
function FillWaterAt(c,r0,dir)
 local from=r0+dir
 local to=Iif(dir>0,ROWS-1,0)
 for r=from,to,dir do
  if mget(c,r)==T.EMPTY then
   mset(c,r,T.WATER)
  else
   return
  end
 end
end

function TanimAdd(c,r)
 if Tanims[c] then
  Ins(Tanims[c],r)
 else
  Tanims[c]={r}
 end
end

-- pack lvl from 0,0-239,16 to the packed
-- level area of the indicated level
function PackLvl(lvlNo)
 local lvl=LVL[lvlNo]
 local start=MapPageStart(lvl.pkstart)
 local outc=start.c
 local outr=start.r
 local len=lvl.pklen*30
 
 local endc=FindLvlEndCol(0,0,LVL_LEN)
  
 -- pack
 local reps=0
 MapClear(outc,outr,len,ROWS)
 for c=0,endc do
  if c>0 and MapColsEqual(c,c-1,0) and
    reps<12 then
   -- increment repeat marker on prev col
   local m=mget(outc-1,outr)  
   m=Iif(m==0,T.META_NUM_0+2,m+1)
   mset(outc-1,outr,m)
   reps=reps+1
  else
   reps=1
   -- copy col to packed level
   MapCopy(c,0,outc,outr,1,ROWS)
   outc=outc+1
   if outc>=start.c+len then
    trace("Capacity exceeded.")
    return false
   end
  end
 end
 trace("packed "..(endc+1).." -> "..
  (outc+1-start.c))
 return true
end

-- Create map col (dstc,0)-(dstc,ROWS-1)
-- from source col located at
-- (srcc,offr)-(srcc,offr+ROWS-1).
-- if ie, instantiates entities.
function CreateCol(srcc,dstc,offr,ie)
 -- copy entire column first
 MapCopy(srcc,offr,dstc,0,1,ROWS)
 mset(dstc,0,T.EMPTY) -- top cell is empty
 if not ie then return end
 -- instantiate entities
 for r=1,ROWS-1 do
  local t=mget(dstc,r)
  if t>=T.FIRST_ENT and EBT[t] then
   -- entity tile: create entity
   mset(dstc,r,T.EMPTY)
   EntAdd(t,dstc*C,r*C)
  end
 end
end

---------------------------------------
-- RENDERING
---------------------------------------

function Rend()
 RendBg()
 if Game.snow then RendSnow() end
 RendMap()
 RendTanims()
 RendEnts()
 RendToasts()
 if Game.m==M.EOL then RendScrim() end
 RendPlr()
 RendParts()
 RendHud()
 RendSign()
end

function RendBg()
 local END_R=ROWS
 cls(Game.lvl.bg)
 local offset=Game.scr//2+50
 -- If i is a col# of mountains (starting
 -- at index 1), then its screen pos
 -- sx=(i-1)*C-off
 -- Solving for i, i=1+(sx+off)/C
 -- so at the left of screen, sx=0, we
 -- have i=1+off/C
 local startI=Max(1,1+offset//C)
 local endI=Min(
   startI+COLS,#Game.bgmnt)
 for i=startI,endI do
  local sx=(i-1)*C-offset
  local part=Game.bgmnt[i]
  for r=part.y,END_R do
   local spid=Iif(r==part.y,
    S.BGMNT.DIAG,S.BGMNT.FULL)
   spr(spid,(i-1)*C-offset,r*C,0,1,
    Iif(part.dy>0,1,0))
  end
 end
end

function RendSnow()
 local dx=-Game.scr
 local dy=Game.t//2
 for _,p in pairs(Game.snow) do
  local sx=((p.x+dx)%SCRW+SCRW)%SCRW
  local sy=((p.y+dy)%SCRH+SCRH)%SCRH
  pix(sx,sy,Game.lvl.snow.clr)
 end
end

function RendToasts()
 for i=#Toasts,1,-1 do
  local t=Toasts[i]
  t.ttl=t.ttl-1
  if t.ttl<=0 then
   Toasts[i]=Toasts[#Toasts]
   Rem(Toasts)
  else
   t.y=t.y-1
   spr(t.sp1,t.x-Game.scr,t.y,0)
   spr(t.sp2,t.x-Game.scr+C,t.y,0)
  end
 end
end

function RendMap()
 -- col c is rendered at
 --   sx=-Game.scr+c*C
 -- Setting sx=0 and solving for c
 --   c=Game.scr//C
 local c=Game.scr//C
 local sx=-Game.scr+c*C
 local w=Min(COLS+1,LVL_LEN-c)
 if c<0 then
  sx=sx+C*(-c)
  c=0
 end
 map(
  -- col,row,w,h
  c,0,w,ROWS,
  -- sx,sy,colorkey,scale
  sx,0,0,1)
end

function RendPlr()
 local spid
 local walking=false

 if Plr.plane>0 then
  RendPlane()
  return
 end

 if Plr.dying>0 then
  spid=S.PLR.DIE
 elseif Plr.atk>0 then
  spid=
    ATK_SEQ[Plr.atk]==1 and S.PLR.SWING
    or S.PLR.HIT
 elseif Plr.grounded then
  if btn(2) or btn(3) then
   spid=S.PLR.WALK1+time()%2
   walking=true
  else
   spid=S.PLR.STAND
  end
 elseif Plr.swim then
  spid=S.PLR.SWIM1+(Game.t//4)%2
 else
  spid=S.PLR.JUMP
 end 
 
 local sx=Plr.x-Game.scr
 local sy=Plr.y
 local flip=Plr.flipped and 1 or 0
 
 -- apply dying animation
 if spid==S.PLR.DIE then
  if Plr.dying<=#DIE_SEQ then
   sx=sx+DIE_SEQ[Plr.dying][1]
   sy=sy+DIE_SEQ[Plr.dying][2]
  else
   sx=-1000
   sy=-1000
  end
 end

 -- if invulnerable, blink
 if Plr.invuln>0 and
   0==(Game.t//4)%2 then return end
 
 spr(spid,sx,sy,0,1,flip)
 
 -- extra sprite for attack states
 if spid==S.PLR.SWING then
  spr(S.PLR.SWING_C,sx,sy-C,0,1,flip)
 elseif spid==S.PLR.HIT then
  spr(S.PLR.HIT_C,
    sx+(Plr.flipped and -C or C),
    sy,0,1,flip)
 end

 -- draw super panda overlay if player
 -- has the super panda powerup
 if Plr.super then
  local osp=Iif3(Plr.atk>0,S.PLR.SUPER_F,
   Plr.swim and not Plr.grounded,
   S.PLR.SUPER_S,
   walking,S.PLR.SUPER_P,S.PLR.SUPER_F)
  spr(osp,sx,Plr.y,0,1,flip)
 end

 -- draw overlays (blinking bamboo and
 -- yellow body) if powerup
 if spid~=S.PLR.SWING and Plr.firePwup>0
   and (time()//128)%2==0 then
  spr(S.PLR.FIRE_BAMBOO,sx,Plr.y,0,1,flip)
 end
 if Plr.firePwup>100 or 
    1==(Plr.firePwup//16)%2 then
  local osp=Iif3(Plr.atk>0,S.PLR.FIRE_F,
   Plr.swim and not Plr.grounded,
   S.PLR.FIRE_S,
   walking,S.PLR.FIRE_P,S.PLR.FIRE_F)
  spr(osp,sx,Plr.y,0,1,flip)
 end

 -- if just respawned, highlight player
 if Game.m==M.PLAY and Plr.dying==0 and
   Plr.respX>=0 and Game.t<100 and
   (Game.t//8)%2==0 then
  rectb(Plr.x-Game.scr-2,Plr.y-2,
    C+4,C+4,15)
 end
end

function RendPlane()
 local ybias=(Game.t//8)%2==0 and 1 or 0

 local sx=Plr.x-Game.scr
 spr(S.AVIATOR,
   sx-C,Plr.y+ybias,0,1,0,0,3,2)
 local spid=(Game.t//4)%2==0 and
   S.AVIATOR_PROP_1 or S.AVIATOR_PROP_2
 spr(spid,sx+C,
   Plr.y+ybias+4,0)
end

function RendHud()
 rect(0,0,SCRW,C,3)

 if Plr.scoreDisp.value~=Plr.score then
  Plr.scoreDisp.value=Plr.score
  Plr.scoreDisp.text=Lpad(Plr.score,6)
 end

 local clr=15

 print(Plr.scoreDisp.text,192,1,clr,true)
 print((Game.m==M.WLD and
   "WORLD MAP" or
   ("LEVEL "..Game.lvl.name)),95,1,7)
 spr(S.PLR.STAND,5,0,0)
 print("x "..Plr.lives,16,1)

 if Plr.plane>0 then
  local barw=PLANE.FUEL_BAR_W
  local lx=120-barw//2
  local y=8
  local clr=(Plr.plane<800 and
    (Game.t//16)%2==0) and 6 or 14
  local clrLo=(clr==14 and 4 or clr)
  print("E",lx-7,y,clr)
  print("F",lx+barw+1,y,14)
  rectb(lx,y,barw,6,clrLo)
  local bw=Plr.plane*
    (PLANE.FUEL_BAR_W-2)//PLANE.MAX_FUEL
  rect(lx+1,y+1,Max(bw,1),4,clr)
  pix(lx+barw//4,y+4,clrLo)
  pix(lx+barw//2,y+4,clrLo)
  pix(lx+barw//2,y+3,clrLo)
  pix(lx+3*barw//4,y+4,clrLo)
 end
end

function RendEnts()
 for i=1,#Ents do
  local e=Ents[i]
  local sx=e.x-Game.scr
  if sx>-C and sx<SCRW then
   spr(e.sprite,sx,e.y,0,1,
     e.flipped and 1 or 0)
  end
 end
end

function RendScrim(sp)
 sp=sp or Iif3(Game.t>45,0,
  Game.t>30,S.SCRIM+2,
  Game.t>15,S.SCRIM+1,
  S.SCRIM)
 for r=0,ROWS-1 do
  for c=0,COLS-1 do
   spr(sp,c*C,r*C,15)
  end
 end
end

-- Render spotlight effect.
-- fc,fr: cell at center of effect
-- t: clock (ticks)
function RendSpotFx(fc,fr,t)
 local rad=Max(0,t//2-2) -- radius
 if rad>COLS then return end
 for r=0,ROWS-1 do
  for c=0,COLS-1 do
   local d=Max(Abs(fc-c),
     Abs(fr-r))
   local sa=d-rad  -- scrim amount
   local spid=Iif2(sa<=0,-1,sa<=3,
     S.SCRIM+sa-1,0)
   if spid>=0 then
    spr(spid,c*C,r*C,15)
   end
  end
 end
end

function RendSign()
 if 0==Plr.signC then return end
 local w=Plr.signC*20
 local h=Plr.signC*3
 local x=SCRW//2-w//2
 local y=SCRH//2-h//2-20
 local s=SIGN_MSGS[Plr.signMsg]
 rect(x,y,w,h,15)
 if Plr.signC==SIGN_C_MAX then
  print(s.l1,x+6,y+8,0)
  print(s.l2,x+6,y+8+C,0)
 end
end

-- Rend tile animations
function RendTanims()
 local c0=Max(0,Game.scr//C)
 local cf=c0+COLS
 for c=c0,cf do
  local anims=Tanims[c]
  if anims then
   for i=1,#anims do
    local r=anims[i]
    local tanim=TANIM[mget(c,r)]
    if tanim then
     local spid=tanim[
       1+(Game.t//16)%#tanim]
     spr(spid,c*C-Game.scr,r*C)
    end
   end
  end
 end
end

--------------------------------------
-- ENTITY BEHAVIORS
---------------------------------------

-- move hit modes: what happens when
-- entity hits something solid.
MOVE_HIT={
 NONE=0,
 STOP=1,
 BOUNCE=2,
 DIE=3,
}

-- aim mode
AIM={
 NONE=0,   -- just shoot in natural
           -- direction of projectile
 HORIZ=1,  -- adjust horizontal vel to
           -- go towards player
 VERT=2,   -- adjust vertical vel to go
           -- towards player
 FULL=3,   -- adjust horiz/vert to aim
           -- at player
}

-- moves horizontally
-- moveDen: every how many ticks to move
-- moveDx: how much to move
-- moveHitMode: what to do on wall hit
-- noFall: if true, flip instead of falling
function BehMove(e)
 if e.moveT>0 then e.moveT=e.moveT-1 end
 if e.moveT==0 then return end
 if e.moveWaitPlr>0 then
  if Abs(Plr.x-e.x)>e.moveWaitPlr then
   return
  else e.moveWaitPlr=0 end
 end
 e.moveNum=e.moveNum+1
 if e.moveNum<e.moveDen then return end
 e.moveNum=0
 
 if e.noFall and
   EntWouldFall(e,e.x+e.moveDx) then
  -- flip rather than fall
  e.moveDx=-e.moveDx
  e.flipped=e.moveDx>0
 elseif e.moveHitMode==MOVE_HIT.NONE or
    EntCanMove(e,e.x+e.moveDx,e.y) then
  e.x=e.x+(e.moveDx or 0)
  e.y=e.y+(e.moveDy or 0)
 elseif e.moveHitMode==MOVE_HIT.BOUNCE
   then
  e.moveDx=-(e.moveDx or 0)
  e.flipped=e.moveDx>0
 elseif e.moveHitMode==MOVE_HIT.DIE then
  e.dead=true
 end
end

-- Moves up/down.
-- e.yamp: amplitude
function BehUpDownInit(e)
 e.maxy=e.y
 e.miny=e.maxy-e.yamp
end

function BehUpDown(e)
 e.ynum=e.ynum+1
 if e.ynum<e.yden then return end
 e.ynum=0
 e.y=e.y+e.dy
 if e.y<=e.miny then e.dy=1 end
 if e.y>=e.maxy then e.dy=-1 end
end

function BehFacePlr(e)
 e.flipped=Plr.x>e.x
 if e.moveDx then
  e.moveDx=Abs(e.moveDx)*
   (e.flipped and 1 or -1)
 end
end

-- automatically flips movement
-- flipDen: every how many ticks to flip
function BehFlip(e)
 e.flipNum=e.flipNum+1
 if e.flipNum<e.flipDen then return end
 e.flipNum=0
 e.flipped=not e.flipped
 e.moveDx=(e.moveDx and -e.moveDx or 0)
end

function BehJump(e)
 if e.jmp==0 then
  e.jmpNum=e.jmpNum+1
  if e.jmpNum<e.jmpDen or
    not e.grounded then return end
  e.jmpNum=0
  e.jmp=1
 else
  -- continue jump  
  e.jmp=e.jmp+1
  if e.jmp>#JUMP_DY then
   -- end jump
   e.jmp=0
  else
   local dy=JUMP_DY[e.jmp]
   if EntCanMove(e,e.x,e.y+dy) then
    e.y=e.y+dy
   else
    e.jmp=0
   end
  end
 end
end

function BehFall(e)
 e.grounded=not EntCanMove(e,e.x,e.y+1)
 if not e.grounded and e.jmp==0 then
  e.y=e.y+1
 end
end

function BehTakeDmg(e,dtype)
 if not ArrayContains(e.dtypes,dtype) then
  return
 end
 e.hp=e.hp-1
 if e.hp>0 then return end
 e.dead=true
 -- drop loot?
 local roll=Rnd(0,99)
 -- give bonus probability to starting
 -- levels (decrease roll value)
 roll=Max(Iif2(Game.lvlNo<2,roll-50,
   Game.lvlNo<4,roll-25,roll),0)
 if roll<e.lootp then
  local i=Rnd(1,#e.loot)
  i=Min(Max(i,1),#e.loot)
  local l=EntAdd(e.loot[i],e.x,e.y-4)
  EntAddBeh(l,BE.MOVE)
  ShallowMerge(l,{moveDy=-1,moveDx=0,
    moveDen=1,moveT=8})
 end
end

function BehPoints(e)
 e.dead=true
 AddScore(e.value or 50,e.x+4,e.y-4)
end

function BehHurt(e)
 HandlePlrHurt()
end

function BehLiftInit(e)
 -- lift top and bottom y:
 local a=C*FetchTile(
   T.META_A,e.x//C)
 local b=C*FetchTile(
   T.META_B,e.x//C)
 if a>b then
  e.boty=a
  e.topy=b
  e.dir=1
 else
  e.topy=a
  e.boty=b
  e.dir=-1
 end 
 e.coll=CR.FULL
end

function BehLift(e)
 e.liftNum=e.liftNum+1
 if e.liftNum<e.liftDen then return end
 e.liftNum=0
 e.y=e.y+e.dir
 if e.dir>0 and e.y>e.boty or 
   e.dir<0 and e.y<e.topy then
  e.dir=-e.dir
 end
end

function BehLiftColl(e)
 -- Lift hit player. Just nudge the player
 Plr.nudgeY=Iif(e.y>Plr.y,-1,1)
end

function BehShootInit(e)
 e.shootNum=Rnd(0,e.shootDen-1)
end

function BehShoot(e)
 e.shootNum=e.shootNum+1
 if e.shootNum<30 then
  e.sprite=e.shootSpr or e.sprite
 end
 if e.shootNum<e.shootDen then return end
 e.shootNum=0
 local shot=EntAdd(
   e.shootEid or EID.FIREBALL,e.x,e.y)
 e.sprite=e.shootSpr or e.sprite
 shot.moveDx=
   Iif(shot.moveDx==nil,0,shot.moveDx)
 shot.moveDy=
   Iif(shot.moveDy==nil,0,shot.moveDy)

 if e.aim==AIM.HORIZ then
  shot.moveDx=(Plr.x>e.x and 1 or -1)*
   Abs(shot.moveDx)
 elseif e.aim==AIM.VERT then
  shot.moveDy=(Plr.y>e.y and 1 or -1)*
   Abs(shot.moveDy)
 elseif e.aim==AIM.FULL then
  local tx=Plr.x-shot.x
  local ty=Plr.y-shot.y
  local mag=math.sqrt(tx*tx+ty*ty)
  local spd=math.sqrt(
    shot.moveDx*shot.moveDx+
    shot.moveDy*shot.moveDy)
  shot.moveDx=math.floor(0.5+tx*spd/mag)
  shot.moveDy=math.floor(0.5+ty*spd/mag)
  if shot.moveDx==0 and shot.moveDy==0 then
   shot.moveDx=-1
  end
 end
end

function BehCrumble(e)
 if not e.crumbling then
  -- check if player on tile
  if Plr.x<e.x-8 then return end
  if Plr.x>e.x+8 then return end
  -- check if player is standing on it
  local pr=RectXLate(
    GetPlrCr(),Plr.x,Plr.y)
  local er=RectXLate(e.coll,e.x,e.y-1)
  e.crumbling=RectIsct(pr,er)
 end

 if e.crumbling then
  -- count down to destruction
  e.cd=e.cd-1
  e.sprite=Iif(e.cd>66,S.CRUMBLE,
    Iif(e.cd>33,S.CRUMBLE_2,S.CRUMBLE_3))
  if e.cd<0 then e.dead=true end
 end
end

function BehTtl(e)
 e.ttl=e.ttl-1
 if e.ttl <= 0 then e.dead = true end
end

function BehDmgEnemy(e)
 local fr=RectXLate(FIRE.COLL,e.x,e.y)
 for i=1,#Ents do
  local ent=Ents[i]
  local er=RectXLate(ent.coll,ent.x,ent.y)
  if e~=ent and RectIsct(fr,er) and
    EntHasBeh(ent,BE.VULN) then
   -- ent hit by player fire
   HandleDamage(ent,Plr.plane>0 and
     DMG.PLANE_FIRE or DMG.FIRE)
   e.dead=true
  end
 end
end

function BehGrantFirePwupColl(e)
 Plr.firePwup=FIRE.DUR
 e.dead=true
 Snd(SND.PWUP)
end

function BehGrantSuperPwupColl(e)
 Plr.super=true
 e.dead=true
 Snd(SND.PWUP)
end

function BehReplace(e)
 local d=Abs(e.x-Plr.x)
 if d<e.replDist then
  EntRepl(e,e.replEid,e.replData)
 end
end

function BehChestInit(e)
 -- ent on top of chest is the contents
 local etop=GetEntAt(e.x,e.y-C)
 if etop then
  e.cont=etop.eid
  etop.dead=true
 else
  e.cont=S.FOOD.LEAF
 end
 -- check multiplier
 e.mul=MetaVal(
  mget(e.x//C,e.y//C-2),1)
 e.open=false
end

function BehChestDmg(e)
 if e.open then return end
 SpawnParts(PFX.POP,e.x+4,e.y+4,14)
 Snd(SND.OPEN);
 e.anim=nil
 e.sprite=S.CHEST_OPEN
 e.open=true
 local by=e.y-C
 local ty=e.y-2*C
 local lx=e.x-C
 local cx=e.x
 local rx=e.x+C
 local c=e.cont
 EntAdd(c,cx,by)
 if e.mul>1 then EntAdd(c,cx,ty) end
 if e.mul>2 then EntAdd(c,lx,by) end
 if e.mul>3 then EntAdd(c,rx,by) end
 if e.mul>4 then EntAdd(c,lx,ty) end
 if e.mul>5 then EntAdd(c,rx,ty) end
end

function BehTplafInit(e)
 e.phase=MetaVal(FetchEntTag(e),0)
end

function BehTplaf(e)
 local UNIT=40     -- in ticks
 local PHASE_LEN=3 -- in units
 local uclk=e.phase+Game.t//UNIT
 local open=((uclk//PHASE_LEN)%2==0)
 local tclk=e.phase*UNIT+Game.t
 e.sprite=Iif2(
  (tclk%(UNIT*PHASE_LEN)<=6),
  S.TPLAF_HALF,open,S.TPLAF,S.TPLAF_OFF)
 e.sol=Iif(open,SOL.HALF,SOL.NOT)
end

function BehDashInit(e)
 assert(EntHasBeh(e,BE.MOVE))
 e.origAnim=e.anim
 e.origMoveDen=e.moveDen
end

function BehDash(e)
 local dashing=e.cdd<e.ddur
 e.cdd=(e.cdd+1)%e.cdur
 if dashing then
  e.anim=e.dashAnim or e.origAnim
  e.moveDen=e.origMoveDen
 else
  e.anim=e.origAnim
  e.moveDen=99999  -- don't move
 end
end

function BehSignInit(e)
 e.msg=MetaVal(FetchEntTag(e),0)
end

function BehSignColl(e)
 Plr.signMsg=e.msg
 -- if starting to read sign, lock player
 -- for a short while
 if Plr.signC==0 then
  Plr.locked=100
 end
 -- increase cycle counter by 2 because
 -- it gets decreased by 1 every frame
 Plr.signC=Min(Plr.signC+2,
   SIGN_C_MAX)
end

function BehOneUp(e)
 e.dead=true
 Plr.lives=Plr.lives+1
 Snd(SND.ONEUP)
end

function BehFlag(e)
 local rx=e.x+C
 if Plr.respX<rx then
  Plr.respX=rx
  Plr.respY=e.y
 end
 Snd(SND.PWUP)
 EntRepl(e,EID.FLAG_T)
end

function BehReplOnGnd(e)
 if e.grounded then
  EntRepl(e,e.replEid,e.replData)
 end
end

function BehPop(e)
 e.dead=true
 SpawnParts(PFX.POP,e.x+4,e.y+4,e.clr)
end

function BehBoardPlane(e)
 e.dead=true
 Plr.plane=PLANE.START_FUEL
 Plr.y=e.y-3*C
 Snd(SND.PLANE)
end

function BehFuel(e)
 e.dead=true
 Plr.plane=Plr.plane+PLANE.FUEL_INC
 Snd(SND.PWUP)
end

---------------------------------------
-- ENTITY BEHAVIORS
---------------------------------------
BE={
 MOVE={
  data={
   -- move denominator (moves every
   -- this many frames)
   moveDen=5,
   moveNum=0, -- numerator, counts up
   -- 1=moving right, -1=moving left
   moveDx=-1,
   moveDy=0,
   moveHitMode=MOVE_HIT.BOUNCE,
   -- if >0, waits until player is less
   -- than this dist away to start motion
   moveWaitPlr=0,
   -- if >=0, how many ticks to move
   -- for (after that, stop).
   moveT=-1,
  },
  update=BehMove,
 },
 FALL={
  data={grounded=false,jmp=0},
  update=BehFall,
 },
 FLIP={
  data={flipNum=0,flipDen=20},
  update=BehFlip,
 },
 FACEPLR={update=BehFacePlr},
 JUMP={
  data={jmp=0,jmpNum=0,jmpDen=50},
  update=BehJump,
 },
 VULN={ -- can be damaged by player
  data={hp=1,
   -- damage types that can hurt this.
   dtypes={DMG.MELEE,DMG.FIRE,DMG.PLANE_FIRE},
   -- loot drop probability (0-100)
   lootp=0,
   -- possible loot to drop (EIDs)
   loot={EID.FOOD.A},
  },
  dmg=BehTakeDmg,
 },
 SHOOT={
  data={shootNum=0,shootDen=100,
   aim=AIM.NONE},
  init=BehShootInit,
  update=BehShoot,
 },
 UPDOWN={
  -- yamp is amplitude of y movement
  data={yamp=16,dy=-1,yden=3,ynum=0},
  init=BehUpDownInit,
  update=BehUpDown,
 },
 POINTS={
  data={value=50},
  coll=BehPoints,
 },
 HURT={ -- can hurt player
  coll=BehHurt
 },
 LIFT={
  data={liftNum=0,liftDen=3},
  init=BehLiftInit,
  update=BehLift,
  coll=BehLiftColl,
 },
 CRUMBLE={
  -- cd: countdown to crumble
  data={cd=50,coll=CR.FULL,crumbling=false},
  update=BehCrumble,
 },
 TTL={  -- time to live (auto destroy)
  data={ttl=150},
  update=BehTtl,
 },
 DMG_ENEMY={ -- damage enemies
  update=BehDmgEnemy,
 },
 GRANT_FIRE={
  coll=BehGrantFirePwupColl,
 },
 REPLACE={
 -- replaces by another ent when plr near
 -- replDist: distance from player
 -- replEid: EID to replace by
  data={replDist=50,replEid=EID.LEAF},
  update=BehReplace,
 },
 CHEST={
  init=BehChestInit,
  dmg=BehChestDmg,
 },
 TPLAF={
  init=BehTplafInit,
  update=BehTplaf,
 },
 DASH={
  data={
   ddur=20, -- dash duration
   cdur=60, -- full cycle duration
   cdd=0, -- cycle counter
  },
  init=BehDashInit,
  update=BehDash,
 },
 GRANT_SUPER={
  coll=BehGrantSuperPwupColl,
 },
 SIGN={
  init=BehSignInit,
  coll=BehSignColl
 },
 ONEUP={coll=BehOneUp},
 FLAG={coll=BehFlag},
 REPL_ON_GND={
  -- replace EID when grounded
  -- replData -- extra data to add to
  data={replEid=EID.LEAF},
  update=BehReplOnGnd
 },
 POP={update=BehPop},
 PLANE={coll=BehBoardPlane},
 FUEL={coll=BehFuel},
}

---------------------------------------
-- ENTITY BEHAVIOR TABLE
---------------------------------------
EBT={
 [EID.EN.SLIME]={
  data={
    hp=1,moveDen=3,clr=11,noFall=true,
    lootp=20,loot={EID.FOOD.A},
  },
  beh={BE.MOVE,BE.FALL,BE.VULN,BE.HURT},
 },

 [EID.EN.HSLIME]={
  data={replDist=50,replEid=EID.EN.SLIME},
  beh={BE.REPLACE},
 },

 [EID.EN.A]={
  data={
    hp=1,moveDen=5,clr=14,flipDen=120,
    lootp=30,
    loot={EID.FOOD.A,EID.FOOD.B},
  },
  beh={BE.MOVE,BE.JUMP,BE.FALL,BE.VULN,
   BE.HURT,BE.FLIP},
 },
 
 [EID.EN.B]={
  data={
    hp=1,moveDen=5,clr=13,
    lootp=30,
    loot={EID.FOOD.A,EID.FOOD.B,
      EID.FOOD.C},
  },
  beh={BE.JUMP,BE.FALL,BE.VULN,BE.HURT,
    BE.FACEPLR},
 },

 [EID.EN.DEMON]={
  data={hp=1,moveDen=5,clr=7,
   aim=AIM.HORIZ,
   shootEid=EID.FIREBALL,
   shootSpr=S.EN.DEMON_THROW,
   lootp=60,
   loot={EID.FOOD.C,EID.FOOD.D}},
  beh={BE.JUMP,BE.FALL,BE.SHOOT,
   BE.HURT,BE.FACEPLR,BE.VULN},
 },

 [EID.EN.SDEMON]={
  data={hp=1,moveDen=5,clr=7,
   flipDen=50,
   shootEid=EID.SNOWBALL,
   shootSpr=S.EN.SDEMON_THROW,
   aim=AIM.HORIZ,
   lootp=75,
   loot={EID.FOOD.C,EID.FOOD.D}},
  beh={BE.JUMP,BE.FALL,BE.SHOOT,
   BE.MOVE,BE.FLIP,BE.VULN,BE.HURT},
 },

 [EID.EN.PDEMON]={
  data={hp=1,clr=11,flipDen=50,
   shootEid=EID.PLASMA,
   shootSpr=S.EN.PDEMON_THROW,
   aim=AIM.FULL,
   lootp=80,
   loot={EID.FOOD.D}},
  beh={BE.JUMP,BE.FALL,BE.SHOOT,
   BE.FLIP,BE.VULN,BE.HURT},
 },

 [EID.EN.BAT]={
  data={hp=1,moveDen=2,clr=9,flipDen=60,
    lootp=40,
    loot={EID.FOOD.A,EID.FOOD.B}},
  beh={BE.MOVE,BE.FLIP,BE.VULN,BE.HURT},
 },
 
 [EID.EN.FISH]={
  data={
    hp=1,moveDen=3,clr=9,flipDen=120,
    lootp=40,
    loot={EID.FOOD.A,EID.FOOD.B},
  },
  beh={BE.MOVE,BE.FLIP,BE.VULN,
   BE.HURT},
 },
  
 [EID.EN.FISH2]={
  data={hp=1,clr=12,moveDen=1,
   lootp=60,
   loot={EID.FOOD.B,EID.FOOD.C}},
  beh={BE.MOVE,BE.DASH,BE.VULN,BE.HURT},
 },

 [EID.FIREBALL]={
  data={hp=1,moveDen=2,clr=7,
    coll=CR.BALL,
    moveHitMode=MOVE_HIT.DIE},
  beh={BE.MOVE,BE.HURT,BE.TTL},
 },

 [EID.PLASMA]={
  data={hp=1,moveDen=2,clr=7,
    moveDx=2,
    coll=CR.BALL,
    moveHitMode=MOVE_HIT.NONE},
  beh={BE.MOVE,BE.HURT,BE.TTL},
 },

 [EID.SNOWBALL]={
  data={hp=1,moveDen=1,clr=15,
    coll=CR.BALL,
    moveHitMode=MOVE_HIT.DIE},
  beh={BE.MOVE,BE.FALL,BE.VULN,BE.HURT},
 },

 [EID.LIFT]={
  data={sol=SOL.FULL},
  beh={BE.LIFT},
 },

 [EID.CRUMBLE]={
  data={
   sol=SOL.FULL,clr=14,
   -- only take melee and plane fire dmg
   dtypes={DMG.MELEE,DMG.PLANE_FIRE},
  },
  beh={BE.CRUMBLE,BE.VULN},
 },

 [EID.PFIRE]={
  data={
   moveDx=1,moveDen=1,ttl=80,
   moveHitMode=MOVE_HIT.DIE,
   coll=FIRE.COLL,
  },
  beh={BE.MOVE,BE.TTL,BE.DMG_ENEMY},
 },

 [EID.FIRE_PWUP]={
  beh={BE.GRANT_FIRE},
 },

 [EID.SPIKE]={
  data={coll=CR.FULL},
  beh={BE.HURT},
 },
 
 [EID.CHEST]={
  data={coll=CR.FULL,
   sol=SOL.FULL},
  beh={BE.CHEST},
 },

 [EID.TPLAF]={
  data={sol=SOL.HALF,
   coll=CR.TOP},
  beh={BE.TPLAF},
 },
 
 [EID.EN.DASHER]={
  data={hp=1,clr=12,moveDen=1,noFall=true,
   dashAnim={S.EN.DASHER,315},
   lootp=60,
   loot={EID.FOOD.B,EID.FOOD.C}},
  beh={BE.MOVE,BE.DASH,BE.VULN,BE.HURT},
 },
 
 [EID.EN.VBAT]={
  data={hp=1,clr=14,yden=2,
    lootp=40,
    loot={EID.FOOD.B,EID.FOOD.C}},
  beh={BE.UPDOWN,BE.VULN,BE.HURT},
 },

 [EID.SUPER_PWUP]={beh={BE.GRANT_SUPER}},
 [EID.SIGN]={beh={BE.SIGN}},
 [EID.FLAG]={beh={BE.FLAG}},
 
 [EID.ICICLE]={
  data={replEid=EID.ICICLE_F,replDist=8},
  beh={BE.REPLACE},
 },

 [EID.ICICLE_F]={
  data={replEid=EID.POP,replData={clr=15}},
  beh={BE.FALL,BE.HURT,BE.REPL_ON_GND}
 },

 [EID.SICICLE]={
  data={replEid=EID.SICICLE_F,replDist=8},
  beh={BE.REPLACE},
 },

 [EID.SICICLE_F]={
  data={replEid=EID.POP,replData={clr=14}},
  beh={BE.FALL,BE.HURT,BE.REPL_ON_GND}
 },

 [EID.POP]={beh={BE.POP}},
 [EID.PLANE]={beh={BE.PLANE}},
 [EID.FUEL]={beh={BE.FUEL}},
 
 [EID.FOOD.LEAF]={
   data={value=50,coll=CR.FOOD},
   beh={BE.POINTS}},
 [EID.FOOD.A]={
   data={value=100,coll=CR.FOOD},
   beh={BE.POINTS}},
 [EID.FOOD.B]={
   data={value=200,coll=CR.FOOD},
   beh={BE.POINTS}},
 [EID.FOOD.C]={
   data={value=500,coll=CR.FOOD},
   beh={BE.POINTS}},
 [EID.FOOD.D]={
   data={value=1000,coll=CR.FOOD},
   beh={BE.POINTS}}, 
}

---------------------------------------
-- DEBUG MENU
---------------------------------------
function DbgTic()
 if Plr.dbgResp then
  cls(1)
  print(Plr.dbgResp)
  if btnp(4) then
   Plr.dbgResp=nil
  end
  return
 end

 Game.dbglvl=Game.dbglvl or 1

 if btnp(3) then
  Game.dbglvl=Iif(Game.dbglvl+1>#LVL,1,Game.dbglvl+1)
 elseif btnp(2) then
  Game.dbglvl=Iif(Game.dbglvl>1,Game.dbglvl-1,#LVL)
 end

 local menu={
  {t="(Close)",f=DbgClose},
  {t="Warp to test lvl",f=DbgWarpTest}, 
  {t="Warp to L"..Game.dbglvl,f=DbgWarp},
  {t="End lvl",f=DbgEndLvl},
  {t="Grant super pwup",f=DbgSuper},
  {t="Fly mode "..
    Iif(Plr.dbgFly,"OFF","ON"),f=DbgFly},
  {t="Invuln mode "..
    Iif(Plr.invuln and Plr.invuln>0,
        "OFF","ON"),
    f=DbgInvuln},
  {t="Unpack L"..Game.dbglvl,f=DbgUnpack},
  {t="Pack L"..Game.dbglvl,f=DbgPack},
  {t="Clear PMEM",f=DbgPmem},
  {t="Win the game",f=DbgWin}, 
  {t="Lose the game",f=DbgLose}, 
 }
 cls(5)
 print("DEBUG")

 rect(110,0,140,16,11)
 print("DBG LVL:",120,4,3)
 print(LVL[Game.dbglvl].name,170,4)

 Plr.dbgSel=Plr.dbgSel or 1
 for i=1,#menu do
  print(menu[i].t,10,10+i*10,
   Plr.dbgSel==i and 15 or 0)
 end
 if btnp(0) then
  Plr.dbgSel=Iif(Plr.dbgSel>1,
   Plr.dbgSel-1,#menu)
 elseif btnp(1) then
  Plr.dbgSel=Iif(Plr.dbgSel<#menu,
   Plr.dbgSel+1,1)
 elseif btnp(4) then
  (menu[Plr.dbgSel].f)()
 end
end

function DbgClose() Plr.dbg=false end

function DbgSuper() Plr.super=true end

function DbgEndLvl()
 EndLvl()
 Plr.dbg=false
end

function DbgPmem() pmem(0,0) end

function DbgWarp()
 StartLvl(Game.dbglvl)
end

function DbgWarpNext()
 StartLvl(Game.lvlNo+1)
end

function DbgWarpTest()
 StartLvl(#LVL)
end

function DbgUnpack()
 UnpackLvl(Game.dbglvl,UMODE.EDIT)
 sync()
 Plr.dbgResp="Unpacked & synced L"..Game.dbglvl
end

function DbgPack()
 local succ=PackLvl(Game.dbglvl)
 --MapClear(0,0,LVL_LEN,ROWS)
 sync()
 Plr.dbgResp=Iif(succ,
   "Packed & synced L"..Game.dbglvl,
   "** ERROR packing L"..Game.dbglvl)
end

function DbgFly()
 Plr.dbgFly=not Plr.dbgFly
 Plr.dbgResp="Fly mode "..Iif(Plr.dbgFly,
  "ON","OFF")
end

function DbgInvuln()
 Plr.invuln=Iif(Plr.invuln>0,0,9999999)
 Plr.dbgResp="Invuln mode "..Iif(
  Plr.invuln>0,"ON","OFF")
end

function DbgWin()
 SetMode(M.WIN)
 Plr.dbg=false
end

function DbgLose()
 SetMode(M.GAMEOVER)
 Plr.dbg=false
end

---------------------------------------
-- UTILITIES
---------------------------------------
function Iif(cond,t,f)
 if cond then return t else return f end
end

function Iif2(cond,t,cond2,t2,f2)
 if cond then return t end
 return Iif(cond2,t2,f2)
end

function Iif3(cond,t,cond2,t2,cond3,t3,f3)
 if cond then return t end
 return Iif2(cond2,t2,cond3,t3,f3)
end

function Iif4(cond,t,cond2,t2,cond3,t3,
   cond4,t4,f4)
 if cond then return t end
 return Iif3(cond2,t2,cond3,t3,cond4,t4,f4)
end

function ArrayContains(a,val)
 for i=1,#a do
  if a[i]==val then return true end
 end
 return false
end

function Lpad(value, width)
 local s=value..""
 while string.len(s) < width do
  s="0"..s
 end
 return s
end

function RectXLate(r,dx,dy)
 return {x=r.x+dx,y=r.y+dy,w=r.w,h=r.h}
end

-- rects have x,y,w,h
function RectIsct(r1,r2)
 return
  r1.x+r1.w>r2.x and r2.x+r2.w>r1.x and
  r1.y+r1.h>r2.y and r2.y+r2.h>r1.y
end

function DeepCopy(t)
 if type(t)~="table" then return t end
 local r={}
 for k,v in pairs(t) do
  if type(v)=="table" then
   r[k]=DeepCopy(v)
  else
   r[k]=v
  end
 end
 return r
end

-- if preserve, fields that already exist
-- in the target won't be overwritten
function ShallowMerge(target,src,
  preserve)
 if not src then return end
 for k,v in pairs(src) do
  if not preserve or not target[k] then
   target[k]=DeepCopy(src[k])
  end
 end
end

function MapCopy(sc,sr,dc,dr,w,h)
 for r=0,h-1 do
  for c=0,w-1 do
   mset(dc+c,dr+r,mget(sc+c,sr+r))
  end
 end
end

function MapClear(dc,dr,w,h)
 for r=0,h-1 do
  for c=0,w-1 do
   mset(dc+c,dr+r,0)
  end
 end
end

function MapColsEqual(c1,c2,r)
 for i=0,ROWS-1 do
  if mget(c1,r+i)~=mget(c2,r+i) then
   return false
  end
 end
 return true
end

function MetaVal(t,deflt)
 return Iif(
  t>=T.META_NUM_0 and t<=T.META_NUM_0+12,
  t-T.META_NUM_0,deflt)
end

-- finds marker m on column c of level
-- return row of marker, -1 if not found
function FetchTile(m,c,nowarn)
 for r=0,ROWS-1 do
  if LvlTile(c,r)==m then
   if erase then SetLvlTile(c,r,0) end
   return r
  end
 end
 if not nowarn then
  trace("Marker not found "..m.." @"..c)
 end
 return -1
end

-- Gets the entity's "tag marker",
-- that is the marker tile that's sitting
-- just above it. Also erases it.
-- If no marker found, returns 0
function FetchEntTag(e)
 local t=mget(e.x//C,e.y//C-1)
 if t>=T.FIRST_META then
  mset(e.x//C,e.y//C-1,0)
  return t
 else
  return 0
 end
end

function Max(x,y) return math.max(x,y) end
function Min(x,y) return math.min(x,y) end
function Abs(x,y) return math.abs(x,y) end
function Rnd(lo,hi) return math.random(lo,hi) end
function Rnd01() return math.random() end
function RndSeed(s) return math.randomseed(s) end
function Ins(tbl,e) return table.insert(tbl,e) end
function Rem(tbl,e) return table.remove(tbl,e) end
function Sin(a) return math.sin(a) end
function Cos(a) return math.cos(a) end