fire-demos.txt — complete source of the three inline demos in "How the fire particles in the horse game work" (https://a327ex.com/posts/how-the-fire-effect-works) This is the EXACT code running on that page, unedited. Each demo is a self-contained Anchor engine script (engine API only, no framework): the site's game host provides layer/input/font shadows and update()/draw() hooks, but nothing here depends on it conceptually — port the update/draw bodies to any engine with circles, layers-as-textures and a fragment shader (or use the two-pass trick described in the post instead of the shader) and you have the effect. Contents: 1. assets/outline.frag — the derived-outline pass (shared by the game and all three demos: black wherever a 5x5 neighborhood has alpha, stamped UNDER the layer = silhouette outline of the whole mass) 2. fire-demo-1/main.lua — one ember in slow motion (rise/shrink/cool) 3. fire-demo-2/main.lua — the emitter (rate/spread, tongues vs core) 4. fire-demo-3/main.lua — full effect + lifecycle (ignite burst, burn, die-down, auto-reignite), outline/shadow toggles, palette swap ================================================================================ 1. assets/outline.frag ================================================================================ // Outline shader - samples neighbors to detect alpha edges // Outputs black where any neighbor has alpha, creating silhouette outline in vec2 TexCoord; out vec4 FragColor; uniform sampler2D u_texture; uniform vec2 u_pixel_size; void main() { // Sample center pixel vec4 center = texture(u_texture, TexCoord); // Sample 24 neighbors in 5x5 grid (excluding center) float x = u_pixel_size.x; float y = u_pixel_size.y; float a = 0.0; // Row -2 a += texture(u_texture, TexCoord + vec2(-2.0*x, -2.0*y)).a; a += texture(u_texture, TexCoord + vec2(-1.0*x, -2.0*y)).a; a += texture(u_texture, TexCoord + vec2( 0.0, -2.0*y)).a; a += texture(u_texture, TexCoord + vec2( 1.0*x, -2.0*y)).a; a += texture(u_texture, TexCoord + vec2( 2.0*x, -2.0*y)).a; // Row -1 a += texture(u_texture, TexCoord + vec2(-2.0*x, -1.0*y)).a; a += texture(u_texture, TexCoord + vec2(-1.0*x, -1.0*y)).a; a += texture(u_texture, TexCoord + vec2( 0.0, -1.0*y)).a; a += texture(u_texture, TexCoord + vec2( 1.0*x, -1.0*y)).a; a += texture(u_texture, TexCoord + vec2( 2.0*x, -1.0*y)).a; // Row 0 (skip center) a += texture(u_texture, TexCoord + vec2(-2.0*x, 0.0)).a; a += texture(u_texture, TexCoord + vec2(-1.0*x, 0.0)).a; // center skipped a += texture(u_texture, TexCoord + vec2( 1.0*x, 0.0)).a; a += texture(u_texture, TexCoord + vec2( 2.0*x, 0.0)).a; // Row +1 a += texture(u_texture, TexCoord + vec2(-2.0*x, 1.0*y)).a; a += texture(u_texture, TexCoord + vec2(-1.0*x, 1.0*y)).a; a += texture(u_texture, TexCoord + vec2( 0.0, 1.0*y)).a; a += texture(u_texture, TexCoord + vec2( 1.0*x, 1.0*y)).a; a += texture(u_texture, TexCoord + vec2( 2.0*x, 1.0*y)).a; // Row +2 a += texture(u_texture, TexCoord + vec2(-2.0*x, 2.0*y)).a; a += texture(u_texture, TexCoord + vec2(-1.0*x, 2.0*y)).a; a += texture(u_texture, TexCoord + vec2( 0.0, 2.0*y)).a; a += texture(u_texture, TexCoord + vec2( 1.0*x, 2.0*y)).a; a += texture(u_texture, TexCoord + vec2( 2.0*x, 2.0*y)).a; // Clamp to 0-1 a = min(a, 1.0); // Output black outline where neighbors have alpha (but center doesn't) // This creates the silhouette effect FragColor = vec4(0.0, 0.0, 0.0, a); } ================================================================================ 2. fire-demo-1/main.lua ================================================================================ -- fire-demo-1 — anatomy of ONE ember, in slow motion. -- Companion demo for the fire-particles post. Engine-API-only (the game_host -- sandbox provides layer/input/font shadows; colors via the engine's rgba()). -- The scene is the GAME's: its checkerboard seen from above (top-down), the -- ember rising toward the camera off a shadow pinned to the board, and the -- outline DERIVED from the fire layer's silhouette by the game's own -- outline.frag — not drawn per circle. Ember numbers are copied verbatim -- from knightvspawns main.lua (ember:new / ember:update / ember:draw_shadow). -- UI = the emoji-template skin, hand-rolled: white LanaPixel labels on -- rounded chips (green = on, fg_dark = off, white on hover), fills on a -- panel layer and glyphs on a content layer, EACH with its own derived -- outline — exactly how the game's ui_panel/ui_content tiers get their look. -- CLICK anywhere to launch an ember at the cursor; the three toggles each -- remove one ingredient so you can see what it contributes. input_bind('click', 'mouse:1') -- the sandbox's bind registry (env shadow) board_layer = layer_create('board') -- checker + ground shadows (painter order) fire_layer = layer_create('fire') -- ember circles ONLY — the outline source fire_out = layer_create('fire_out') panel_layer = layer_create('panel') -- chip fills (emoji ui_panel tier) panel_out = layer_create('panel_out') content_layer = layer_create('content') -- labels/knobs (emoji ui_content tier) content_out = layer_create('content_out') outline_shader = shader_load_file('assets/outline.frag') font_load('fd1_ui', 'assets/LanaPixel.ttf', 11) -- the game's UI font ('main') UI_FONT = 'fd1_ui' FONT_H = font_get_height(UI_FONT) CHIP_H = FONT_H + 6 -- ui_button: pad_y 3 W, H = engine_get_width(), engine_get_height() TIME_SCALE = 0.22 -- slow motion: one life reads like a diagram SQUARE = 30 -- the game's tile size CY = 92 -- center tile row's center; flames top out around y=42 -- game palette (emoji/palette.lua) COL_LIGHT = rgba(231, 232, 233, 255) -- fg (cream squares, button fill) COL_DARK = rgba(128, 128, 128, 255) -- gray squares COL_FG_DARK = rgba(201, 202, 203, 255) -- off/disabled fill COL_GREEN = rgba(122, 179, 87, 255) -- accent: toggled ON COL_WHITE = rgba(255, 255, 255, 255) embers = {} toggles = { { key = 'rise', label = 'RISE', on = true }, { key = 'shrink', label = 'SHRINK', on = true }, { key = 'cool', label = 'COOL', on = true }, } spawn_t = 0 local function opt(key) for _, t in ipairs(toggles) do if t.key == key then return t.on end end end -- emoji ui_button footprint: label width + 2*8 padding, radius 4 local function chip_w(label) return font_get_text_width(UI_FONT, label) + 16 end local function chip_rect(i) -- 8px gaps: each chip's derived outline eats 2px per side, so a 4px gap -- lets the black rims touch — 8 keeps visible air between them local x = 6 for k = 1, i - 1 do x = x + chip_w(toggles[k].label) + 8 end return x, 5, chip_w(toggles[i].label), CHIP_H end local function pt_in(mx, my, x, y, w, h) return mx >= x and mx < x + w and my >= y and my < y + h end -- the emoji-template chip: rounded fill on the panel tier, pixel-snapped -- white Lana label on the content tier (the game's ui_button label math) local function chip(x, y, w, h, label, fill) layer_rounded_rectangle(panel_layer, x, y, w, h, 4, fill) layer_text(content_layer, label, UI_FONT, math.floor(x + w/2 - font_get_text_width(UI_FONT, label)/2), math.floor(y + h/2 - FONT_H/2 + 1) + 1, COL_WHITE) end -- exact game params: a rising tongue (vz 28..78, rise 130, rs 2.2..4.2) local function spawn_ember(x, y) local a = math.random()*math.pi*2 local sp = 6 + math.random()*16 local e = { x = x, y = y, z = math.random()*3, vx = sp*math.cos(a), vy = sp*math.sin(a)*0.5, -- vy flattened: top-down depth vz = 28 + math.random()*50, rise = 130, rs0 = 2.2 + math.random()*2.0, t = 0, life = 0.4 + math.random()*0.3, } e.hold = e.life*(0.25 + math.random()*0.2) e.rs = e.rs0 embers[#embers + 1] = e end function update(dt) W, H = engine_get_width(), engine_get_height() local sdt = dt*TIME_SCALE spawn_t = spawn_t - dt if spawn_t <= 0 then -- ambient: one ember on a beat spawn_ember(W/2, CY) spawn_t = 1.1 end local mx, my = mouse_position() if is_pressed('click') then -- chip hit-test first; else spawn at the cursor local hit = false for i, t in ipairs(toggles) do local x, y, w, h = chip_rect(i) if pt_in(mx, my, x, y, w, h) then t.on = not t.on; hit = true end end if not hit and my > CHIP_H + 9 then spawn_ember(mx, my) end end for i = #embers, 1, -1 do local e = embers[i] e.t = e.t + sdt if e.t >= e.life then table.remove(embers, i) else if opt('rise') then e.vz = e.vz + e.rise*sdt -- accelerate upward... e.z = e.z + e.vz*sdt -- ...along the fake height axis end e.x, e.y = e.x + e.vx*sdt, e.y + e.vy*sdt e.vx, e.vy = e.vx - e.vx*3*sdt, e.vy - e.vy*3*sdt e.rs = e.rs0 if opt('shrink') and e.t >= e.hold then e.rs = e.rs0*(1 - (e.t - e.hold)/(e.life - e.hold)) end end end end -- the game's checker, anchored so one tile is centered at (W/2, CY) local function draw_board() local ox, oy = W/2 - SQUARE/2, CY - SQUARE/2 local nx = math.ceil(W/(2*SQUARE)) + 1 for gy = -3, 3 do for gx = -nx, nx do local sx, sy = ox + gx*SQUARE, oy + gy*SQUARE if sx < W and sx + SQUARE > 0 and sy < H and sy + SQUARE > 0 then layer_rectangle(board_layer, sx, sy, SQUARE, SQUARE, (gx + gy)%2 == 0 and COL_LIGHT or COL_DARK) end end end end function draw() draw_board() for _, e in ipairs(embers) do -- ground shadow: pinned to the board at (x, y), shrinking as the ember -- climbs — the game's exact math (alpha 105, ellipse 0.42, z/22 shrink) local uz = math.min(math.max(e.z/22, 0), 1) layer_push(board_layer, e.x, e.y, 0, 1, 0.42) layer_circle(board_layer, 0, 0, e.rs0*0.9*(1 - 0.6*uz), rgba(0, 0, 0, 105)) layer_pop(board_layer) -- the ember: a PLAIN colored circle — the outline comes from the derived pass local u = e.t/e.life local r, g, b = 253 - 30*u, 205 - 168*u, 86 - 22*u -- yellow -> red if not opt('cool') then r, g, b = 253, 205, 86 end layer_circle(fire_layer, e.x, e.y - e.z, e.rs, rgba(math.floor(r), math.floor(g), math.floor(b), 255)) end -- chips: green = on, fg_dark = off, white on hover (the emoji skin) local mx, my = mouse_position() for i, t in ipairs(toggles) do local x, y, w, h = chip_rect(i) local fill = pt_in(mx, my, x, y, w, h) and COL_WHITE or (t.on and COL_GREEN or COL_FG_DARK) chip(x, y, w, h, t.label, fill) end -- hint: bare outlined white Lana, the game's HUD-text treatment local hint = 'click to spawn' layer_text(content_layer, hint, UI_FONT, math.floor(W - font_get_text_width(UI_FONT, hint) - 8), 9, COL_WHITE) layer_render(board_layer) -- execute the queued shapes into each FBO... layer_render(fire_layer) layer_render(panel_layer) layer_render(content_layer) -- ...derive the outlines (the game's pass: outline.frag turns any -- transparent pixel within 2px of alpha black)... shader_set_vec2_immediate(outline_shader, 'u_pixel_size', 1/W, 1/H) layer_clear(fire_out) layer_draw_from(fire_out, fire_layer, outline_shader) layer_clear(panel_out) layer_draw_from(panel_out, panel_layer, outline_shader) layer_clear(content_out) layer_draw_from(content_out, content_layer, outline_shader) -- ...then composite bottom-to-top, each outline directly beneath its layer layer_draw(board_layer) layer_draw(fire_out) layer_draw(fire_layer) layer_draw(panel_out) layer_draw(panel_layer) layer_draw(content_out) layer_draw(content_layer) end ================================================================================ 3. fire-demo-2/main.lua ================================================================================ -- fire-demo-2 — the EMITTER: one burning board tile streaming embers at full -- speed. Companion demo for the fire-particles post. Top-down like the game: -- the spawn boxes are AREAS of the tile (the game's update_fires boxes), the -- shadows sit on the board, and the outline is the derived silhouette pass. -- Defaults are the game's exact numbers — a tongue every 0.035s plus a -- base-core ember per tick. UI = the emoji-template skin (LanaPixel labels, -- ui_slider's fg_dark track + green fill + white knob, chip toggles), on -- panel/content tiers with their own derived outlines like the game's UI. -- Drag the sliders; toggle the two layers (rising TONGUES vs the base CORE). input_bind('click', 'mouse:1') -- the sandbox's bind registry (env shadow) board_layer = layer_create('board') fire_layer = layer_create('fire') fire_out = layer_create('fire_out') panel_layer = layer_create('panel') panel_out = layer_create('panel_out') content_layer = layer_create('content') content_out = layer_create('content_out') outline_shader = shader_load_file('assets/outline.frag') font_load('fd2_ui', 'assets/LanaPixel.ttf', 11) -- the game's UI font ('main') UI_FONT = 'fd2_ui' FONT_H = font_get_height(UI_FONT) CHIP_H = FONT_H + 6 W, H = engine_get_width(), engine_get_height() SQUARE = 30 -- the game's tile size CY = 92 -- the burning tile's center row COL_LIGHT = rgba(231, 232, 233, 255) -- game palette: fg / gray checker COL_DARK = rgba(128, 128, 128, 255) COL_FG_DARK = rgba(201, 202, 203, 255) COL_GREEN = rgba(122, 179, 87, 255) COL_WHITE = rgba(255, 255, 255, 255) TRACK_W = 64 embers = {} sliders = { -- defaults = the game's values: 28.6 Hz tick, spawn-box scale 1.0 { key = 'rate', label = 'RATE', v = 0.305 }, -- 6..80 Hz emit tick { key = 'spread', label = 'SPREAD', v = 0.375 }, -- 0.25x..2.25x spawn boxes } toggles = { { key = 'tongues', label = 'TONGUES', on = true }, { key = 'core', label = 'CORE', on = true }, } emit_t, drag = 0, nil local function sv(key) for _, s in ipairs(sliders) do if s.key == key then return s.v end end end local function opt(key) for _, t in ipairs(toggles) do if t.key == key then return t.on end end end local function rand(a, b) return a + math.random()*(b - a) end local function pt_in(mx, my, x, y, w, h) return mx >= x and mx < x + w and my >= y and my < y + h end local function chip_w(label) return font_get_text_width(UI_FONT, label) + 16 end -- toggle chips, right-aligned (x depends on live W); 8px gaps so the chips' -- derived outlines (2px per side) keep visible air between them local function toggle_rect(i) local x = W - 6 for k = #toggles, i, -1 do x = x - chip_w(toggles[k].label) - 8 end return x + 8, 5, chip_w(toggles[i].label), CHIP_H end -- slider rows at top-left: label (content) + track (panel); grab the whole row local SL_LX = 6 local SL_LW = math.max(font_get_text_width(UI_FONT, 'RATE'), font_get_text_width(UI_FONT, 'SPREAD')) + 6 local function slider_rect(i) return SL_LX, 5 + (i - 1)*(CHIP_H + 6), SL_LW + TRACK_W, CHIP_H end local function chip(x, y, w, h, label, fill) layer_rounded_rectangle(panel_layer, x, y, w, h, 4, fill) layer_text(content_layer, label, UI_FONT, math.floor(x + w/2 - font_get_text_width(UI_FONT, label)/2), math.floor(y + h/2 - FONT_H/2 + 1) + 1, COL_WHITE) end -- exact game params (knightvspawns spawn_ember / spawn_base_ember) local function spawn(kind) local s = 0.25 + sv('spread')*2.0 -- spawn-box scale; 1.0 = the game local cx = W/2 local e = { t = 0 } if kind == 'tongue' then -- full box, full rise: the visible flames e.x = cx + rand(-SQUARE*0.28, SQUARE*0.28)*s e.y = CY + rand(-SQUARE*0.2, SQUARE*0.22)*s e.z = math.random()*3 local a = math.random()*math.pi*2 local sp = rand(6, 22) e.vx, e.vy = sp*math.cos(a), sp*math.sin(a)*0.5 e.vz, e.rise = rand(28, 78), 130 e.rs0 = rand(2.2, 4.2) e.life = rand(0.4, 0.7) else -- base core: tight, low, barely climbs e.x = cx + rand(-SQUARE*0.12, SQUARE*0.12)*s e.y = CY + rand(-SQUARE*0.04, SQUARE*0.16)*s e.z = math.random()*2 local a = math.random()*math.pi*2 local sp = rand(1, 8) e.vx, e.vy = sp*math.cos(a), sp*math.sin(a)*0.5 e.vz, e.rise = rand(2, 16), 25 e.rs0 = rand(2.6, 4.8) e.life = rand(0.32, 0.55) end e.hold = e.life*rand(0.25, 0.45) e.rs = e.rs0 embers[#embers + 1] = e end function update(dt) W, H = engine_get_width(), engine_get_height() local mx, my = mouse_position() if is_pressed('click') then for i, t in ipairs(toggles) do local x, y, w, h = toggle_rect(i) if pt_in(mx, my, x, y, w, h) then t.on = not t.on end end for i, s in ipairs(sliders) do local x, y, w, h = slider_rect(i) if pt_in(mx, my, x, y, w, h) then drag = s end end end if drag then if is_down('click') then drag.v = math.max(0, math.min(1, (mx - (SL_LX + SL_LW))/TRACK_W)) else drag = nil end end -- the game's emitter: on each 0.035s tick, one tongue + one base ember local rate = 6 + sv('rate')*74 emit_t = emit_t + dt local step = 1/rate while emit_t >= step do emit_t = emit_t - step if opt('tongues') then spawn('tongue') end if opt('core') then spawn('core') end end for i = #embers, 1, -1 do local e = embers[i] e.t = e.t + dt if e.t >= e.life then table.remove(embers, i) else e.vz = e.vz + e.rise*dt e.z = e.z + e.vz*dt e.x, e.y = e.x + e.vx*dt, e.y + e.vy*dt e.vx, e.vy = e.vx - e.vx*3*dt, e.vy - e.vy*3*dt e.rs = (e.t < e.hold) and e.rs0 or e.rs0*(1 - (e.t - e.hold)/(e.life - e.hold)) end end end local function draw_board() local ox, oy = W/2 - SQUARE/2, CY - SQUARE/2 local nx = math.ceil(W/(2*SQUARE)) + 1 for gy = -3, 3 do for gx = -nx, nx do local sx, sy = ox + gx*SQUARE, oy + gy*SQUARE if sx < W and sx + SQUARE > 0 and sy < H and sy + SQUARE > 0 then layer_rectangle(board_layer, sx, sy, SQUARE, SQUARE, (gx + gy)%2 == 0 and COL_LIGHT or COL_DARK) end end end end function draw() draw_board() for _, e in ipairs(embers) do local uz = math.min(math.max(e.z/22, 0), 1) layer_push(board_layer, e.x, e.y, 0, 1, 0.42) layer_circle(board_layer, 0, 0, e.rs0*0.9*(1 - 0.6*uz), rgba(0, 0, 0, 105)) layer_pop(board_layer) local u = e.t/e.life layer_circle(fire_layer, e.x, e.y - e.z, e.rs, rgba(math.floor(253 - 30*u), math.floor(205 - 168*u), math.floor(86 - 22*u), 255)) end local mx, my = mouse_position() -- sliders: white Lana label + the game's ui_slider (fg_dark rounded track, -- green fill, white knob on the content tier) for i, s in ipairs(sliders) do local x, y, w, h = slider_rect(i) layer_text(content_layer, s.label, UI_FONT, x, math.floor(y + h/2 - FONT_H/2 + 1) + 1, COL_WHITE) local tx, ty = x + SL_LW, y + (h - 4)/2 layer_rounded_rectangle(panel_layer, tx, ty, TRACK_W, 4, 2, COL_FG_DARK) local fw = math.floor(TRACK_W*s.v + 0.5) if fw > 0 then layer_rounded_rectangle(panel_layer, tx, ty, fw, 4, 2, COL_GREEN) end layer_circle(content_layer, tx + TRACK_W*s.v, ty + 2, 4, COL_WHITE) end for i, t in ipairs(toggles) do local x, y, w, h = toggle_rect(i) local fill = pt_in(mx, my, x, y, w, h) and COL_WHITE or (t.on and COL_GREEN or COL_FG_DARK) chip(x, y, w, h, t.label, fill) end layer_render(board_layer) layer_render(fire_layer) layer_render(panel_layer) layer_render(content_layer) shader_set_vec2_immediate(outline_shader, 'u_pixel_size', 1/W, 1/H) layer_clear(fire_out) layer_draw_from(fire_out, fire_layer, outline_shader) layer_clear(panel_out) layer_draw_from(panel_out, panel_layer, outline_shader) layer_clear(content_out) layer_draw_from(content_out, content_layer, outline_shader) layer_draw(board_layer) layer_draw(fire_out) layer_draw(fire_layer) layer_draw(panel_out) layer_draw(panel_layer) layer_draw(content_out) layer_draw(content_layer) end ================================================================================ 4. fire-demo-3/main.lua ================================================================================ -- fire-demo-3 — the FULL effect with its lifecycle. Companion demo for the -- fire-particles post. Click a board tile to IGNITE it (the game's one-shot -- 10-ember burst); it burns for a bit, then DIES DOWN the way the game's -- fires do — tongues cut instantly, the base core keeps emitting for half a -- second at falling intensity and shrinking size. A tile auto-reignites when -- everything has gone out, so the cycle is always visible. -- OUTLINE toggles the derived silhouette pass (the game's outline.frag over -- the fire layer), SHADOW the ground ellipses; the palette chip swaps the two -- color endpoints (FIRE / MAGIC / TOXIC) over the unchanged skeleton. -- UI = the emoji-template skin on panel/content tiers with derived outlines -- (white LanaPixel labels, green/fg_dark/white chip fills, cream buttons). input_bind('click', 'mouse:1') -- the sandbox's bind registry (env shadow) board_layer = layer_create('board') fire_layer = layer_create('fire') fire_out = layer_create('fire_out') panel_layer = layer_create('panel') panel_out = layer_create('panel_out') content_layer = layer_create('content') content_out = layer_create('content_out') outline_shader = shader_load_file('assets/outline.frag') font_load('fd3_ui', 'assets/LanaPixel.ttf', 11) -- the game's UI font ('main') UI_FONT = 'fd3_ui' FONT_H = font_get_height(UI_FONT) CHIP_H = FONT_H + 6 W, H = engine_get_width(), engine_get_height() SQUARE = 30 -- the game's tile size CY = 92 -- the main tile row's centers sit at this y BURN = 2.4 -- seconds a tile burns (the game counts march beats) FADE = 0.5 -- the game's FIRE_FADE: base-core die-down time COL_LIGHT = rgba(231, 232, 233, 255) -- game palette: fg / gray checker COL_DARK = rgba(128, 128, 128, 255) COL_FG_DARK = rgba(201, 202, 203, 255) COL_GREEN = rgba(122, 179, 87, 255) COL_WHITE = rgba(255, 255, 255, 255) -- FIRE = the game's exact yellow -> red (emoji/palette.lua) PALETTES = { { name = 'FIRE', hot = { 253, 205, 86 }, cold = { 223, 37, 64 } }, { name = 'MAGIC', hot = { 150, 190, 255 }, cold = { 150, 60, 235 } }, { name = 'TOXIC', hot = { 190, 245, 80 }, cold = { 30, 160, 70 } }, } pal = 1 embers = {} fires = {} -- key 'gx|gy' -> { gx, gy, t, fade } (fade nil = burning) toggles = { { key = 'outline', label = 'OUTLINE', on = true }, { key = 'shadow', label = 'SHADOW', on = true }, } emit_t, no_fire_t = 0, 10 -- start "cold" so a tile ignites on first sight local function opt(key) for _, t in ipairs(toggles) do if t.key == key then return t.on end end end local function rand(a, b) return a + math.random()*(b - a) end local function cell_center(gx, gy) return W/2 + gx*SQUARE, CY + gy*SQUARE end local function pt_in(mx, my, x, y, w, h) return mx >= x and mx < x + w and my >= y and my < y + h end local function chip_w(label) return font_get_text_width(UI_FONT, label) + 16 end -- 8px gaps: the chips' derived outlines eat 2px per side of a gap local function toggle_rect(i) local x = 6 for k = 1, i - 1 do x = x + chip_w(toggles[k].label) + 8 end return x, 5, chip_w(toggles[i].label), CHIP_H end -- palette button: right-aligned, width fixed to the longest name (stable -- footprint while cycling), cream fill like the game's secondary ui_button PAL_W = 0 for _, p in ipairs(PALETTES) do PAL_W = math.max(PAL_W, chip_w(p.name)) end local function pal_rect() return W - PAL_W - 6, 5, PAL_W, CHIP_H end local function chip(x, y, w, h, label, fill) layer_rounded_rectangle(panel_layer, x, y, w, h, 4, fill) layer_text(content_layer, label, UI_FONT, math.floor(x + w/2 - font_get_text_width(UI_FONT, label)/2), math.floor(y + h/2 - FONT_H/2 + 1) + 1, COL_WHITE) end -- exact game params (knightvspawns spawn_ember / spawn_base_ember) local function spawn_ember(x, y, kind, inten) local e = { t = 0, x = x, y = y } local a = math.random()*math.pi*2 if kind == 'core' then local sp = rand(1, 8) e.vx, e.vy = sp*math.cos(a), sp*math.sin(a)*0.5 e.z, e.vz, e.rise = math.random()*2, rand(2, 16), 25 e.rs0 = rand(2.6, 4.8)*(0.45 + 0.55*inten) -- smaller as the fire dies e.life = rand(0.32, 0.55) else local sp = rand(6, 22) e.vx, e.vy = sp*math.cos(a), sp*math.sin(a)*0.5 e.z, e.vz, e.rise = math.random()*3, rand(28, 78), 130 e.rs0 = rand(2.2, 4.2) e.life = rand(0.4, 0.7) end e.hold = e.life*rand(0.25, 0.45) e.rs = e.rs0 embers[#embers + 1] = e end -- the game's ignite_tile: the fire arrives with a one-shot burst of 10 embers local function ignite(gx, gy) fires[gx .. '|' .. gy] = { gx = gx, gy = gy, t = 0 } local cx, cy = cell_center(gx, gy) for k = 1, 10 do spawn_ember(cx + rand(-4, 4), cy + 4 + rand(-3, 3), 'tongue') end end function update(dt) W, H = engine_get_width(), engine_get_height() local mx, my = mouse_position() if is_pressed('click') then local hit = false for i, t in ipairs(toggles) do local x, y, w, h = toggle_rect(i) if pt_in(mx, my, x, y, w, h) then t.on = not t.on; hit = true end end local px, py, pw, ph = pal_rect() if pt_in(mx, my, px, py, pw, ph) then pal = pal%#PALETTES + 1; hit = true end if not hit and my > CHIP_H + 9 then -- ignite the tile under the cursor local gx = math.floor((mx - (W/2 - SQUARE/2))/SQUARE) local gy = math.floor((my - (CY - SQUARE/2))/SQUARE) ignite(gx, gy) -- re-clicking a burning tile re-fuels it end end -- tile lifecycle: burn, then the base core fades out over FADE seconds local any = false for k, f in pairs(fires) do any = true f.t = f.t + dt if f.t >= BURN then f.fade = (f.fade or 0) + dt if f.fade >= FADE then fires[k] = nil end end end no_fire_t = any and 0 or no_fire_t + dt if no_fire_t > 0.9 then -- keep the demo alive: reignite local span = math.max(1, math.floor((W/2 - 40)/SQUARE)) ignite(math.random(-span, span), 0) no_fire_t = 0 end -- the game's update_fires: on each 0.035s tick, per burning tile one tongue -- (only while it actually burns) + one base ember with probability `inten` emit_t = emit_t + dt local emit = emit_t >= 0.035 if emit then emit_t = 0 end if emit then for _, f in pairs(fires) do local burning = f.fade == nil local inten = burning and 1 or math.max(0, 1 - f.fade/FADE) local cx, cy = cell_center(f.gx, f.gy) if burning then spawn_ember(cx + rand(-SQUARE*0.28, SQUARE*0.28), cy + rand(-SQUARE*0.2, SQUARE*0.22), 'tongue') end if math.random() < inten then spawn_ember(cx + rand(-SQUARE*0.12, SQUARE*0.12), cy + rand(-SQUARE*0.04, SQUARE*0.16), 'core', inten) end end end for i = #embers, 1, -1 do local e = embers[i] e.t = e.t + dt if e.t >= e.life then table.remove(embers, i) else e.vz = e.vz + e.rise*dt e.z = e.z + e.vz*dt e.x, e.y = e.x + e.vx*dt, e.y + e.vy*dt e.vx, e.vy = e.vx - e.vx*3*dt, e.vy - e.vy*3*dt e.rs = (e.t < e.hold) and e.rs0 or e.rs0*(1 - (e.t - e.hold)/(e.life - e.hold)) end end end local function draw_board() local ox, oy = W/2 - SQUARE/2, CY - SQUARE/2 local nx = math.ceil(W/(2*SQUARE)) + 1 for gy = -3, 3 do for gx = -nx, nx do local sx, sy = ox + gx*SQUARE, oy + gy*SQUARE if sx < W and sx + SQUARE > 0 and sy < H and sy + SQUARE > 0 then layer_rectangle(board_layer, sx, sy, SQUARE, SQUARE, (gx + gy)%2 == 0 and COL_LIGHT or COL_DARK) end end end end function draw() draw_board() local P = PALETTES[pal] for _, e in ipairs(embers) do if opt('shadow') then local uz = math.min(math.max(e.z/22, 0), 1) layer_push(board_layer, e.x, e.y, 0, 1, 0.42) layer_circle(board_layer, 0, 0, e.rs0*0.9*(1 - 0.6*uz), rgba(0, 0, 0, 105)) layer_pop(board_layer) end local u = e.t/e.life local r = math.floor(P.hot[1] + (P.cold[1] - P.hot[1])*u) local g = math.floor(P.hot[2] + (P.cold[2] - P.hot[2])*u) local b = math.floor(P.hot[3] + (P.cold[3] - P.hot[3])*u) layer_circle(fire_layer, e.x, e.y - e.z, e.rs, rgba(r, g, b, 255)) end local mx, my = mouse_position() for i, t in ipairs(toggles) do local x, y, w, h = toggle_rect(i) local fill = pt_in(mx, my, x, y, w, h) and COL_WHITE or (t.on and COL_GREEN or COL_FG_DARK) chip(x, y, w, h, t.label, fill) end -- palette cycler: cream secondary button, white on hover local px, py, pw, ph = pal_rect() chip(px, py, pw, ph, P.name, pt_in(mx, my, px, py, pw, ph) and COL_WHITE or COL_LIGHT) -- hint: bare outlined white Lana, the game's HUD-text treatment layer_text(content_layer, 'click a tile', UI_FONT, 8, H - FONT_H - 6, COL_WHITE) layer_render(board_layer) layer_render(fire_layer) layer_render(panel_layer) layer_render(content_layer) shader_set_vec2_immediate(outline_shader, 'u_pixel_size', 1/W, 1/H) layer_clear(panel_out) layer_draw_from(panel_out, panel_layer, outline_shader) layer_clear(content_out) layer_draw_from(content_out, content_layer, outline_shader) layer_draw(board_layer) if opt('outline') then -- the toggle strips the FIRE outline only layer_clear(fire_out) layer_draw_from(fire_out, fire_layer, outline_shader) layer_draw(fire_out) end layer_draw(fire_layer) layer_draw(panel_out) layer_draw(panel_layer) layer_draw(content_out) layer_draw(content_layer) end