0,]']±>Sï}WÿÍu§ğp8·d%qy)6o;]ÉA¦ösï÷ôôô”°ÂVl†3?»»N»»»L»»»»L»»»»i»»»]^»»»»»»_ij»»y»»»»»»»»»»»»»»»»»»y»»~»»»»»»»»»‰»»™š»»»»»»»»»»»Ÿ©»»º»»»»»»»®¯»»®¯¹»»»»»»»»»»»¾¿Ê»»ÍÎÏ»»»»»»»É»»ÎÏ»»İ»»»Üİ»»»»Üİß»»»»îïù»»»ìî»»»»éêë»»ú»»ıÿ»»»üış»»»»üışÿùúû»»ÿ«««¬­­¬««««¬­­¬««««¬­­¬««««¬¬­­¬««««¬­¬««««¬­¬««««¬¬­««««¬¬­­««««¬¬­­««««¬¬­­««««¬¬­­««««¬¬­­««««¬¬­­««««¬¬­­««««¬¬­­««««¬¬­­««««÷¬ô¬­­« !BCTevxz |} CBAC#FFIXLHOIO'OOOLGED.D 100 then return true end if TimeService.hit_stop > 0 then TimeService.hit_stop = TimeService.hit_stop - 1 return true end return false end TelemetryService = { falls = 0, grapples = 0, bananas = 0, gems = 0 } function TelemetryService.init() TelemetryService.falls = pmem(0) or 0 TelemetryService.grapples = pmem(1) or 0 TelemetryService.bananas = pmem(2) or 0 TelemetryService.gems = pmem(3) or 0 end function TelemetryService.log_event(id, val) if id == 0 then TelemetryService.falls = TelemetryService.falls + 1; pmem(0, TelemetryService.falls) elseif id == 1 then TelemetryService.grapples = TelemetryService.grapples + 1; pmem(1, TelemetryService.grapples) elseif id == 2 then TelemetryService.bananas = TelemetryService.bananas + (val or 1); pmem(2, TelemetryService.bananas) elseif id == 3 then TelemetryService.gems = TelemetryService.gems + (val or 1); pmem(3, TelemetryService.gems) else local current = pmem(id) or 0 pmem(id, current + (val or 1)) end end AudioService = { sfx_enabled = true, bgm_enabled = true, current_track = -1 } function AudioService.play_sfx(id, channel) if not AudioService.sfx_enabled then return end sfx(id) end function AudioService.play_bgm(track) if not AudioService.bgm_enabled then return end if AudioService.current_track ~= track then music(track) AudioService.current_track = track end end function AudioService.stop_bgm() music() AudioService.current_track = -1 end DialogueService = { active = false, tree = {}, current_node = 1, char_idx = 0, timer = 0, cursor_idx = 1, on_complete = nil } function DialogueService.start(tree_data, on_complete) DialogueService.tree = tree_data DialogueService.current_node = 1 DialogueService.char_idx = 0 DialogueService.timer = 0 DialogueService.cursor_idx = 1 DialogueService.active = true DialogueService.on_complete = on_complete end function DialogueService.update() if not DialogueService.active then return end local node = DialogueService.tree[DialogueService.current_node] local text_len = #node.text if DialogueService.char_idx < text_len then DialogueService.timer = DialogueService.timer + 1 if DialogueService.timer > 1 then DialogueService.char_idx = DialogueService.char_idx + 1 DialogueService.timer = 0 if DialogueService.char_idx % 3 == 0 then AudioService.play_sfx(8, 2) end end if InputService.jump or InputService.attack then DialogueService.char_idx = text_len InputService.jump = false end else if node.options then if InputService.up and DialogueService.cursor_idx > 1 then DialogueService.cursor_idx = DialogueService.cursor_idx - 1 AudioService.play_sfx(8, 2) end if InputService.down and DialogueService.cursor_idx < #node.options then DialogueService.cursor_idx = DialogueService.cursor_idx + 1 AudioService.play_sfx(8, 2) end if InputService.jump then AudioService.play_sfx(9, 2) local choice = node.options[DialogueService.cursor_idx] -- Telemetry logging if choice.telemetry_id then TelemetryService.log_event(choice.telemetry_id, choice.telemetry_val or 1) end -- Execute custom action if it exists if choice.action then choice.action() end if choice.next_node then DialogueService.current_node = choice.next_node DialogueService.char_idx = 0 DialogueService.cursor_idx = 1 else DialogueService.close() end InputService.jump = false end else if InputService.jump or InputService.attack then AudioService.play_sfx(9, 2) -- Execute custom action if it exists if node.action then node.action() end if node.next_node then DialogueService.current_node = node.next_node DialogueService.char_idx = 0 else DialogueService.close() end InputService.jump = false end end end end function DialogueService.close() DialogueService.active = false if DialogueService.on_complete then DialogueService.on_complete() end end function DialogueService.draw() if not DialogueService.active then return end local node = DialogueService.tree[DialogueService.current_node] rect(10, 86, 220, 48, 0) rectb(10, 86, 220, 48, 12) local spr_id = node.speaker_sprite or 256 spr(spr_id, 14, 90, 0, 1, 0, 0, 2, 2) rectb(13, 89, 18, 18, 5) local display_text = string.sub(node.text, 1, DialogueService.char_idx) local text_y = 90 local lines = {} for s in string.gmatch(display_text.."\n", "(.-)\n") do table.insert(lines, s) end for i, line in ipairs(lines) do print(line, 36, text_y + (i-1)*8, 15, true, 1) end if DialogueService.char_idx >= #node.text and node.options then local opt_y = 90 + (#lines)*8 for i, opt in ipairs(node.options) do local col = (i == DialogueService.cursor_idx) and 14 or 5 print((i == DialogueService.cursor_idx and ">" or " ") .. opt.text, 36, opt_y + (i-1)*8, col, true, 1) end end end BackgroundService = { layers = { { map_x = 0, map_y = 102, w = 30, h = 17, plx_x = 0.2, plx_y = 0.1 }, { map_x = 0, map_y = 119, w = 30, h = 17, plx_x = 0.4, plx_y = 0.2 } }, animations = { [100] = { frames = {100, 101, 102}, speed = 200 }, [120] = { frames = {120, 121}, speed = 500 } } } function BackgroundService.init() end function BackgroundService.remap_tile(tile_id, x, y) local anim = BackgroundService.animations[tile_id] if anim then local frame_idx = (math.floor(time() / anim.speed) % #anim.frames) + 1 return anim.frames[frame_idx] end return tile_id end function BackgroundService.draw(cam_x, cam_y) cls(0) for _, layer in ipairs(BackgroundService.layers) do local plx = (cam_x * layer.plx_x) local ply = (cam_y * layer.plx_y) local px = plx % (layer.w * 8) local py = ply % (layer.h * 8) local draw_y = -py while draw_y < 136 do local draw_x = -px while draw_x < 240 do map(layer.map_x, layer.map_y, layer.w, layer.h, draw_x, draw_y, 0, 1, BackgroundService.remap_tile) draw_x = draw_x + (layer.w * 8) end draw_y = draw_y + (layer.h * 8) end end end FXService = { effects = {}, prompt = nil } function FXService.trigger_banana_bonanza() table.insert(FXService.effects, { text = "BANANAS", timer = 0, delay = 0, x = 120, y = 40 }) table.insert(FXService.effects, { text = "BANANAS", timer = 0, delay = 15, x = 120, y = 68 }) table.insert(FXService.effects, { text = "BANANAS", timer = 0, delay = 30, x = 120, y = 96 }) end function FXService.update() for i = #FXService.effects, 1, -1 do local fx = FXService.effects[i] if fx.delay > 0 then fx.delay = fx.delay - 1 else fx.timer = fx.timer + 1 if fx.timer > 45 then table.remove(FXService.effects, i) end end end end function FXService.draw() for _, fx in ipairs(FXService.effects) do if fx.delay <= 0 then local scale = 1 + math.sin(fx.timer * 0.1) * 2 local col = (fx.timer // 3) % 15 + 1 local width = (#fx.text * 4 * scale) print(fx.text, fx.x - (width/2), fx.y - (fx.timer * 0.5), col, false, scale) end end end ParticleService = { particles = {} } function ParticleService.spawn(p_type, x, y, dir) dir = dir or 1 if p_type == "DUST" then for i=1, 4 do table.insert(ParticleService.particles, { x = x + math.random(-6, 6), y = y, vx = math.random(-10, 10)*0.05, vy = math.random(-5, -1)*0.1, life = math.random(10, 20), max_life = 20, type = "DUST", col = 15 }) end elseif p_type == "SPARK" then for i=1, 5 do table.insert(ParticleService.particles, { x = x, y = y, vx = math.random(-15, 15)*0.1, vy = math.random(-15, 15)*0.1, life = math.random(8, 15), max_life = 15, type = "SPARK", col = 10 }) end elseif p_type == "SWEAT" then table.insert(ParticleService.particles, { x = x + math.random(-4, 4), y = y, vx = dir * math.random(2, 8)*0.1, vy = -math.random(5, 15)*0.1, life = math.random(15, 30), max_life = 30, type = "SWEAT", col = 11 }) end end function ParticleService.update() for i = #ParticleService.particles, 1, -1 do local p = ParticleService.particles[i] p.x = p.x + p.vx; p.y = p.y + p.vy if p.type == "DUST" then p.vx = p.vx * 0.9; p.vy = p.vy * 0.9 elseif p.type == "SPARK" then p.vy = p.vy + 0.1 elseif p.type == "SWEAT" then p.vy = p.vy + 0.15 end p.life = p.life - 1 if p.life <= 0 then table.remove(ParticleService.particles, i) end end end function ParticleService.draw(cam_x, cam_y) for _, p in ipairs(ParticleService.particles) do local cx, cy = p.x - cam_x, p.y - cam_y if p.type == "DUST" then local r = math.max(0, (p.life / p.max_life) * 2.5) circ(cx, cy, r, p.col) elseif p.type == "SPARK" then local col = p.life % 2 == 0 and 10 or 9 rect(cx, cy, 2, 2, col) elseif p.type == "SWEAT" then pix(cx, cy, p.col); pix(cx, cy+1, p.col) end end end CameraService = { x = 0, y = 0, target_x = 0, target_y = 0, bounds = { min_x = 0, min_y = 0, max_x = 240, max_y = 136 }, state = "FOLLOW", timer = 0, shake = 0 } function CameraService.update() if CameraService.shake > 0 then CameraService.shake = CameraService.shake - 1 end if CameraService.state == "TRANSITION" then CameraService.timer = CameraService.timer + 1 local t = CameraService.timer / 30 local smooth_t = t * t * (3 - 2 * t) CameraService.x = CameraService.start_x + (CameraService.target_x - CameraService.start_x) * smooth_t CameraService.y = CameraService.start_y + (CameraService.target_y - CameraService.start_y) * smooth_t if CameraService.timer >= 30 then CameraService.state = "FOLLOW" GameplayContext.transitioning = false end elseif CameraService.state == "FOLLOW" then local tx = Player.x - 120 + (Player.w/2) local ty = Player.y - 68 + (Player.h/2) tx = math.max(CameraService.bounds.min_x, math.min(tx, CameraService.bounds.max_x - 240)) ty = math.max(CameraService.bounds.min_y, math.min(ty, CameraService.bounds.max_y - 136)) CameraService.x = CameraService.x + (tx - CameraService.x) * 0.1 CameraService.y = CameraService.y + (ty - CameraService.y) * 0.1 end end function CameraService.get_cam() local sx, sy = 0, 0 if CameraService.shake > 0 then sx = math.random(-CameraService.shake, CameraService.shake) * 0.5 sy = math.random(-CameraService.shake, CameraService.shake) * 0.5 end return math.floor(CameraService.x + sx), math.floor(CameraService.y + sy) end UIService = { b_y = -30, b_timer = 0, g_y = -30, g_timer = 0, cursor_idx = 1 } function UIService.trigger_coin_ui(type) if type == "BANANA" then UIService.b_timer = 90 end if type == "GEM" then UIService.g_timer = 120 end end function UIService.update() if UIService.b_timer > 0 then UIService.b_timer = UIService.b_timer - 1 UIService.b_y = UIService.b_y + (5 - UIService.b_y) * 0.2 else UIService.b_y = UIService.b_y + (-30 - UIService.b_y) * 0.1 end if UIService.g_timer > 0 then UIService.g_timer = UIService.g_timer - 1 UIService.g_y = UIService.g_y + (25 - UIService.g_y) * 0.2 else UIService.g_y = UIService.g_y + (-30 - UIService.g_y) * 0.1 end end function UIService.draw_hud() spr(352, 4, 4, 0, 1, 0, 0, 2, 2) rect(22, 6, 44, 8, 0) for i=1, Player.max_hp do local col = i <= Player.hp and 11 or 5 rect(24 + (i-1)*14, 8, 12, 4, col) end rect(22, 16, 44, 4, 0) rect(23, 17, (Player.special / Player.max_special) * 42, 2, 9) if Player.has_boss_key then spr(353, 240 - 24, 24, 0, 1, 0, 0, 2, 2) end if UIService.b_y > -25 then spr(288, 240 - 50, UIService.b_y, 0, 1, 0, 0, 2, 2) print("x" .. TelemetryService.bananas, 240 - 30, UIService.b_y + 7, 0, true, 1) print("x" .. TelemetryService.bananas, 240 - 30, UIService.b_y + 6, 12, true, 1) end if UIService.g_y > -25 then spr(320, 240 - 50, UIService.g_y, 0, 1, 0, 0, 2, 2) print("x" .. TelemetryService.gems, 240 - 30, UIService.g_y + 7, 0, true, 1) print("x" .. TelemetryService.gems, 240 - 30, UIService.g_y + 6, 10, true, 1) end if BossService.active and BossService.state ~= "DORMANT" then local bh_w = 140 local bh_x = (240 - bh_w) / 2 rect(bh_x, 120, bh_w, 8, 0) rect(bh_x + 1, 121, (BossService.hp / BossService.max_hp) * (bh_w - 2), 6, BossService.phase == 2 and 2 or 5) print("MECHA-APE", bh_x + 45, 112, 15, true, 1) end end RenderService = {} function RenderService.transform(px,py,x,y,r,sx,sy,ox,oy) x,y,r,sx,sy,ox,oy = x or 0, y or 0, r or 0, sx or 1, sy or 1, ox or 0, oy or 0 local c, s = math.cos(r), math.sin(r) px, py = (px-ox)*sx, (py-oy)*sy return (px*c-py*s)+x, (px*s+py*c)+y end function RenderService.xspr(id,x,y,w,h,r,sx,sy,ox,oy,tc) id,w,h = id%512, (w or 1)//1, (h or 1)//1 local u1,v1 = (id%16)*8, (id//16)*8 local u2,v2 = u1+w*8, v1 local u3,v3 = u2, v2+h*8 local u4,v4 = u1, v3 local x1,y1 = RenderService.transform(0,0,x,y,r,sx,sy,ox,oy) local x2,y2 = RenderService.transform(w*8,0,x,y,r,sx,sy,ox,oy) local x3,y3 = RenderService.transform(w*8,h*8,x,y,r,sx,sy,ox,oy) local x4,y4 = RenderService.transform(0,h*8,x,y,r,sx,sy,ox,oy) ttri(x1,y1,x2,y2,x3,y3,u1,v1,u2,v2,u3,v3,0,tc) ttri(x3,y3,x4,y4,x1,y1,u3,v3,u4,v4,u1,v1,0,tc) end -- ========================================== -- [ MAP & COLLISION ] -- ========================================== MapService = { rooms = {} } function MapService.is_solid(x, y) return fget(mget(x // 8, y // 8), CONST.F_SOLID) end function MapService.is_spike(x, y) return fget(mget(x // 8, y // 8), CONST.F_SPIKE) end function MapService.is_wallrunnable(x, y) return fget(mget(x // 8, y // 8), CONST.F_WALLRUN) end function MapService.init_entities() EnemyService.enemies = {} EnemyService.spores = {} CurrencyService.items = {} PlatformService.platforms = {} ParticleService.particles = {} NPCService.npcs = {} BossService.active = false BossService.shockwaves = {} for ty = 0, 135 do for tx = 0, 239 do local tile = mget(tx, ty) if tile == CONST.SPAWN_BANANA then CurrencyService.spawn(tx * 8, ty * 8, "BANANA") mset(tx, ty, 0) elseif tile == CONST.SPAWN_GEM then CurrencyService.spawn(tx * 8, ty * 8, "GEM") mset(tx, ty, 0) elseif tile == CONST.SPAWN_MUSHROOM then EnemyService.spawn("MUSHROOM", tx * 8, ty * 8) mset(tx, ty, 0) elseif tile == CONST.SPAWN_DUMMY then EnemyService.spawn("DUMMY", tx * 8, ty * 8) mset(tx, ty, 0) elseif tile == CONST.SPAWN_PLATFORM then PlatformService.spawn(tx * 8, ty * 8, 32, 8, 64, 1) mset(tx, ty, 0) elseif tile == CONST.SPAWN_PLAYER then Player.x = tx * 8 Player.y = ty * 8 Player.change_state("SPAWN") mset(tx, ty, 0) elseif tile == CONST.SPAWN_BOSS then BossService.spawn(tx * 8, ty * 8) mset(tx, ty, 0) elseif tile == CONST.SPAWN_KEY then CurrencyService.spawn(tx * 8, ty * 8, "KEY") mset(tx, ty, 0) elseif tile == CONST.SPAWN_NPC then NPCService.spawn(tx * 8, ty * 8) mset(tx, ty, 0) end end end end function MapService.reveal_secret(tx, ty) if not fget(mget(tx, ty), CONST.F_SECRET) then return end local queue = {{x=tx, y=ty}} mset(tx, ty, 0) CameraService.shake = 10 while #queue > 0 do local curr = table.remove(queue) ParticleService.spawn("DUST", curr.x*8+4, curr.y*8+4) local neighbors = {{x=curr.x+1, y=curr.y}, {x=curr.x-1, y=curr.y}, {x=curr.x, y=curr.y+1}, {x=curr.x, y=curr.y-1}} for _, n in ipairs(neighbors) do if fget(mget(n.x, n.y), CONST.F_SECRET) then mset(n.x, n.y, 0) table.insert(queue, {x=n.x, y=n.y}) end end end end function MapService.open_door(tx, ty) if not fget(mget(tx, ty), CONST.F_DOOR) then return end local queue = {{x=tx, y=ty}} mset(tx, ty, 0) CameraService.shake = 15 while #queue > 0 do local curr = table.remove(queue) ParticleService.spawn("DUST", curr.x*8+4, curr.y*8+4) local neighbors = {{x=curr.x+1, y=curr.y}, {x=curr.x-1, y=curr.y}, {x=curr.x, y=curr.y+1}, {x=curr.x, y=curr.y-1}} for _, n in ipairs(neighbors) do if fget(mget(n.x, n.y), CONST.F_DOOR) then mset(n.x, n.y, 0) table.insert(queue, {x=n.x, y=n.y}) end end end end function MapService.check_transition(px, py) if px < CameraService.bounds.min_x or px > CameraService.bounds.max_x or py < CameraService.bounds.min_y or py > CameraService.bounds.max_y then for _, r in ipairs(MapService.rooms) do if px >= r.x and px <= r.x + r.w and py >= r.y and py <= r.y + r.h then if CameraService.bounds.min_x ~= r.x or CameraService.bounds.min_y ~= r.y then CameraService.bounds = { min_x = r.x, min_y = r.y, max_x = r.x + r.w, max_y = r.y + r.h } CameraService.state = "TRANSITION" CameraService.timer = 0 CameraService.start_x = CameraService.x CameraService.start_y = CameraService.y local tx = px - 120; local ty = py - 68 CameraService.target_x = math.max(r.x, math.min(tx, r.x + r.w - 240)) CameraService.target_y = math.max(r.y, math.min(ty, r.y + r.h - 136)) GameplayContext.transitioning = true return end end end end end function MapService.find_ledge(px, py, dir, w, h) local check_x = px + w/2 + (dir * (w/2 + 4)) for y_off = 0, 8, 4 do local tx = check_x // 8 local ty = (py + y_off) // 8 if fget(mget(tx, ty), CONST.F_LEDGE) then if not fget(mget(tx, ty - 1), CONST.F_SOLID) then return ty * 8, tx * 8 end end end return nil, nil end function MapService.find_grapple_point(px, py, p_dir) local range = WhipWeapon.stats.max_length local best_dist, best_target = range, nil local start_x = p_dir == 1 and px or (px - range) local end_x = p_dir == 1 and (px + range) or px for tx = start_x // 8, end_x // 8 do for ty = (py - range) // 8, (py + range) // 8 do if fget(mget(tx, ty), CONST.F_GRAPPLE) then local gx, gy = tx * 8 + 4, ty * 8 + 4 local dist = math.sqrt((gx - px)^2 + (gy - py)^2) if dist < best_dist then best_dist = dist; best_target = {x = gx, y = gy} end end end end return best_target end -- ========================================== -- [ DOMAIN: CURRENCY & SCENERY ] -- ========================================== CurrencyService = { items = {} } function CurrencyService.spawn(x, y, i_type) local base = 288 if i_type == "GEM" then base = 320 elseif i_type == "KEY" then base = 353 end table.insert(CurrencyService.items, { x = x, y = y, w = 16, h = 16, type = i_type or "BANANA", frame_timer = 0, current_frame = 0, base_sprite = base }) end function CurrencyService.update() for i = #CurrencyService.items, 1, -1 do local item = CurrencyService.items[i] item.frame_timer = item.frame_timer + 1 if item.frame_timer > 6 then item.frame_timer = 0 item.current_frame = (item.current_frame + 1) % 4 end local px, py = Player.x + Player.w/2, Player.y + Player.h/2 local dist = math.sqrt((px - (item.x + item.w/2))^2 + (py - (item.y + item.h/2))^2) if dist < (Player.w/2 + 8) then if item.type == "KEY" then Player.has_boss_key = true table.insert(FXService.effects, { text = "BOSS KEY!", timer = 0, delay = 0, x = Player.x + 16, y = Player.y - 10 }) CameraService.shake = 8 AudioService.play_sfx(4, 2) elseif item.type == "GEM" then FXService.trigger_banana_bonanza() TelemetryService.log_event(3, 1) UIService.trigger_coin_ui("GEM") AudioService.play_sfx(4, 2) else TelemetryService.log_event(2, 1) UIService.trigger_coin_ui("BANANA") AudioService.play_sfx(3, 2) end table.remove(CurrencyService.items, i) end end end function CurrencyService.draw(cam_x, cam_y) for _, item in ipairs(CurrencyService.items) do local float_y = math.sin(time() * 0.005 + item.x) * (item.type == "GEM" and 6 or 4) local sprite_id = item.base_sprite if item.type ~= "KEY" then sprite_id = sprite_id + (item.current_frame * 2) end local scale = item.type == "GEM" and 2.5 or 2 spr(sprite_id, item.x - cam_x, item.y - cam_y + float_y, 0, 1, 0, 0, 2, 2) end end PlatformService = { platforms = {} } function PlatformService.spawn(x, y, w, h, range, spd) table.insert(PlatformService.platforms, {x=x, y=y, w=w, h=h, start_x=x, range=range, spd=spd, dir=1}) end function PlatformService.update() for _, p in ipairs(PlatformService.platforms) do p.x = p.x + (p.spd * p.dir) if p.x > p.start_x + p.range or p.x < p.start_x then p.dir = p.dir * -1 end end end function PlatformService.collide_player(player) local was_grounded = player.grounded for _, p in ipairs(PlatformService.platforms) do if player.vy >= 0 and player.y + player.h >= p.y and player.y + player.h <= p.y + math.max(4, player.vy + 2) and player.x + player.w > p.x and player.x < p.x + p.w then player.y = p.y - player.h player.vy = 0 player.grounded = true player.x = player.x + (p.spd * p.dir) if not was_grounded then ParticleService.spawn("DUST", player.x + player.w/2, player.y + player.h) end end end end function PlatformService.draw(cam_x, cam_y) for _, p in ipairs(PlatformService.platforms) do rect(p.x - cam_x, p.y - cam_y, p.w, p.h, 6) rectb(p.x - cam_x, p.y - cam_y, p.w, p.h, 12) end end -- ========================================== -- [ DOMAIN: ENEMIES & BOSS ] -- ========================================== EnemyService = { enemies = {}, spores = {} } function EnemyService.spawn(e_type, x, y) local w, h = e_type == "MUSHROOM" and 16 or 32, e_type == "MUSHROOM" and 16 or 32 table.insert(EnemyService.enemies, { type = e_type, x = x, y = y, w = w, h = h, vx = 0, vy = 0, hp = 3, flash_timer = 0, timer = math.random(30, 90) }) end function EnemyService.update() for i = #EnemyService.enemies, 1, -1 do local e = EnemyService.enemies[i] e.vy = math.min(e.vy + CONST.grav, 7) e.vx = e.vx * 0.85 e.x = e.x + e.vx if MapService.is_solid(e.x + (e.vx > 0 and e.w or 0), e.y + e.h/2) then e.vx = e.vx * -1 end e.y = e.y + e.vy if e.vy >= 0 and (MapService.is_solid(e.x + 4, e.y + e.h) or MapService.is_solid(e.x + e.w - 4, e.y + e.h)) then e.y = ((e.y + e.h) // 8) * 8 - e.h e.vy = 0 end if e.type == "MUSHROOM" then e.timer = e.timer - 1 if e.timer <= 0 then e.timer = 120 for angle = -math.pi/2 - 0.4, -math.pi/2 + 0.4, 0.4 do table.insert(EnemyService.spores, { x = e.x + 8, y = e.y + 4, vx = math.cos(angle) * 2.5, vy = math.sin(angle) * 2.5, life = 100 }) end end end if WhipWeapon.active and WhipWeapon.mode == "TETHER_EXTEND" then if WhipWeapon.tip_x > e.x and WhipWeapon.tip_x < e.x + e.w and WhipWeapon.tip_y > e.y and WhipWeapon.tip_y < e.y + e.h then if e.flash_timer == 0 then e.hp = e.hp - 1 e.flash_timer = 15 ParticleService.spawn("SPARK", WhipWeapon.tip_x, WhipWeapon.tip_y) AudioService.play_sfx(5, 3) WhipWeapon.cancel() end end end if e.flash_timer > 0 then e.flash_timer = e.flash_timer - 1 end if e.hp <= 0 then CurrencyService.spawn(e.x + 8, e.y + 8, "BANANA") table.remove(EnemyService.enemies, i) end end for i = #EnemyService.spores, 1, -1 do local s = EnemyService.spores[i] s.x = s.x + s.vx; s.y = s.y + s.vy s.vx = s.vx * 0.98; s.vy = s.vy * 0.98 s.life = s.life - 1 local px, py = Player.x + Player.w/2, Player.y + Player.h/2 if math.sqrt((px - s.x)^2 + (py - s.y)^2) < math.max(8, Player.w/2 + 4) and Player.invuln_timer == 0 then Player.hurt(s.x > px and -1 or 1) end if s.life <= 0 then table.remove(EnemyService.spores, i) end end end function EnemyService.draw(cam_x, cam_y) for _, e in ipairs(EnemyService.enemies) do local cx, cy = e.x - cam_x, e.y - cam_y local col = e.flash_timer > 0 and 12 or (e.type == "MUSHROOM" and 3 or 6) rect(cx, cy, e.w, e.h, col) if e.type ~= "MUSHROOM" then rect(cx, cy - 8, 32, 4, 0) rect(cx, cy - 8, (e.hp / 3) * 32, 4, 5) end end for _, s in ipairs(EnemyService.spores) do local radius = math.max(1, s.life / 20) circ(s.x - cam_x, s.y - cam_y, radius, 11) circb(s.x - cam_x, s.y - cam_y, radius + 1, 5) end end BossService = { active = false, shockwaves = {} } function BossService.spawn(x, y) BossService.active = true BossService.x = x; BossService.y = y BossService.w = 48; BossService.h = 48 BossService.hp = 25; BossService.max_hp = 25 BossService.state = "DORMANT" BossService.timer = 0 BossService.vx = 0; BossService.vy = 0 BossService.flash = 0 BossService.phase = 1 BossService.shockwaves = {} end function BossService.update() if not BossService.active then return end local px = Player.x + Player.w/2 local bx = BossService.x + BossService.w/2 local dir = px > bx and 1 or -1 if BossService.state == "DORMANT" then if math.abs(px - bx) < 100 and math.abs((Player.y + Player.h/2) - (BossService.y + BossService.h/2)) < 120 then BossService.state = "INTRO" end return end if BossService.flash > 0 then BossService.flash = BossService.flash - 1 end if BossService.state == "INTRO" then BossService.vy = BossService.vy + CONST.grav BossService.y = BossService.y + BossService.vy if MapService.is_solid(bx, BossService.y + BossService.h) then BossService.y = ((BossService.y + BossService.h) // 8) * 8 - BossService.h BossService.vy = 0 CameraService.shake = 15 AudioService.play_sfx(7, 3) BossService.state = "IDLE" BossService.timer = 60 end elseif BossService.state == "IDLE" then BossService.timer = BossService.timer - 1 if BossService.timer <= 0 then if math.random() > 0.4 then BossService.state = "JUMP" BossService.vy = -6.0 BossService.vx = dir * (BossService.phase == 2 and 2.5 or 1.5) else BossService.state = "POUND_PREP" BossService.timer = BossService.phase == 2 and 20 or 40 end end elseif BossService.state == "JUMP" then BossService.vy = BossService.vy + CONST.grav BossService.x = BossService.x + BossService.vx BossService.y = BossService.y + BossService.vy if BossService.vy > 0 and MapService.is_solid(bx, BossService.y + BossService.h) then BossService.y = ((BossService.y + BossService.h) // 8) * 8 - BossService.h BossService.vx = 0; BossService.vy = 0 CameraService.shake = 10 AudioService.play_sfx(7, 3) BossService.state = "STUNNED" BossService.timer = BossService.phase == 2 and 25 or 45 end elseif BossService.state == "POUND_PREP" then BossService.timer = BossService.timer - 1 if BossService.timer <= 0 then BossService.state = "POUND" BossService.vy = -8.0 end elseif BossService.state == "POUND" then BossService.vy = BossService.vy + CONST.grav * 1.5 BossService.y = BossService.y + BossService.vy if BossService.vy > 0 and MapService.is_solid(bx, BossService.y + BossService.h) then BossService.y = ((BossService.y + BossService.h) // 8) * 8 - BossService.h BossService.vy = 0 CameraService.shake = 20 AudioService.play_sfx(7, 3) table.insert(BossService.shockwaves, {x = BossService.x, y = BossService.y + BossService.h - 16, vx = -4, w = 12, h = 16}) table.insert(BossService.shockwaves, {x = BossService.x + BossService.w, y = BossService.y + BossService.h - 16, vx = 4, w = 12, h = 16}) BossService.state = "IDLE" BossService.timer = BossService.phase == 2 and 25 or 50 end elseif BossService.state == "STUNNED" then BossService.timer = BossService.timer - 1 if BossService.timer <= 0 then BossService.state = "IDLE"; BossService.timer = 20 end end for i = #BossService.shockwaves, 1, -1 do local s = BossService.shockwaves[i] s.x = s.x + s.vx if MapService.is_solid(s.x + (s.vx > 0 and s.w or 0), s.y + s.h/2) then table.remove(BossService.shockwaves, i) else if math.abs((Player.x + Player.w/2) - (s.x + s.w/2)) < (Player.w/2 + s.w/2) and math.abs((Player.y + Player.h/2) - (s.y + s.h/2)) < (Player.h/2 + s.h/2) and Player.invuln_timer == 0 then Player.hurt(s.vx > 0 and 1 or -1) end end end if BossService.state ~= "STUNNED" and BossService.state ~= "INTRO" then if math.abs((Player.x + Player.w/2) - bx) < (Player.w/2 + BossService.w/2 - 8) and math.abs((Player.y + Player.h/2) - (BossService.y + BossService.h/2)) < (Player.h/2 + BossService.h/2 - 8) and Player.invuln_timer == 0 then Player.hurt(dir) end end if WhipWeapon.active and WhipWeapon.mode == "TETHER_EXTEND" then local wx, wy = WhipWeapon.tip_x, WhipWeapon.tip_y if wx > BossService.x and wx < BossService.x + BossService.w and wy > BossService.y and wy < BossService.y + BossService.h then if BossService.flash == 0 then BossService.hp = BossService.hp - (BossService.state == "STUNNED" and 3 or 1) BossService.flash = 15 ParticleService.spawn("SPARK", wx, wy) AudioService.play_sfx(5, 3) if BossService.hp <= BossService.max_hp / 2 and BossService.phase == 1 then BossService.phase = 2 CameraService.shake = 10 ParticleService.spawn("SWEAT", bx, BossService.y, 1) end if BossService.hp <= 0 then for i=1, 15 do CurrencyService.spawn(BossService.x + math.random(BossService.w), BossService.y + math.random(BossService.h), "GEM") end BossService.active = false CameraService.shake = 30 end WhipWeapon.cancel() end end end end function BossService.draw(cam_x, cam_y) if not BossService.active or BossService.state == "DORMANT" then return end local cx, cy = BossService.x - cam_x, BossService.y - cam_y local col = BossService.flash > 0 and 12 or (BossService.phase == 2 and 2 or 5) rect(cx, cy, BossService.w, BossService.h, col) rectb(cx, cy, BossService.w, BossService.h, 0) rect(cx + 8, cy + 12, 10, 8, 0) rect(cx + BossService.w - 18, cy + 12, 10, 8, 0) rect(cx + 10 + (BossService.phase == 2 and math.random(-1,1) or 0), cy + 14, 4, 4, 12) rect(cx + BossService.w - 16 + (BossService.phase == 2 and math.random(-1,1) or 0), cy + 14, 4, 4, 12) for _, s in ipairs(BossService.shockwaves) do rect(s.x - cam_x, s.y - cam_y, s.w, s.h, 10) rectb(s.x - cam_x, s.y - cam_y, s.w, s.h, 9) end end -- ========================================== -- [ DOMAIN: NPC & INTERACTION ] -- ========================================== NPCService = { npcs = {} } function NPCService.spawn(x, y) table.insert(NPCService.npcs, {x = x, y = y, w = 16, h = 16, sprite = 256}) end function NPCService.update() if DialogueService.active then return end local px, py = Player.x + Player.w/2, Player.y + Player.h/2 for _, n in ipairs(NPCService.npcs) do local nx, ny = n.x + n.w/2, n.y + n.h/2 local dist = math.sqrt((px - nx)^2 + (py - ny)^2) if dist < 24 then FXService.prompt = {x = n.x, y = n.y - 12, text = "[UP] TALK"} if InputService.up and Player.grounded then local demo_tree = { [1] = { speaker_sprite = 256, text = "Hey! The lab is burning down!\nCan you save the subjects?", options = { {text = "I'll save them.", next_node = 2, telemetry_id = 128, telemetry_val = 1}, {text = "I need the upgrades.", next_node = 3, telemetry_id = 1, telemetry_val = 1} } }, [2] = { speaker_sprite = 256, text = "Thank you! Take this keycard.", next_node = nil, action = function() Player.has_boss_key = true table.insert(FXService.effects, { text = "GOT BOSS KEY!", timer = 0, delay = 0, x = Player.x + 16, y = Player.y - 10 }) AudioService.play_sfx(4, 2) end }, [3] = { speaker_sprite = 256, text = "You... you're a monster.\n(Whip Unlocked)", next_node = nil, action = function() Player.unlocked_whip = true table.insert(FXService.effects, { text = "WHIP UNLOCKED!", timer = 0, delay = 0, x = Player.x + 16, y = Player.y - 10 }) AudioService.play_sfx(4, 2) end } } DialogueService.start(demo_tree) FXService.prompt = nil end return end end FXService.prompt = nil end function NPCService.draw(cam_x, cam_y) for _, n in ipairs(NPCService.npcs) do RenderService.xspr(n.sprite, n.x - cam_x, n.y - cam_y, 2, 2, 0, 1, 1, 8, 8, 0) end if FXService.prompt then print(FXService.prompt.text, FXService.prompt.x - cam_x - 8, FXService.prompt.y - cam_y, 14) end end -- ========================================== -- [ DOMAIN: WHIP WEAPON ] -- ========================================== WhipWeapon = { active = false, mode = "NONE", timer = 0, tip_x = 0, tip_y = 0, target_x = 0, target_y = 0, dir_x = 1, dir_y = 0, stats = { max_length = 48 * DESIGNER_PLAYER_SCALE, travel_spd = 5, pull_spd = 7.5, tether_spd = 10 }, nodes = {} } for i=1, 8 do WhipWeapon.nodes[i] = {x=120, y=68, old_x=120, old_y=68} end function WhipWeapon.get_muzzle() local mx = Player.x + Player.w/2 + (Player.dir * Player.muzzle_offset.x) local my = Player.y + Player.h/2 + Player.muzzle_offset.y return mx, my end function WhipWeapon.fire_grapple(target_x, target_y) WhipWeapon.active = true; WhipWeapon.mode = "GRAPPLE"; WhipWeapon.timer = 0 WhipWeapon.target_x = target_x; WhipWeapon.target_y = target_y local mx, my = WhipWeapon.get_muzzle() WhipWeapon.tip_x = mx; WhipWeapon.tip_y = my end function WhipWeapon.fire_tether(dx, dy) WhipWeapon.active = true; WhipWeapon.mode = "TETHER_EXTEND"; WhipWeapon.timer = 0 WhipWeapon.dir_x = dx; WhipWeapon.dir_y = dy local mx, my = WhipWeapon.get_muzzle() WhipWeapon.tip_x = mx; WhipWeapon.tip_y = my end function WhipWeapon.cancel() WhipWeapon.active = false; WhipWeapon.mode = "NONE" end function WhipWeapon.update() if not Player.unlocked_whip then return end local mx, my = WhipWeapon.get_muzzle() if WhipWeapon.active then local dist = math.sqrt((WhipWeapon.tip_x - mx)^2 + (WhipWeapon.tip_y - my)^2) for i=1, 8 do local t = (i-1) / 7 WhipWeapon.nodes[i].x = mx + (WhipWeapon.tip_x - mx) * t WhipWeapon.nodes[i].y = my + (WhipWeapon.tip_y - my) * t WhipWeapon.nodes[i].old_x = WhipWeapon.nodes[i].x WhipWeapon.nodes[i].old_y = WhipWeapon.nodes[i].y end else WhipWeapon.nodes[1].x, WhipWeapon.nodes[1].y = mx, my for i=2, 8 do local n = WhipWeapon.nodes[i] local vx, vy = (n.x - n.old_x) * 0.8, (n.y - n.old_y) * 0.8 n.old_x, n.old_y = n.x, n.y n.x = n.x + vx; n.y = n.y + vy + 0.4 if MapService.is_solid(n.x, n.y) then n.y = (n.y // 8) * 8 - 2 n.x = n.x - vx * 0.5 end end for step=1, 3 do for i=1, 7 do local n1, n2 = WhipWeapon.nodes[i], WhipWeapon.nodes[i+1] local dx, dy = n2.x - n1.x, n2.y - n1.y local dist = math.max(0.1, math.sqrt(dx^2 + dy^2)) local target_dist = 8 * DESIGNER_PLAYER_SCALE if dist > target_dist then local diff = (dist - target_dist) / dist * 0.5 if i > 1 then n1.x = n1.x + dx * diff; n1.y = n1.y + dy * diff end n2.x = n2.x - dx * diff; n2.y = n2.y - dy * diff end end end end if not WhipWeapon.active then return end WhipWeapon.timer = WhipWeapon.timer + 1 if WhipWeapon.mode == "GRAPPLE" then if WhipWeapon.timer < WhipWeapon.stats.travel_spd then local t = WhipWeapon.timer / WhipWeapon.stats.travel_spd WhipWeapon.tip_x = mx + (WhipWeapon.target_x - mx) * t WhipWeapon.tip_y = my + (WhipWeapon.target_y - my) * t else WhipWeapon.tip_x, WhipWeapon.tip_y = WhipWeapon.target_x, WhipWeapon.target_y end elseif WhipWeapon.mode == "TETHER_EXTEND" then if WhipWeapon.timer < 5 then WhipWeapon.tip_x, WhipWeapon.tip_y = mx - (WhipWeapon.dir_x * 15), my - (7 * DESIGNER_PLAYER_SCALE) else local t_extend = WhipWeapon.stats.tether_spd local t = math.min(1, (WhipWeapon.timer - 5) / t_extend) local base_x = mx + (WhipWeapon.dir_x * WhipWeapon.stats.max_length * t) local base_y = my + (WhipWeapon.dir_y * WhipWeapon.stats.max_length * t) local wave = math.sin(t * math.pi) * (-15 * DESIGNER_PLAYER_SCALE) local next_x = base_x + (-WhipWeapon.dir_y * wave) local next_y = base_y + (WhipWeapon.dir_x * wave) local tx, ty = next_x // 8, next_y // 8 local tile = mget(tx, ty) local found_ledge, l_ty = false, ty for check_y = ty - 1, ty + 1 do if fget(mget(tx, check_y), CONST.F_LEDGE) and not fget(mget(tx, check_y - 1), CONST.F_SOLID) then found_ledge, l_ty = true, check_y; break end end if found_ledge then WhipWeapon.mode = "TETHER_PULL" WhipWeapon.target_x = tx * 8 + (WhipWeapon.dir_x < 0 and 8 or 0) WhipWeapon.target_y = l_ty * 8 AudioService.play_sfx(2, 1) elseif fget(tile, CONST.F_CRATE) then mset(tx, ty, 0) ParticleService.spawn("SPARK", tx*8+4, ty*8+4) ParticleService.spawn("DUST", tx*8+4, ty*8+4) CameraService.shake = 6 AudioService.play_sfx(2, 1) WhipWeapon.cancel() elseif fget(tile, CONST.F_SOLID) then ParticleService.spawn("SPARK", next_x, next_y) AudioService.play_sfx(2, 1) WhipWeapon.cancel() else WhipWeapon.tip_x = next_x; WhipWeapon.tip_y = next_y end if WhipWeapon.timer >= (5 + t_extend) and WhipWeapon.mode ~= "TETHER_PULL" then WhipWeapon.cancel() end end elseif WhipWeapon.mode == "TETHER_PULL" then WhipWeapon.tip_x, WhipWeapon.tip_y = WhipWeapon.target_x, WhipWeapon.target_y end end function WhipWeapon.draw(cam_x, cam_y) if not Player.unlocked_whip then return end local mx, my = WhipWeapon.get_muzzle() local ws = 2 * DESIGNER_PLAYER_SCALE if not WhipWeapon.active then for i = 1, #WhipWeapon.nodes - 1 do local n1, n2 = WhipWeapon.nodes[i], WhipWeapon.nodes[i+1] local angle = math.atan2(n2.y - n1.y, n2.x - n1.x) local spid = i == 1 and 400 or 401 RenderService.xspr(spid, n1.x - cam_x, n1.y - cam_y, 1, 1, angle, ws, ws, 4, 4, 0) end local last = WhipWeapon.nodes[#WhipWeapon.nodes] local p_last = WhipWeapon.nodes[#WhipWeapon.nodes - 1] local angle = math.atan2(last.y - p_last.y, last.x - p_last.x) RenderService.xspr(402, last.x - cam_x, last.y - cam_y, 1, 1, angle, ws, ws, 4, 4, 0) return end local tx, ty = WhipWeapon.tip_x - cam_x, WhipWeapon.tip_y - cam_y mx = mx - cam_x; my = my - cam_y local dist = math.sqrt((tx - mx)^2 + (ty - my)^2) local angle = math.atan2(ty - my, tx - mx) local segments = math.max(1, math.floor(dist / (12 * DESIGNER_PLAYER_SCALE))) RenderService.xspr(400, mx, my, 1, 1, angle, ws, ws, 4, 4, 0) for i = 1, segments - 1 do local t = i / segments local seg_x, seg_y = mx + (tx - mx) * t, my + (ty - my) * t if WhipWeapon.mode == "TETHER_EXTEND" and WhipWeapon.timer >= 5 then local wave = math.sin(t * math.pi) * math.sin((WhipWeapon.timer - 5) * 0.5) * (-8 * DESIGNER_PLAYER_SCALE) seg_y = seg_y + wave if i > 1 then local prev_t = (i-1) / segments local prev_x = mx + (tx - mx) * prev_t local prev_y = my + (ty - my) * prev_t + (math.sin(prev_t * math.pi) * math.sin((WhipWeapon.timer - 5) * 0.5) * (-8 * DESIGNER_PLAYER_SCALE)) angle = math.atan2(seg_y - prev_y, seg_x - prev_x) end end RenderService.xspr(401, seg_x, seg_y, 1, 1, angle, ws, ws, 4, 4, 0) end RenderService.xspr(402, tx, ty, 1, 1, angle, ws, ws, 4, 4, 0) end -- ========================================== -- [ DOMAIN: PLAYER ENTITY & STATES ] -- ========================================== Player = { base_scale = DESIGNER_PLAYER_SCALE, x = 120, y = 68, w = 16 * DESIGNER_PLAYER_SCALE, h = 16 * DESIGNER_PLAYER_SCALE, vx = 0, vy = 0, dir = 1, grounded = false, scale_x = DESIGNER_PLAYER_SCALE, scale_y = DESIGNER_PLAYER_SCALE, angle = 0, muzzle_offset = {x = 8 * DESIGNER_PLAYER_SCALE, y = 2 * DESIGNER_PLAYER_SCALE}, active_state = nil, active_state_name = "", state_timer = 0, ledge_cooldown = 0, invuln_timer = 0, hp = 3, max_hp = 3, special = 0, max_special = 100, has_boss_key = false, unlocked_whip = false, unlocked_wallrun = true, last_wallrun_dir = 0, anim = "IDLE", anim_frame = 1, anim_timer = 0, animations = { IDLE = { frames = {256, 258}, spd = 30 }, RUN = { frames = {260, 262, 264, 266}, spd = 6 }, JUMP = { frames = {268}, spd = 10 }, FALL = { frames = {270}, spd = 10 }, LEDGE = { frames = {296}, spd = 10 }, MANTLE = { frames = {298}, spd = 10 }, GRAPPLE = { frames = {300}, spd = 10 }, SLEEP = { frames = {328, 330}, spd = 30 }, WAKE = { frames = {332, 334}, spd = 15 }, CARTWHEEL = { frames = {260, 270}, spd = 5 }, WALLRUN = { frames = {260, 262, 264, 266}, spd = 4 } } } function Player.set_anim(name) if Player.anim ~= name then Player.anim = name; Player.anim_frame = 1; Player.anim_timer = 0 end end function Player.hurt(dir) Player.hp = Player.hp - 1 AudioService.play_sfx(6, 0) if Player.hp <= 0 then Player.y = 1000 Player.hp = Player.max_hp else Player.vy = -3.5; Player.vx = dir * 4.0 Player.invuln_timer = 45 Player.change_state("NORMAL") WhipWeapon.cancel(); TimeService.hit_stop = 8 end end PlayerStates = {} PlayerStates.SPAWN = { enter = function(p) p.scale_x, p.scale_y = p.base_scale, p.base_scale p.vx, p.vy = 0, 0 p.angle = 0 end, update = function(p) if p.state_timer > 105 then return "NORMAL" end return nil end } PlayerStates.NORMAL = { enter = function(p) p.angle = 0 end, update = function(p) p.muzzle_offset = {x = 8 * p.base_scale, y = 2 * p.base_scale} if p.ledge_cooldown > 0 then p.ledge_cooldown = p.ledge_cooldown - 1 end local move = (InputService.right and 1 or 0) - (InputService.left and 1 or 0) if p.invuln_timer > 30 then move = 0 end if move ~= 0 then p.dir = move; p.vx = p.vx + move * CONST.accel else p.vx = p.vx * (1 - CONST.fric) end p.vx = math.max(-CONST.max_spd, math.min(CONST.max_spd, p.vx)) p.vy = math.min(p.vy + CONST.grav, 7) if InputService.attack then if p.unlocked_whip then return "TETHER_FIRE" elseif p.grounded then return "CARTWHEEL" end end if InputService.grapple and p.unlocked_whip then local target = MapService.find_grapple_point(p.x + p.w/2, p.y + p.h/2, p.dir) if target then return "GRAPPLE", target end end if p.unlocked_wallrun and not p.grounded and move == p.dir and p.invuln_timer == 0 and p.dir ~= p.last_wallrun_dir then local check_x = p.x + p.w/2 + (p.dir * (p.w/2 + 2)) if MapService.is_wallrunnable(check_x, p.y + p.h/2) then return "WALL_RUN", p.dir end end if p.vy >= 0 and move == p.dir and p.ledge_cooldown == 0 and p.invuln_timer == 0 then local ledge_y, ledge_x = MapService.find_ledge(p.x, p.y, p.dir, p.w, p.h) if ledge_y then p.y = ledge_y - (6 * p.base_scale) p.x = ledge_x + (p.dir == -1 and 8 or 0) - (p.w/2) - (p.dir * (p.w/2)) return "LEDGE" end end if InputService.jump and p.grounded then p.vy = CONST.jump_force; p.scale_x, p.scale_y = p.base_scale * 0.6, p.base_scale * 1.4 AudioService.play_sfx(0, 0) end return nil end } PlayerStates.CARTWHEEL = { enter = function(p) p.vy = 0 p.vx = p.dir * (CONST.max_spd * 1.8) p.scale_x, p.scale_y = p.base_scale * 0.8, p.base_scale * 0.7 ParticleService.spawn("DUST", p.x + p.w/2, p.y + p.h) end, update = function(p) p.vx = p.dir * (CONST.max_spd * 1.8) p.vy = math.min(p.vy + CONST.grav, 7) p.angle = p.angle + (p.dir * 0.5) local cx, cy = p.x + p.w/2, p.y + p.h/2 for _, e in ipairs(EnemyService.enemies) do if math.abs(cx - (e.x + e.w/2)) < (p.w/2 + e.w/2) and math.abs(cy - (e.y + e.h/2)) < (p.h/2 + e.h/2) then if e.flash_timer == 0 then e.hp = e.hp - 1 e.flash_timer = 15 ParticleService.spawn("SPARK", e.x + e.w/2, e.y + e.h/2) AudioService.play_sfx(5, 3) end end end if InputService.jump then p.vy = CONST.jump_force p.scale_x, p.scale_y = p.base_scale * 0.6, p.base_scale * 1.4 AudioService.play_sfx(0, 0) return "NORMAL" end if MapService.is_solid(p.x + p.w/2 + (p.dir * (p.w/2 - 4)), cy) then return "NORMAL" end if p.state_timer > 20 then return "NORMAL" end return nil end } PlayerStates.WALL_RUN = { enter = function(p, wall_dir) p.wall_dir = wall_dir p.last_wallrun_dir = wall_dir p.vy = -4.0 p.vx = 0 p.angle = -wall_dir * (math.pi / 2) ParticleService.spawn("DUST", p.x + (wall_dir == 1 and p.w or 0), p.y + p.h) end, update = function(p) p.vy = p.vy + CONST.grav local check_x = p.x + p.w/2 + (p.wall_dir * (p.w/2 + 2)) local move = (InputService.right and 1 or 0) - (InputService.left and 1 or 0) if not MapService.is_wallrunnable(check_x, p.y + p.h/2) or move == -p.wall_dir then p.angle = 0 return "NORMAL" end if InputService.jump then p.vy = CONST.jump_force p.vx = -p.wall_dir * (CONST.max_spd * 1.5) p.dir = -p.wall_dir p.angle = 0 AudioService.play_sfx(0, 0) return "NORMAL" end if p.vy >= 0 then p.angle = 0 return "NORMAL" end return nil end } PlayerStates.LEDGE = { enter = function(p) p.vx, p.vy = 0, 0; p.scale_x, p.scale_y = p.base_scale * 0.8, p.base_scale * 1.2; p.angle = 0; p.last_wallrun_dir = 0 end, update = function(p) p.muzzle_offset = {x = 0, y = -8 * p.base_scale} if InputService.down then p.ledge_cooldown = 15; return "NORMAL" elseif InputService.jump then p.ledge_cooldown = 15; p.vy = CONST.jump_force if InputService.left and p.dir == 1 then p.vx = -CONST.max_spd end if InputService.right and p.dir == -1 then p.vx = CONST.max_spd end AudioService.play_sfx(0, 0) return "NORMAL" elseif InputService.up then return "MANTLE" end return nil end } PlayerStates.MANTLE = { enter = function(p) p.start_x, p.start_y = p.x, p.y p.target_x = p.x + (p.dir * 12 * p.base_scale); p.target_y = p.y - (16 * p.base_scale) p.scale_x, p.scale_y = p.base_scale, p.base_scale end, update = function(p) if p.state_timer % 6 == 0 then ParticleService.spawn("SWEAT", p.x + p.w/2, p.y + (2 * p.base_scale), p.dir) end local duration = 15 local t = p.state_timer / duration p.x = p.start_x + (p.target_x - p.start_x) * t p.y = p.start_y + (p.target_y - p.start_y) * t - math.sin(t * math.pi) * (8 * p.base_scale) p.angle = t * math.pi * 2 * p.dir if p.state_timer >= duration then p.angle = 0; p.vx = p.dir * 2; p.vy = 0; return "NORMAL" end return nil end } PlayerStates.GRAPPLE = { enter = function(p, target) TelemetryService.log_event(1) AudioService.play_sfx(1, 1) WhipWeapon.fire_grapple(target.x, target.y) p.last_wallrun_dir = 0 end, update = function(p) p.muzzle_offset = {x = 0, y = -8 * p.base_scale} local cx, cy = p.x + p.w/2, p.y + p.h/2 local dx, dy = WhipWeapon.target_x - cx, WhipWeapon.target_y - cy local dist = math.sqrt(dx^2 + dy^2) if WhipWeapon.timer > WhipWeapon.stats.travel_spd then if dist < 16 then p.vy = CONST.jump_force * 0.8; WhipWeapon.cancel(); return "NORMAL" else p.angle = math.atan2(dy, dx * p.dir) p.scale_x = p.base_scale + (dist / WhipWeapon.stats.max_length) * 0.5 p.scale_y = p.base_scale - (dist / WhipWeapon.stats.max_length) * 0.3 p.vx = (dx / dist) * WhipWeapon.stats.pull_spd; p.vy = (dy / dist) * WhipWeapon.stats.pull_spd end else p.vx, p.vy = 0, -2 end if InputService.jump then p.vy = CONST.jump_force; WhipWeapon.cancel(); return "NORMAL" end return nil end } PlayerStates.TETHER_FIRE = { enter = function(p) p.muzzle_offset = {x = 10 * p.base_scale, y = 4 * p.base_scale}; p.scale_x, p.scale_y = p.base_scale * 1.1, p.base_scale * 0.9 TelemetryService.log_event(1) AudioService.play_sfx(1, 1) local dx, dy = InputService.get_aim_vector(p.dir) WhipWeapon.fire_tether(dx, dy) end, update = function(p) p.vy = math.min(p.vy + CONST.grav, 7) local move = (InputService.right and 1 or 0) - (InputService.left and 1 or 0) if move ~= 0 then p.vx = p.vx + move * (CONST.accel * 0.5) end p.vx = p.vx * 0.9 if p.vy > 0 then p.vy = p.vy * 0.75 end if WhipWeapon.mode == "TETHER_PULL" then return "TETHER_PULL" end if not WhipWeapon.active then return "NORMAL" end return nil end } PlayerStates.TETHER_PULL = { enter = function(p) p.angle = 0 end, update = function(p) p.muzzle_offset = {x = 0, y = -8 * p.base_scale} local cx, cy = p.x + p.w/2, p.y + p.h/2 local dx, dy = WhipWeapon.target_x - cx, WhipWeapon.target_y - cy local dist = math.sqrt(dx^2 + dy^2) if dist < 24 then p.y = WhipWeapon.target_y - (6 * p.base_scale) p.x = WhipWeapon.target_x - (p.w/2) - (p.dir * (p.w/2)) WhipWeapon.cancel(); return "LEDGE" else p.angle = math.atan2(dy, dx * p.dir) p.vx = (dx / dist) * WhipWeapon.stats.pull_spd * 1.5 p.vy = (dy / dist) * WhipWeapon.stats.pull_spd * 1.5 end if InputService.jump then p.vy = CONST.jump_force; WhipWeapon.cancel(); return "NORMAL" end return nil end } function Player.change_state(state_name, args) if Player.active_state_name == state_name then return end Player.active_state_name = state_name; Player.active_state = PlayerStates[state_name] Player.state_timer = 0 if Player.active_state.enter then Player.active_state.enter(Player, args) end end function Player.update() if Player.invuln_timer > 0 then Player.invuln_timer = Player.invuln_timer - 1 end Player.state_timer = Player.state_timer + 1 if Player.active_state_name ~= "SPAWN" then Player.scale_x = Player.scale_x + (Player.base_scale - Player.scale_x) * 0.2 Player.scale_y = Player.scale_y + (Player.base_scale - Player.scale_y) * 0.2 end if Player.active_state_name ~= "GRAPPLE" and Player.active_state_name ~= "MANTLE" and Player.active_state_name ~= "TETHER_PULL" and Player.active_state_name ~= "CARTWHEEL" and Player.active_state_name ~= "WALL_RUN" then Player.angle = Player.angle * 0.8 end if Player.active_state then local next_state, args = Player.active_state.update(Player) if next_state then Player.change_state(next_state, args) end end if Player.active_state_name ~= "MANTLE" and Player.active_state_name ~= "LEDGE" and Player.active_state_name ~= "SPAWN" then Player.apply_physics() end if Player.active_state_name == "NORMAL" then if not Player.grounded then if Player.vy < 0 then Player.set_anim("JUMP") else Player.set_anim("FALL") end elseif math.abs(Player.vx) > 0.5 then Player.set_anim("RUN") else Player.set_anim("IDLE") end elseif Player.active_state_name == "LEDGE" then Player.set_anim("LEDGE") elseif Player.active_state_name == "CARTWHEEL" then Player.set_anim("CARTWHEEL") elseif Player.active_state_name == "WALL_RUN" then Player.set_anim("WALLRUN") elseif Player.active_state_name == "MANTLE" then Player.set_anim("MANTLE") elseif Player.active_state_name == "GRAPPLE" or Player.active_state_name == "TETHER_PULL" then Player.set_anim("GRAPPLE") elseif Player.active_state_name == "SPAWN" then if Player.state_timer < 60 then Player.set_anim("SLEEP") else Player.set_anim("WAKE") end end local cur_anim = Player.animations[Player.anim] if cur_anim then Player.anim_timer = Player.anim_timer + 1 if Player.anim_timer >= cur_anim.spd then Player.anim_timer = 0 Player.anim_frame = (Player.anim_frame % #cur_anim.frames) + 1 end end end function Player.apply_physics() local next_x = Player.x + Player.vx local check_tx = (next_x + Player.w/2 + (Player.vx > 0 and (Player.w/2) or -(Player.w/2))) // 8 local check_ty = (Player.y + Player.h/2) // 8 if fget(mget(check_tx, check_ty), CONST.F_DOOR) and Player.has_boss_key then MapService.open_door(check_tx, check_ty) Player.has_boss_key = false end Player.x = Player.x + Player.vx if Player.vx > 0 and (MapService.is_solid(Player.x + Player.w, Player.y + 4) or MapService.is_solid(Player.x + Player.w, Player.y + Player.h - 4)) then Player.x = ((Player.x + Player.w) // 8) * 8 - Player.w; Player.vx = 0 elseif Player.vx < 0 and (MapService.is_solid(Player.x, Player.y + 4) or MapService.is_solid(Player.x, Player.y + Player.h - 4)) then Player.x = (Player.x // 8) * 8 + 8; Player.vx = 0 end Player.y = Player.y + Player.vy local was_grounded = Player.grounded Player.grounded = false if Player.vy > 0 and (MapService.is_solid(Player.x + 4, Player.y + Player.h) or MapService.is_solid(Player.x + Player.w - 4, Player.y + Player.h)) then Player.y = ((Player.y + Player.h) // 8) * 8 - Player.h; Player.vy = 0; Player.grounded = true Player.last_wallrun_dir = 0 if not was_grounded then ParticleService.spawn("DUST", Player.x + Player.w/2, Player.y + Player.h) end if Player.active_state_name == "NORMAL" and Player.scale_y > Player.base_scale then Player.scale_x, Player.scale_y = Player.base_scale * 1.3, Player.base_scale * 0.7 end elseif Player.vy < 0 and (MapService.is_solid(Player.x + 4, Player.y) or MapService.is_solid(Player.x + Player.w - 4, Player.y)) then Player.y = (Player.y // 8) * 8 + 8; Player.vy = 0 end local p_cx, p_bottom = (Player.x + Player.w/2), (Player.y + Player.h - 4) if MapService.is_spike(p_cx, p_bottom) and Player.invuln_timer == 0 then Player.hurt(0) CameraService.shake = 8 end local p_tx, p_ty = p_cx // 8, (Player.y + Player.h/2) // 8 if fget(mget(p_tx, p_ty), CONST.F_SECRET) then MapService.reveal_secret(p_tx, p_ty) end PlatformService.collide_player(Player) end function Player.draw(cam_x, cam_y) if Player.invuln_timer > 0 and (Player.invuln_timer // 4) % 2 == 0 then return end local cx, cy = Player.x + Player.w/2 - cam_x, Player.y + Player.h/2 - cam_y local cur_anim = Player.animations[Player.anim] local sprite_id = cur_anim and cur_anim.frames[Player.anim_frame] or 256 RenderService.xspr(sprite_id, cx, cy, 2, 2, Player.angle, Player.dir * Player.scale_x, Player.scale_y, 8, 8, 0) end -- ========================================== -- [ CONTEXTS ] -- ========================================== MainMenuContext = { cursor_y = 0 } function MainMenuContext.enter() MainMenuContext.cursor_y = 0 AudioService.play_bgm(0) end function MainMenuContext.update() if InputService.down and MainMenuContext.cursor_y == 0 then MainMenuContext.cursor_y = 1 AudioService.play_sfx(8, 2) end if InputService.up and MainMenuContext.cursor_y == 1 then MainMenuContext.cursor_y = 0 AudioService.play_sfx(8, 2) end if InputService.jump then AudioService.play_sfx(9, 2) if MainMenuContext.cursor_y == 0 then RootContext.switch(GameplayContext) else exit() end end end function MainMenuContext.draw() cls(0) print("PROJECT PRIMATE", 75, 40, 15, true, 1) spr(384, 85, 70 + (MainMenuContext.cursor_y * 15), 0, 1, 0, 0, 1, 1) print("START GAME", 95, 72, 14, true, 1) print("QUIT", 95, 87, 14, true, 1) print("DEATHS:"..TelemetryService.falls.." BANANAS:"..TelemetryService.bananas, 55, 120, 5) end GameplayContext = { transitioning = false, paused = false } function GameplayContext.enter() sync(4, 0, false) AudioService.play_bgm(1) Player.x, Player.y = 120, 172 Player.hp = Player.max_hp Player.has_boss_key = false Player.change_state("NORMAL") WhipWeapon.cancel() MapService.init_entities() BackgroundService.init() MapService.rooms = { {x = 0, y = 136, w = 240, h = 136}, {x = 240, y = 136, w = 480, h = 272}, {x = 720, y = 136, w = 240, h = 272}, } CameraService.bounds = { min_x = 0, min_y = 0, max_x = 240, max_y = 136 } CameraService.x = 0; CameraService.y = 136 GameplayContext.transitioning = false GameplayContext.paused = false end function GameplayContext.update() if InputService.pause and not GS ameplayContext.transitioning then if InputService.down then UIService.trigger_coin_ui("BANANA") UIService.trigger_coin_ui("GEM") else GameplayContext.paused = not GameplayContext.paused UIService.cursor_idx = 1 end end if GameplayContext.paused then if InputService.down and UIService.cursor_idx == 1 then UIService.cursor_idx = 2; AudioService.play_sfx(8, 2) end if InputService.up and UIService.cursor_idx == 2 then UIService.cursor_idx = 1; AudioService.play_sfx(8, 2) end if InputService.jump then AudioService.play_sfx(9, 2) if UIService.cursor_idx == 1 then GameplayContext.paused = false else RootContext.switch(MainMenuContext) end end return end if GameplayContext.transitioning then CameraService.update() return end if DialogueService.active then DialogueService.update() ParticleService.update() FXService.update() CameraService.update() return end PlatformService.update() Player.update() MapService.check_transition(Player.x + Player.w/2, Player.y + Player.h/2) if Player.y > 600 then TelemetryService.log_event(0) RootContext.switch(GameplayContext) end WhipWeapon.update() EnemyService.update() NPCService.update() BossService.update() CurrencyService.update() ParticleService.update() FXService.update() UIService.update() CameraService.update() end function GameplayContext.draw() local cx, cy = CameraService.get_cam() BackgroundService.draw(cx, cy) map(cx//8, cy//8, 32, 18, -(cx%8), -(cy%8), 0) CurrencyService.draw(cx, cy) PlatformService.draw(cx, cy) EnemyService.draw(cx, cy) NPCService.draw(cx, cy) BossService.draw(cx, cy) ParticleService.draw(cx, cy) Player.draw(cx, cy) WhipWeapon.draw(cx, cy) UIService.draw_hud() FXService.draw() DialogueService.draw() if GameplayContext.paused then rect(70, 40, 100, 56, 0) rectb(70, 40, 100, 56, 5) print("PAUSED", 105, 45, 15) spr(384, 85, 60 + ((UIService.cursor_idx-1) * 15), 0, 1, 0, 0, 1, 1) print("RESUME", 95, 62, 14) print("QUIT TO TITLE", 95, 77, 14) end end -- ========================================== -- [ ENGINE CORE ] -- ========================================== RootContext = { active = nil } function RootContext.switch(new_context) if RootContext.active and RootContext.active.exit then RootContext.active.exit() end RootContext.active = new_context if RootContext.active and RootContext.active.enter then RootContext.active.enter() end end TelemetryService.init() RootContext.switch(MainMenuContext) function TIC() InputService.update() if not TimeService.update() then if RootContext.active and RootContext.active.update then RootContext.active.update() end end if RootContext.active and RootContext.active.draw then RootContext.active.draw() end end