pico-os-app 1 1531 zombie-td 0.3.1 ゾンビTD ゾンビTD 142320 2cd1ade7bfd09ce855744ece957ec93febccaeffd09209216f8f6a3749831dc0 - 斜め上から見た箱庭でゾンビのウェーブからベースを守るタワーディフェンス cdae7ba29d76143cb10ee4c5bf4c9c78c3a55dfd5d6cd0df25ff0cbacad30c5d kimu1109 - ed25519 cdae7ba29d76143cb10ee4c5bf4c9c78c3a55dfd5d6cd0df25ff0cbacad30c5d 4592be2da5c34af057163d815bcbeeba98d3505498c05bcc1af9a8de7fafac167d1ddcc588e7fc39de952812ad28725a4fd588d6de6fc98d95ccb4e687de3402 app.cfg 255 c28ddfd71c774732fb372b8a5bd291ab43743c1b77e80195a0afdc556c4f5284 buildings.lua 12235 67043509e18164ea2bd743f37f8837c891bf02ff5315f6bd8ebaaeffd132a82f combat.lua 4023 ae51ed4334b90af6d59891455b5852c58bfa7a81db6c0ac2197c41001215ab6a cursor.lua 7226 37a5425a4c0e577c979ff9f9a2ec592d9b4b2a27c5149422310e8267d8facbfd faces.pimg 22581 e1c36b0adeb42faf9b6dd6586beac8fa18fd0f6b8f331f1a43b8652485a24870 game.lua 25153 0c4c3b7ce1336935e86c1376c327dd4905fe3fecaca9666bc2f4c96120cf5b06 icon.pimg 373 69fc55ac3c38185c132dd23f29d9d5c8f2b9273c1e8b6a1f0434d89c1d52fc48 main.lua 243 96283cc5d6295c756190c61b9353df47c4a722169aa0da90e7cc02bb32e08b75 orders.lua 5736 5bd26b80484d556d03d25824207b265546255b88419440b82b84d21b8edd0940 palette.lua 661 658afbeedeb1368265a1abb23265d47ced8d1f2991c15a1333637d3b9571d5b2 save.lua 2704 d9f802d3ab1fc69e39f391616f1bd08f3be464d23d7917ad4e070ad2ba442702 sfx.lua 1905 a777ef86df9075019b206021071595c5264639be2c82cfa55177f15ffe863118 soldiers.lua 18420 f441892e25ca14dff60add450043bbd610b24a270012beb1c58960e1f4b4e262 state.lua 1433 70680f2ff89528fd546425d14516bf5dc7a6e5cde7ea035f3a7b7b56af3f8d2f tutorial.lua 1780 fd35867de630e5554947fc387564407f1409a277372e90d61a3da0d39a398673 ui.lua 11619 0a7f56619624f423d2a229be4663324ac63eee889cdef158cb1cb2c4ffa411f5 units.pimg 7947 54d8313aa7932a369542053673fb19682e3378057c6d8a8fd030f6c2ab5ed99b waves.lua 3808 207411e6a6eb542af608403d0a6331a7ad7a17409af892701f33962359df223a zombies.lua 14218 325c78fab9d0e34a51d2cbd051834bc10b97ce202e43e2f06933e5c9ce37c150 name=ゾンビTD id=zombie-td description=斜め上から見た箱庭でゾンビのウェーブからベースを守るタワーディフェンス icon=icon.pimg version=0.3.1 permission_network=false permission_sd_outside_app_dir=false strip_debug=true -- ゾンビTD: 建物(弓塔・剣塔・バリケード)。仕様は docs/ZOMBIE_TD.md「建物」。 -- 建設・レベルアップには時間がかかり、その間は耐久が低い(完成時の40%)。修理は3秒かけて戻る。売ると払った合計の70%。 -- タワーは人や物(crowd = "fixed")で、ゾンビの狙う相手になる。バリケードはブロック(穴の開いた絵、pass)で、 -- ゾンビは中を通り抜けられるが遅くなり(レベルが上がるほど遅い)、通っている間はバリケードの耐久が下がる。 -- 1つの表のキーは16個以内(zombies.lua と同じ理由)。種類ごとに同じ値はメタテーブルに置く。 local iso = pico.iso local combat = require("combat") local sfx = require("sfx") local M = {} M.TAG = 4 -- 人や物の tag(タワー) M.WALLS = { 12, 14, 15, 16 } -- バリケード Lv1〜4 のブロック番号(faces.pimg のこの段を柵の絵にしてある) M.MAX_TOWERS = 20 local LOW = 0.4 -- 建設中・レベルアップ中の耐久(完成時の割合) local REPAIR_TIME = 3 local REPAIR_RATE = 0.3 -- 修理の費用 = 減った割合 × 払った合計 × これ local SELL_RATE = 0.7 local SCAN_GAP = 0.3 local MELEE_GAP = 0.9 -- 剣塔はこれだけ離れていても届く(台の上から槍で突く) -- 種類とレベルごとの値(数値は初期値)。up[i] は Lv i → i+1 の費用。build は建設の秒(レベルアップはその半分) -- 動けない代わりに、同じお金なら兵士より強くしてある(近接兵 $60 で 7.5/秒・耐久60 に対し、 -- 弓塔 $100 で 13/秒・射程6、剣塔 $90 で 17/秒・耐久450。docs/ZOMBIE_TD.md「釣り合い」) M.TYPES = { arrow = { name = "弓塔", tower = true, prio = 4, cost = 100, build = 5, sx = 216, range = 6, up = { 80, 120, 170 }, lv = { { hp = 200, dmg = 12, rate = 0.9 }, { hp = 230, dmg = 17, rate = 0.9 }, -- ダメージ強化 { hp = 260, dmg = 20, rate = 0.7 }, -- 間隔短縮・ダメージ強化 { hp = 300, dmg = 26, rate = 0.55 }, -- 間隔短縮・ダメージ強化Ⅱ }, }, guard = { -- ゾンビが一番先に狙う(囮。優先度の順位 0) name = "剣塔", tower = true, prio = 0, cost = 90, build = 5, sx = 236, up = { 70, 110, 160 }, lv = { { hp = 450, dmg = 12, rate = 0.7 }, { hp = 650, dmg = 12, rate = 0.7 }, -- 鎧 → 塔の耐久 { hp = 750, dmg = 18, rate = 0.7 }, -- 槍 { hp = 1000, dmg = 22, rate = 0.7 }, -- 強化鎧 → 塔の耐久 }, }, -- slow = 中を通るゾンビの速さの倍率(zombies.lua) wall = { name = "柵", cost = 20, build = 3, up = { 25, 40, 60 }, lv = { { hp = 150, slow = 0.5 }, { hp = 260, slow = 0.45 }, { hp = 400, slow = 0.4 }, { hp = 600, slow = 0.35 } }, }, } M.KINDS = { "arrow", "guard", "wall" } local SET = {} local img local base local spawns local list = {} M.list = list M.at = {} -- 柱(x * 1024 + z) → 建物 M.on_walls_changed = nil -- function() バリケードが増えた/消えた(流れの場を作り直す) M.on_lost = nil -- function(建物) 壊されたとき local towers = 0 local function col(x, z) return math.floor(x) * 1024 + math.floor(z) end -- 最大の耐久(建設中・レベルアップ中は低い) local function max_hp(b) local full = b.t.lv[b.lv].hp if b.state ~= "ready" then return math.floor(full * LOW) end return full end M.max_hp = max_hp local function set_bar(b) if not b.id then return end SET.bar = math.max(0, b.hp) * 100 // max_hp(b) SET.bar_color = b.state == "ready" and 10 or 14 iso.entity_set(b.id, SET) SET.bar, SET.bar_color = nil, nil end local function hurt(b, dmg) if b.hp <= 0 then return end b.hp = b.hp - dmg if b.hp > 0 then set_bar(b) end end local META = {} for kind, t in pairs(M.TYPES) do META[kind] = { __index = { t = t, kind = kind, side = "building", hurt = hurt, r = t.tower and 0.45 or 0.5, prio = t.prio or 9 } } end function M.init(units_image, base_unit, spawn_points) img = units_image base = base_unit spawns = spawn_points M.clear() end -- set_wall_block は下で定義する local set_wall_block -- 全部取り除く(バリケードのブロックも空気へ戻す。流れの場の作り直しは呼び出し側で) function M.clear() local open = iso.size() ~= nil for i = #list, 1, -1 do local b = list[i] if b.id then combat.remove(b); iso.entity_remove(b.id) elseif open then set_wall_block(b, 0) end list[i] = nil end for k in pairs(M.at) do M.at[k] = nil end towers = 0 end function M.count() return #list end -- 置けるか。置けないときは理由を返す function M.can_place(kind, x, z, stand_rules) x, z = math.floor(x), math.floor(z) if M.at[col(x, z)] then return false, "もう建物があります" end if not iso.stand(x, z, nil, stand_rules) then return false, "そこには置けません" end if math.max(math.abs(x - base.x), math.abs(z - base.z)) <= 3 then return false, "ベースの近くには置けません" end for _, s in ipairs(spawns) do if math.max(math.abs(x - s.x), math.abs(z - s.z)) <= 3 then return false, "出現位置の近くには置けません" end end if M.TYPES[kind].tower and towers >= M.MAX_TOWERS then return false, "タワーは" .. M.MAX_TOWERS .. "個までです" end return true end function set_wall_block(b, block) local x, z = math.floor(b.x), math.floor(b.z) iso.set(x, b.y, z, block) iso.dirty_edit(x, b.y, z) end -- 建てる(建設中から始まる)。can_place を通してから呼ぶこと function M.place(kind, x, z, stand_rules) x, z = math.floor(x), math.floor(z) local t = M.TYPES[kind] local y = iso.stand(x, z, nil, stand_rules) -- x, z は柱の真ん中(ユニットの位置)。rr = 修理で1秒に戻す量、look = 左を向いているか local b = setmetatable({ x = x + 0.5, y = y, z = z + 0.5, hp = 1, lv = 1, state = "build", timer = t.build, atk = 0, tgt = false, scan = 0, rr = 0, id = false, look = false }, META[kind]) if t.tower then local id = iso.entity_add(img, x + 0.5, y, z + 0.5, { sx = 196, sy = 16, w = 20, h = 24, r = 0.45, height = 2, crowd = "fixed", tag = M.TAG, shadow_color = 3, bar = 100, bar_color = 14, }) if not id then return nil end b.id = id -- 撃つ高さ・高低差の倍率は台の上(地面より1段上)で数える b.y = y + 1 combat.add(b) towers = towers + 1 else set_wall_block(b, M.WALLS[1]) if M.on_walls_changed then M.on_walls_changed() end end b.hp = max_hp(b) set_bar(b) list[#list + 1] = b M.at[col(x, z)] = b return b end -- 建設/レベルアップが終わった local function finish(b) local low = max_hp(b) b.state = "ready" b.hp = b.hp + (max_hp(b) - low) if b.id then SET.sx, SET.sy, SET.w, SET.h, SET.height = b.t.sx, b.lv * 40 - 36, 20, 36, 2.2 -- レベルごとに40pxの段 iso.entity_set(b.id, SET) SET.sx, SET.sy, SET.w, SET.h, SET.height = nil, nil, nil, nil, nil end set_bar(b) end -- 保存から戻す: 完成した状態で lv・hp にする function M.restore(kind, x, z, lv, hp, stand_rules) local b = M.place(kind, x, z, stand_rules) if not b then return nil end b.lv = math.max(1, math.min(lv or 1, #b.t.lv)) finish(b) b.hp = math.max(1, math.min(hp or max_hp(b), max_hp(b))) if not b.id then set_wall_block(b, M.WALLS[b.lv]) end set_bar(b) return b end -- 払った合計 function M.paid(b) local p = b.t.cost for i = 1, b.lv - 1 do p = p + b.t.up[i] end return p end function M.busy(b) return b.state ~= "ready" end -- 次のレベルへの費用(最大・建設中なら nil) function M.upgrade_cost(b) if b.state ~= "ready" then return nil end return b.t.up[b.lv] end function M.upgrade(b) if not M.upgrade_cost(b) then return false end b.lv = b.lv + 1 b.state = "up" b.timer = b.t.build / 2 b.rr = 0 b.hp = math.min(b.hp, max_hp(b)) if not b.id then set_wall_block(b, M.WALLS[b.lv]) end set_bar(b) return true end -- 修理の費用(要らない・できないなら nil) function M.repair_cost(b) if b.state ~= "ready" or b.rr > 0 then return nil end local m = max_hp(b) if b.hp >= m then return nil end return math.max(1, math.floor((1 - b.hp / m) * M.paid(b) * REPAIR_RATE + 0.5)) end function M.repair(b) if not M.repair_cost(b) then return false end b.rr = (max_hp(b) - b.hp) / REPAIR_TIME b.timer = REPAIR_TIME return true end -- 売った額(売れないなら nil) function M.sell_value(b) if b.state ~= "ready" then return nil end return math.floor(M.paid(b) * SELL_RATE + 0.5) end local function remove(b) for i = #list, 1, -1 do if list[i] == b then table.remove(list, i) end end M.at[col(b.x, b.z)] = nil if b.id then combat.remove(b) iso.entity_remove(b.id) towers = towers - 1 else set_wall_block(b, 0) if M.on_walls_changed then M.on_walls_changed() end end end function M.sell(b) local v = M.sell_value(b) if not v then return 0 end remove(b) return v end -- (x, z) の柱のバリケード(無ければ nil) function M.wall_at(x, z) local b = M.at[col(x, z)] return b and not b.id and b or nil end -- ---------------------------------------------------------------- 戦い(タワー) local function in_reach(b, z) local d = combat.dist(b, z) if b.t.range then return d <= b.t.range * combat.height_factor(b, z) and iso.sight(b.x, b.y + 1.2, b.z, z.x, z.y + 1, z.z, combat.SIGHT_PASS) end return d - b.r - z.r <= MELEE_GAP end local function fight(b, dt) local tgt = b.tgt if tgt and not (combat.alive(tgt) and in_reach(b, tgt)) then tgt = false; b.tgt = false end if not tgt then b.scan = b.scan - dt if b.scan > 0 then return end b.scan = SCAN_GAP local range = (b.t.range or 1) * 1.5 + 0.5 for _, id in ipairs(iso.nearby(b.x, b.z, range, M.ZOMBIE_TAG, 8)) do local z = combat.units[id] if z and z.hp > 0 and in_reach(b, z) then tgt = z; break end end b.tgt = tgt if not tgt then return end end b.atk = b.atk - dt if b.atk > 0 then return end local lv = b.t.lv[b.lv] b.atk = lv.rate if b.t.range then combat.shoot(b, tgt, math.floor(lv.dmg * combat.height_factor(b, tgt) + 0.5), 5, 2, 12, 0.15) sfx.arrow() else tgt:hurt(lv.dmg, b) sfx.hit() end local left = (tgt.x - b.x) - (tgt.z - b.z) < 0 if b.look ~= left then b.look = left SET.flip = left iso.entity_set(b.id, SET) SET.flip = nil end end function M.update(dt) for i = 1, #list do local b = list[i] if b.hp > 0 then if b.state ~= "ready" then b.timer = b.timer - dt if b.timer <= 0 then finish(b); sfx.built() end else if b.rr > 0 then b.timer = b.timer - dt b.hp = math.min(max_hp(b), b.hp + b.rr * dt) if b.timer <= 0 then b.rr = 0 end set_bar(b) end if b.id then fight(b, dt) end end end end for i = #list, 1, -1 do local b = list[i] if b.hp <= 0 then remove(b) sfx.lost() if M.on_lost then M.on_lost(b) end end end end -- 建物の残り秒(建設中・レベルアップ中・修理中。無ければ nil) function M.remaining(b) if b.state ~= "ready" or b.rr > 0 then return math.max(0, b.timer) end return nil end M.col = col return M -- ゾンビTD: 戦いの共通部分(人や物のハンドル → ユニットの表、弾、距離と高低差)。 -- ユニット(ベース・ゾンビ・兵士)は表で、どれも次を持つ: -- id(人や物のハンドル)・x, y, z(足元)・hp(0 以下 = 倒れた)・r(当たりの半径)・ -- side("base" "zombie" "soldier")・hurt(自分, ダメージ, 撃った/叩いたユニット) -- 弾は C++ の pico.iso.shot_*。撃ったユニットとダメージは弾のハンドルで覚えておき、当たったら hurt を呼ぶ。 local iso = pico.iso local M = {} M.units = {} -- 人や物のハンドル → ユニット local shot_src = {} -- 弾のハンドル → 撃ったユニット local shot_dmg = {} -- 弾のハンドル → ダメージ local SHOT = {} -- shot_add に渡す表(使い回す) function M.add(u) M.units[u.id] = u end function M.remove(u) if M.units[u.id] == u then M.units[u.id] = nil end end -- まだ生きているか(倒れて取り除かれた後の古い参照を見分ける) function M.alive(u) return u and u.hp > 0 and M.units[u.id] == u or false end -- 水平の距離 function M.dist(a, b) local dx, dz = a.x - b.x, a.z - b.z return math.sqrt(dx * dx + dz * dz) end -- 当たりの円どうしの隙間(負なら重なっている) function M.gap(a, b) return M.dist(a, b) - a.r - b.r end -- 高低差の倍率: 相手より1段高いごとに +10%(+50% まで)、低いと同じだけ下がる(-30% まで) function M.height_factor(a, b) local f = 1 + 0.1 * (a.y - b.y) if f > 1.5 then return 1.5 elseif f < 0.7 then return 0.7 end return f end -- 狙った相手へ弾を撃つ(必ず当たる)。opts は shot_add の color / size / speed / arc function M.shoot(src, tgt, dmg, color, size, speed, arc) SHOT.target = tgt.id SHOT.color = color or 0 SHOT.size = size or 2 SHOT.speed = speed or 8 SHOT.arc = arc or 0 local id = iso.shot_add(src.x, src.y + 1.2, src.z, SHOT) if id then shot_src[id] = src; shot_dmg[id] = dmg end return id end -- 弾を進めて、当たったものに hurt を呼ぶ function M.step(dt) if iso.shot_count() == 0 then return end -- 弾が無いのに毎フレーム空の表を作らない for _, h in ipairs(iso.shots_step(dt)) do local src, dmg = shot_src[h.id], shot_dmg[h.id] shot_src[h.id] = nil shot_dmg[h.id] = nil if not h.lost and dmg then local u = M.units[h.target] if u then u:hurt(dmg, src) end end end end -- 弾を全部消す(ユニットの表はそのまま) function M.clear_shots() iso.shot_clear() for k in pairs(shot_src) do shot_src[k] = nil end for k in pairs(shot_dmg) do shot_dmg[k] = nil end end -- 地形に沿って (tx, tz) の方へ v だけ歩く。登れない段差・水・世界の外へは入らない(入れない軸だけ止める)。 -- rules は iso.stand の規則。歩けたら true。u.gy(立つ高さ)も合わせる local function can_enter(u, nx, nz, rules) local ny = iso.stand(nx, nz, nil, rules) if not ny then return nil end if ny - u.gy > 1.01 or u.gy - ny > 2.01 then return nil end return ny end -- (nx, nz) へ動けるなら動かす local function try(u, nx, nz, rules) if math.floor(nx) == math.floor(u.x) and math.floor(nz) == math.floor(u.z) then u.x, u.z = nx, nz return true end local ny = can_enter(u, nx, nz, rules) if not ny then return false end u.x, u.z, u.gy = nx, nz, ny return true end function M.walk(u, tx, tz, v, rules) local dx, dz = tx - u.x, tz - u.z local len = math.sqrt(dx * dx + dz * dz) if len < 1e-4 then return false end if v > len then v = len end local nx, nz = u.x + dx / len * v, u.z + dz / len * v -- 斜めに入れなければ、片方の軸だけで滑る return try(u, nx, nz, rules) or try(u, nx, u.z, rules) or try(u, u.x, nz, rules) end return M -- ゾンビTD: コントローラーと物理キーボードの操作(カーソルを動かして選ぶ)。 -- 地図のカーソル(マス1つ。黄色のひし形)と、下の欄のボタンのフォーカス(ui.lua)を動かす。 -- 十字/矢印: カーソルを1マス。マス目に沿って斜めに動く(上=右上、右=右下、下=左下、左=左上。 -- 2つ同時で画面の上下左右)。押したままで続けて動く。画面の端に近づくとカメラが付いてくる -- A/Enter・Space: カーソルの所の兵士・建物を選ぶ/選んだ兵士をそこへ動かす/「建設」中ならそこへ建てる -- B/Esc: 建設をやめる → 選択を外す -- X/x: 兵士を順に選ぶ、Y/y: 建物を順に選ぶ(カーソルもそこへ) -- R/c: カーソルをベースへ、START/n: 次へ(準備時間)、L+十字/Shift+矢印: カメラだけ動かす -- SELECT/Tab: 下の欄のボタンへ(左右で選び A で押す。B か SELECT で地図へ戻る) -- メニュー(ui.menu)が開いている間は上下で選び A で決める、B でやめる -- 地図をタッチするとカーソルは隠れる(次に十字を押すと画面の真ん中から)。 -- Luaのメモリを食わないよう関数を少なくしてある(関数1つで約0.5KB)。操作は名前(act の a)にしてから1か所で行う。 local iso = pico.iso local G = require("state") local orders = require("orders") local soldiers = require("soldiers") local buildings = require("buildings") local ui = require("ui") local M = { on = false, x = 0, z = 0 } local VX, VY, VW, VH -- 地図の表示範囲(init で決まる) local hooks -- game.lua の操作 {scroll=, start=, new_game=} local rx, rz, rt = 0, 0, 0 -- 押したままの向きと、次に動くまでの秒 -- カーソルのひし形(その柱の地面の上面)の手前の角。他の3つの角は決まった距離(1マス = 横16・縦8) local function corner() local ax, ay = iso.to_screen(M.x, iso.ground(M.x, M.z) or 0, M.z) return math.floor(ax), math.floor(ay) end local function dirty() if M.on then local ax, ay = corner() pico.mark_dirty(ax - 18, ay - 19, 38, 23) end end function M.draw() if not M.on then return end local ax, ay = corner() pico.draw_line(ax, ay, ax + 16, ay - 8, 14, 2) pico.draw_line(ax + 16, ay - 8, ax, ay - 16, 14, 2) pico.draw_line(ax, ay - 16, ax - 16, ay - 8, 14, 2) pico.draw_line(ax - 16, ay - 8, ax, ay, 14, 2) end -- カーソルを柱 (x, z) へ。画面の端に近ければカメラを動かす local function place(x, z) dirty() local w = iso.size() - 1 M.x, M.z, M.on = math.max(0, math.min(w, math.floor(x))), math.max(0, math.min(w, math.floor(z))), true dirty() local sx, sy = corner() sy = sy - 8 local mx = math.max(0, VX + 28 - sx) + math.min(0, VX + VW - 28 - sx) local my = math.max(0, VY + 20 - sy) + math.min(0, VY + VH - 20 - sy) if mx ~= 0 or my ~= 0 then hooks.scroll(mx, my) end end M.place = place function M.hide() dirty() M.on = false end local function list_next(list, cur) local i = 0 for k, u in ipairs(list) do if u == cur then i = k end end return list[i % #list + 1] end -- 操作を1つ行う。a = "move"(dx, dz で1マス)/ "cam"(dx, dz を画面の横・縦に動かす)/ "ok" / "cancel" / -- "soldier" / "building" / "base" / "start" / "panel"(下の欄のフォーカスの入り切り) local function act(a, dx, dz) if ui.menu_active() then return ui.menu_input(a == "move" and dx > 0, a == "move" and dx < 0, a == "ok" or a == "start", a == "cancel") end if G.mode == "over" then if a == "ok" or a == "start" then hooks.new_game() end return end if G.mode ~= "play" then return end if a == "panel" then ui.set_focus(not ui.focus) elseif ui.focus then -- 下の欄: 左右でボタンを選び、A で押す、B で地図へ if a == "move" and dz ~= 0 then ui.focus_move(-dz) elseif a == "ok" then ui.focus_press() elseif a == "cancel" then ui.set_focus(false) end elseif a == "move" then if M.on then place(M.x + dx, M.z + dz) else -- 隠れていたら画面の真ん中(無ければベース)に出す local x, _, z = iso.pick(VX + VW // 2, VY + VH // 2) local b = G.base if x then place(x, z) elseif b then place(b.x, b.z) end end elseif a == "cam" then hooks.scroll(dx, dz) elseif a == "ok" then if M.on then orders.act_at(M.x, M.z) else act("move", 0, 0) end elseif a == "cancel" then if G.build_mode then G.build_mode = false else orders.deselect_all() end elseif a == "soldier" or a == "building" then local s = a == "soldier" local list = s and soldiers.list or buildings.list if #list == 0 then return G.say(s and "兵士がいません" or "建物がありません") end local u = list_next(list, s and G.sel[1] or G.bsel) if s then orders.set_selection({ u }) else orders.select_building(u) end place(u.x, u.z) elseif a == "base" then local b = G.base if b then place(b.x, b.z) end elseif a == "start" then hooks.start() end pico.invalidate(G.panel) end M.act = act -- コントローラーのボタン → 操作(押した瞬間) local PAD_ACT = { a = "ok", b = "cancel", x = "soldier", y = "building", r = "base", start = "start", select = "panel" } -- 毎フレーム(dt は秒)。コントローラーを読む function M.update(dt) local pd, pp = pico.pad_down, pico.pad_pressed for b, a in pairs(PAD_ACT) do if pp(b) then act(a) end end local dx = (pd("up") and 1 or 0) - (pd("down") and 1 or 0) local dz = (pd("left") and 1 or 0) - (pd("right") and 1 or 0) if pd("l") then -- L を押している間はカメラだけ(左右は画面の左右、上下は画面の上下) if dx ~= 0 or dz ~= 0 then act("cam", dz * 6, dx * 4) end dx, dz = 0, 0 end if dx ~= rx or dz ~= rz then rx, rz, rt = dx, dz, 0.28 if dx ~= 0 or dz ~= 0 then act("move", dx, dz) end elseif dx ~= 0 or dz ~= 0 then rt = rt - dt if rt <= 0 then rt = 0.08; act("move", dx, dz) end end end -- 物理キーボードのキー → 操作。矢印は {dx, dz, カメラの横, 縦}(Shift+矢印でカメラ) local KEY_ACT = { enter = "ok", [" "] = "ok", escape = "cancel", x = "soldier", y = "building", c = "base", n = "start", tab = "panel" } local KEY_DIR = { up = { 1, 0, 0, 16 }, down = { -1, 0, 0, -16 }, left = { 0, 1, 32, 0 }, right = { 0, -1, -32, 0 } } -- 取ったら true(メニューが開いている間は全部取る) function M.key(key, mods) local d = KEY_DIR[key] if d then if mods and mods.shift then act("cam", d[3], d[4]) else act("move", d[1], d[2]) end elseif KEY_ACT[key] then act(KEY_ACT[key]) else return ui.menu_active() end return true end -- view = {x=, y=, w=, h=} function M.init(view, h) VX, VY, VW, VH = view.x, view.y, view.w, view.h hooks = h end return M à+/ -                              ‚‚ˆ Š �  ’  ˜  š                            .@0                € ‚  †    Š    Ž’    –    š                                                            @                                      €‚  †    Š  Ž’    –    š                             @                     €‚  †    Š  Ž’    –    š                                        € ‚   † Š  Ž ’    –    š                                                                                           . @ 0                                                                               €      ‚        †       Š       Ž        ’        –        š                                                                                                   ‚   ƒ   Š   ’    –    ™    š›                                                                                                                                                                               €        „    Š       Ž    ’      –     šœ      z                                                                                                                               €  ‚ †  Š  Ž ’    –    š                                                  €   ‚   †Š  Ž’    –    š                           A          €‚ †   Š  Ž’    –    š                                                       €  ‚ †   ŠŽ’    –    š                +€   õ     €ï      ƒ      ‡‹�    “    —›-- ゾンビTD: 斜め上から見た箱庭(pico.iso)のタワーディフェンス。仕様は docs/ZOMBIE_TD.md。 -- 「作る順番」の2〜7: マップの生成・カメラ・ゾンビ・兵士・建物・ウェーブ(準備時間/次へ/お金)・保存・ハイスコア・効果音・説明。 -- 上の行: ベースの耐久・お金・ウェーブ(準備時間は残り秒と[次へ]、ウェーブ中は残りのゾンビ)・fps -- 下の欄: ui.lua。地図: タップ=兵士・建物を選ぶ/選んでいる兵士をそこへ動かす/(「建設」で選んだ後)そこへ建てる、 -- ドラッグ=カメラ(「選択」中は範囲選択)。 -- コントローラー・キーボード: カーソルを動かして選ぶ(cursor.lua)。1/2/3=雇う、HOME=戻る。 -- 5秒ごとにシリアルへ "[TD] fps=.. lua=..ms render=..ms alive=.. mem lua=.. heap_free=.. refused=.." を出す(実機の速さとメモリを測る)。 -- ファイル: state(共有の状態)・orders(操作)・ui(下の欄・メニュー)・cursor(コントローラー)・waves・save・sfx(効果音)・tutorial(説明)・ -- zombies・soldiers・buildings・combat。 -- マップ・流れの場・経路探索・押し合い・弾・描画は C++ のエンジン(src/iso/)。 local iso = pico.iso local PAL = require("palette") local G = require("state") local combat = require("combat") local zombies = require("zombies") local soldiers = require("soldiers") local buildings = require("buildings") local orders = require("orders") local ui = require("ui") local waves = require("waves") local save = require("save") local sfx = require("sfx") local tutorial = require("tutorial") local cursor = require("cursor") local W_CHUNKS = 7 -- 7x7 チャンク = 56x56 マス(全部を読み込んだままにできる上限) local BASE_HP = 2000 local MAX_TRIES = 8 -- 出現位置からベースへ道が無い種は飛ばす(この回数まで) local START_MONEY = 300 local WATER = 1 -- 水のブロック(ゾンビも兵士も入らない) local LOG_MS = 5000 -- ゾンビの道の規則(流れの場)。水には入らない。段差は1段まで登り、2段まで降りる。高低差は少し嫌う。 -- バリケード(buildings.WALLS のブロック)は、ゾンビは中を通り抜けられる(pass)が通ると高くつく。兵士は避ける。 -- 弾と視線はバリケードを透過する local WALLS = buildings.WALLS local WALL_COST = {} for _, b in ipairs(WALLS) do WALL_COST[b] = 6 end local FLOW_RULES = { max_up = 1, max_down = 2, height = 2, up_cost = 0.5, diagonal = true, avoid = { WATER }, pass = WALLS, body_cost = WALL_COST } local STAND_RULES = { height = 2, avoid = { WATER }, pass = WALLS } -- ゾンビ local S_STAND_RULES = { height = 2, avoid = { WATER, WALLS[1], WALLS[2], WALLS[3], WALLS[4] } } -- 兵士・建てる所 local CROWD_RULES = { height = 2, pass = WALLS } combat.SIGHT_PASS = WALLS G.S_STAND = S_STAND_RULES -- Luaのごみ集めは世代別にする。既定(incremental)だと、毎フレームの使い捨ての表が -- 生きている量の2倍まで溜まってから集めるので、ゾンビが多いとLuaが予算(200KB)近くまで膨らみ、 -- 実機では本体のメモリが先に尽きて落ちた(ゾンビ30体を超えたところで再起動)。 -- さらに小さい集め(minor)を早めにする(既定の 20, 100 → 5, 30)。ゾンビ40匹・兵士8人・建物10個で、 -- ごみを含めた山が PC で約267KB → 約247KB(実機の32bit換算で約200KB → 約186KB)になり、処理時間は変わらなかった collectgarbage("generational", 5, 30) for i, c in ipairs(PAL.colors) do pico.set_palette(i, c[1], c[2], c[3]) end iso.sky(PAL.sky) local DIR = pico.app_dir() local faces = pico.image_load(pico.path_join(DIR, "faces.pimg")) local units = pico.image_load(pico.path_join(DIR, "units.pimg")) -- faces.pimg は使うブロックの段だけを並べてある(script/assets/generate_zombie_td_sheet.py の FACE_ROWS と同じ並び。26 = カーソル) local FACE_ROWS = { 1, 2, 3, 4, 5, 11, 12, 14, 15, 16, 18, 22, 26 } if faces then iso.set_image(faces, FACE_ROWS) else pico.show_error("faces.pimg を読めません") end if not units then pico.show_error("units.pimg を読めません") end local MAP_DIR = pico.path_join(DIR, "map") -- 何も書かない(地形は種から作るので保存しない) local CX, CY, CW, CH = pico.content_rect() local HUD_H, INFO_H, BTN_H = 20, 20, 34 local PANEL_H = INFO_H + BTN_H local VY = CY + HUD_H local VH = CH - HUD_H - PANEL_H local PY = VY + VH iso.view(CX, VY, CW, VH) local hud = pico.create("Canvas") local view = pico.create("Canvas") local panel = pico.create("Canvas") pico.set(hud, "x", CX); pico.set(hud, "y", CY); pico.set(hud, "w", CW); pico.set(hud, "h", HUD_H) pico.set(view, "x", CX); pico.set(view, "y", VY); pico.set(view, "w", CW); pico.set(view, "h", VH) pico.set(panel, "x", CX); pico.set(panel, "y", PY); pico.set(panel, "w", CW); pico.set(panel, "h", PANEL_H) G.hud, G.view, G.panel = hud, view, panel local load_msg = "" local stage = nil -- load の中: pump / flow(nil = 始め方を選んでいる/失敗した) local tries = 0 local W = 56 local arena = nil local resume = nil -- 続きから始めるときの保存(マップを作り終えたら戻す) local over_text = nil -- ゲームオーバーの枠の2行目 local OX, OY = 0, 0 G.seed = 0 -- ---------------------------------------------------------------- カメラ local function set_origin(x, y) -- 画面の真ん中に見える地面(高さ6あたり)の点がマップの中に収まるように抑える local u = (CX + CW / 2 - x - 16) / 16 -- x - z local s = (y + 32 - 16 * 6 - (VY + VH / 2)) / 8 -- x + z local cu = math.max(-W, math.min(W, u)) local cs = math.max(0, math.min(2 * W, s)) x = math.floor(x - (cu - u) * 16 + 0.5) y = math.floor(y + (cs - s) * 8 + 0.5) if x == OX and y == OY then return end OX, OY = x, y iso.origin(x, y) pico.invalidate(view) end local function center_on(x, y, z) set_origin(CX + CW // 2 - 16 - 16 * (x - z), VY + VH // 2 - 16 + 8 * (x + z) + 16 * y) end local function scroll(dx, dy) set_origin(OX + dx, OY + dy) end local function center_base() local b = G.base if b then center_on(b.x, b.y, b.z) end end -- ---------------------------------------------------------------- 計測 local perf = { frames = 0, lua_us = 0, render_us = 0, t0 = 0, fps = 0, lua_ms = 0, render_ms = 0 } -- Luaの整数は32bit(LUA_32BITS)。pico.micros() は約36分で負へ回るが、引き算も同じく回るので差は正しい local function us_since(t) return pico.micros() - t end local function perf_tick() perf.frames = perf.frames + 1 local now = pico.millis() if perf.t0 == 0 then perf.t0 = now end local span = now - perf.t0 if span >= LOG_MS then perf.fps = perf.frames * 1000 / span perf.lua_ms = perf.lua_us / perf.frames / 1000 perf.render_ms = perf.render_us / perf.frames / 1000 local m = pico.memory_info() pico.log(string.format("[TD] fps=%.1f lua=%.2fms render=%.2fms alive=%d wait=%d shots=%d mem lua=%d heap_free=%d headroom=%d refused=%d", perf.fps, perf.lua_ms, perf.render_ms, zombies.alive(), zombies.waiting(), iso.shot_count(), m.lua_used, m.heap_free, m.heap_headroom or 0, m.heap_refused or 0)) perf.frames, perf.lua_us, perf.render_us, perf.t0 = 0, 0, 0, now pico.invalidate(hud) end end -- ---------------------------------------------------------------- マップを作る local function goals_around(bx, bz) -- ベースのまわり(中心から2マス離れた輪の16柱)がゾンビの目的地 local g = {} for dx = -2, 2 do for dz = -2, 2 do if math.max(math.abs(dx), math.abs(dz)) == 2 then g[#g + 1] = { bx + dx, bz + dz } end end end return g end local function mem_log(what) collectgarbage("collect") local m = pico.memory_info() pico.log(string.format("[TD] %s: ヒープの空き %d + 未使用 %d バイト、Lua %d/%d バイト", what, m.heap_free, m.heap_headroom or 0, m.lua_used, m.lua_budget)) end local function gen_fail(msg) mem_log("失敗") pico.show_error(msg) load_msg = "作れませんでした(「他」でやり直し)" stage = nil pico.invalidate(view) end local function clear_units() orders.deselect_all() -- 先に外す(消した後だと選択の印を消せない) zombies.clear() soldiers.clear() buildings.clear() combat.clear_shots() G.build_mode = false end local function gen_start() clear_units() cursor.hide() iso.flow_clear() iso.close() G.base = false local bx, by, bz = iso.create(MAP_DIR, 4, G.seed, W_CHUNKS) G.mode = "load" if not bx then return gen_fail("マップを作れません: " .. tostring(by)) end W = iso.size() if not iso.keep_all(true) then iso.close() return gen_fail("マップを作れません: メモリが足りません") end arena = iso.arena() center_on(bx, by, bz) load_msg = "マップを作っています..." stage = "pump" pico.invalidate(view) pico.invalidate(hud) end -- 新しいマップで始める(s を渡すとその種で。続きから始めるときは game に保存を渡す) local function new_map(s, game) G.seed = s or (pico.millis() * 7919 + math.random(0, 0xffff)) % 0x7fffffff tries = game and MAX_TRIES or 0 -- 保存した種は道があると分かっているので作り直さない resume = game gen_start() end local function update_base_bar() iso.entity_set(G.base.id, { bar = math.max(0, G.base.hp) * 100 // BASE_HP }) end -- 途中の保存(準備時間の間とウェーブの始め) local function save_game() if G.mode == "play" then save.write(save.dump(G, waves, soldiers, buildings)) end end -- ベースが壊れた: スコア(耐えたウェーブ数)を残し、途中の保存を消す local function game_over() G.mode = "over" local score = { w = waves.n - 1, hp = 0, earned = G.earned } local best = save.load().best save.write(nil, score) local nb = save.load().best or score over_text = string.format("耐えたウェーブ %d (最高 %d)%s", score.w, nb.w, save.better(score, best) and score.w > 0 and " 新記録!" or "") pico.invalidate(view) tutorial.stop() sfx.game_over() pico.log(string.format("[TD] ベースが壊れました(種 %d、ウェーブ %d、出したゾンビ %d、倒した %d、雇った兵士 %d)", G.seed, waves.n, zombies.stats.spawned, zombies.stats.killed, soldiers.stats.hired)) end local function base_hurt(b, dmg) if G.mode ~= "play" then return end b.hp = b.hp - dmg sfx.base() update_base_bar() pico.invalidate(hud) if b.hp <= 0 then game_over() end end local function start_play() local a = arena.base local base = { x = a.x, y = a.y, z = a.z, hp = BASE_HP, r = 1.4, side = "base", hurt = base_hurt } base.id = iso.entity_add(units, a.x + 0.5, a.y, a.z + 0.5, { sx = 80, sy = 0, w = 44, h = 40, r = 1.4, height = 2.5, crowd = "fixed", tag = 2, bar = 100, bar_color = 2, }) G.base = base combat.add(base) zombies.init(units, arena.spawns, base, STAND_RULES) soldiers.init(units, base, S_STAND_RULES) buildings.init(units, base, arena.spawns) G.money, G.earned, G.speed = START_MONEY, 0, 1 waves.reset(1) if resume then save.restore(resume, G, waves, soldiers, buildings, S_STAND_RULES) resume = nil update_base_bar() end G.mode = "play" pico.invalidate(view) pico.invalidate(hud) pico.invalidate(panel) collectgarbage("collect") local m = pico.memory_info() local st = iso.stats() pico.log(string.format("[TD] 種 %d: チャンク %d 個 (置き場 %d バイト)、Lua %d/%d バイト、ヒープの空き %d + 未使用 %d バイト", G.seed, st.chunks, st.bytes, m.lua_used, m.lua_budget, m.heap_free, m.heap_headroom or 0)) end local function gen_step() if stage == "pump" then iso.pump(64, 30) if iso.pending() == 0 then load_msg = "道を調べています..." pico.invalidate(view) mem_log("チャンクを読み込んだ") local ok, err = iso.flow_build(goals_around(arena.base.x, arena.base.z), FLOW_RULES) if not ok then return gen_fail("道を作れません: " .. tostring(err)) end stage = "flow" end elseif stage == "flow" then local done = iso.flow_step(4000) local info = iso.flow_info() if info.failed then return gen_fail("道を作れません: メモリが足りません") end if done then -- 出現位置のどれかからベースへ届かない種は使わない local ok = true for _, s in ipairs(arena.spawns) do if not iso.flow_get(s.x, s.z) then ok = false end end if ok or tries >= MAX_TRIES then if not ok then pico.log("[TD] 道のある種が見つかりませんでした。このまま使います") end stage = nil start_play() else pico.log(string.format("[TD] 種 %d は出現位置から道がありません。次の種へ", G.seed)) tries = tries + 1 G.seed = (G.seed + 1) % 0x7fffffff gen_start() end end end end -- ---------------------------------------------------------------- ウェーブ local function next_wave() if G.mode ~= "play" or waves.phase ~= "prep" then return end local bonus = waves.skip() if bonus > 0 then G.add_money(bonus, true); G.say(string.format("早く呼んだ +$%d", bonus)) end end local function wave_event(ev) if ev == "start" then save_game() -- ウェーブ中に閉じたら、ここからやり直す waves.start(zombies) sfx.wave_start() G.say(string.format("ウェーブ %d!", waves.n)) elseif ev == "clear" then local bonus = waves.bonus(waves.n - 1) G.add_money(bonus, true) G.say(string.format("ウェーブ %d を越えた +$%d", waves.n - 1, bonus)) sfx.wave_clear() save_game() end pico.invalidate(hud) end -- ---------------------------------------------------------------- つなぎ zombies.on_kill = function(zb) G.add_money(math.floor(zb.money * waves.money_mul(waves.n) + 0.5), true) end buildings.on_walls_changed = function() G.flow_dirty = true end zombies.TARGET_TAGS = { soldiers.TAG, buildings.TAG } zombies.wall_at = buildings.wall_at soldiers.ZOMBIE_TAG = zombies.TAG buildings.ZOMBIE_TAG = zombies.TAG soldiers.blocked = function(x, z) return buildings.at[buildings.col(x, z)] ~= nil end soldiers.pressing = zombies.pressing -- ---------------------------------------------------------------- 描画 local box = ui.box local NEXT_X = CW - 42 -- 上の行の[次へ] pico.on(hud, "render", function() pico.fill_rect(CX, CY, CW, HUD_H, PAL.dark) local base = G.base if G.mode == "load" or not base then pico.draw_text(CX + 4, CY + 2, "種 " .. G.seed, 15, 0) return end pico.draw_text(CX + 2, CY + 2, string.format("基%d", math.max(0, math.floor(base.hp))), base.hp > BASE_HP // 4 and 15 or 12, 0) pico.draw_text(CX + 58, CY + 2, string.format("$%d", G.money), 14, 0) if waves.phase == "prep" and G.mode == "play" then pico.draw_text(CX + 106, CY + 2, string.format("W%dまで%d", waves.n, math.ceil(waves.timer)), 10, 0) box(CX + NEXT_X, CY + 1, 40, HUD_H - 2, nil) pico.draw_text(CX + NEXT_X + 20, CY + 2, "次へ", 0, 0, "center") else pico.draw_text(CX + 106, CY + 2, string.format("W%d 残%d", waves.n, zombies.alive() + zombies.waiting()), 15, 0) pico.draw_text(CX + CW - 2, CY + 2, string.format("%.0ffps", perf.fps), 8, 0, "right") end end) pico.on(hud, "press_end", function(_, _, _, lx) if lx >= NEXT_X and not ui.menu_active() then next_wave() end end) local drag_rect = nil -- 範囲選択の矩形 {x0, y0, x1, y1} -- 説明の案内(地図の上に出す枠) local TUT_X, TUT_Y, TUT_W = CX + 4, VY + 4, CW - 8 local TUT_H = 40 -- 描き直す範囲(2行まで) local function tut_dirty() pico.mark_dirty(TUT_X, TUT_Y, TUT_W, TUT_H) end pico.on(view, "render", function() if G.mode == "load" then local x, y, w, h = pico.get_draw_area() pico.fill_rect(x, y, w, h, PAL.sky) if load_msg ~= "" then box(CX + 30, VY + VH // 2 - 20, CW - 60, 40, load_msg) end ui.menu_draw() return end local t = pico.micros() iso.render() perf.render_us = perf.render_us + us_since(t) cursor.draw() if drag_rect then local r = drag_rect pico.draw_rect(math.min(r[1], r[3]), math.min(r[2], r[4]), math.abs(r[3] - r[1]) + 1, math.abs(r[4] - r[2]) + 1, 14) end local tt = tutorial.text() if tt then local _, th = pico.measure_text(tt, TUT_W - 24, 0) pico.fill_rect(TUT_X, TUT_Y, TUT_W, th + 6, PAL.light) pico.draw_rect(TUT_X, TUT_Y, TUT_W, th + 6, 0) pico.draw_text_wrapped(TUT_X + 4, TUT_Y + 3, TUT_W - 24, tt, 0, 0) pico.draw_text(TUT_X + TUT_W - 4, TUT_Y + 3, "×", 8, 0, "right") -- タップで説明を終える end if G.mode == "over" then box(CX + 10, VY + VH // 2 - 38, CW - 20, 76) pico.draw_text(CX + CW // 2, VY + VH // 2 - 32, "ベースが壊れました", 12, 0, "center") pico.draw_text(CX + CW // 2, VY + VH // 2 - 10, over_text or "", 0, 0, "center") pico.draw_text(CX + CW // 2, VY + VH // 2 + 12, "タップかAで新しく始める", 0, 0, "center") end ui.menu_draw() end) -- ---------------------------------------------------------------- 操作 local function new_game() new_map() if not save.load().tut then tutorial.start(); tut_dirty() end end -- 効果音の入り切り(覚えておく) local function set_sound(on) sfx.on = on save.set("snd", on) end -- 説明をもう一度(終えると覚える) local function restart_tutorial() tutorial.start() tut_dirty() end local function back() -- 準備時間の間に閉じたら今の状態を保存する(ウェーブ中ならウェーブの始めの保存が残っている) if G.mode == "play" and waves.phase == "prep" then save_game() end pico.pop() end ui.init({ x = CX, y = PY, w = CW, info_h = INFO_H, btn_h = BTN_H, vy = VY, vh = VH }, { back = back, center = center_base, new_game = new_game, sound = set_sound, tutorial = restart_tutorial }) cursor.init({ x = CX, y = VY, w = CW, h = VH }, { scroll = scroll, new_game = new_game, start = function() next_wave() end, }) local touch = nil pico.on(view, "press_start", function(_, x, y) touch = { x = x, y = y, drag = false, menu = ui.menu_active() } if touch.menu then return end cursor.hide() if ui.focus then ui.set_focus(false); pico.invalidate(panel) end end) pico.on(view, "press_move", function(_, x, y, _, _, dx, dy) if not touch or touch.menu or G.mode == "load" then return end if not touch.drag and math.abs(x - touch.x) + math.abs(y - touch.y) > 8 then touch.drag = true dx, dy = x - touch.x, y - touch.y end if not touch.drag then return end if G.select_mode then drag_rect = { touch.x, touch.y, x, y } pico.invalidate(view) else scroll(dx, dy) end end) pico.on(view, "press_end", function(_, x, y) local t = touch touch = nil if not t then return end if t.menu then return ui.menu_tap(x, y) end if drag_rect then local r = drag_rect drag_rect = nil pico.invalidate(view) orders.select_rect(r[1], r[2], x, y) G.select_mode = false pico.invalidate(panel) return end if t.drag then return end -- 説明の案内をタップすると説明を終える if tutorial.text() and y < TUT_Y + TUT_H and t.y < TUT_Y + TUT_H then tutorial.stop() save.set("tut", true) pico.invalidate(view) return end if G.mode == "over" then new_game() elseif G.mode == "play" then orders.tap_map(x, y) end pico.invalidate(panel) end) pico.on(view, "press_out", function() touch = nil if drag_rect then drag_rect = nil; pico.invalidate(view) end end) pico.on_back(function() back(); return true end) pico.on_key(function(key, mods) if cursor.key(key, mods) then return true end if key == "1" or key == "2" or key == "3" then if G.mode == "play" then orders.hire(soldiers.KINDS[tonumber(key)]) end else return false end pico.invalidate(panel) return true end) -- ---------------------------------------------------------------- 毎フレーム local SIM_MS = 33 local sim_acc = 0 function loop(dt) perf_tick() if G.mode == "load" then gen_step(); cursor.update(dt / 1000); return end local t0 = pico.micros() if G.mode == "play" then -- ウェーブ中と一時停止中はスリープしない if waves.phase == "wave" or G.speed == 0 then pico.keep_awake() end -- ゲームの計算(ゾンビ・兵士・建物・弾・押し合い)は1秒に30回まで(SIM_MS ごと)。画面の描き直しと操作は毎フレーム。 -- 実機ではLuaの計算が1フレーム 5〜12ms かかり(PCの約30倍。特に重い所は無く、Luaの実行そのものの速さ)、 -- 60〜90fps で毎回計算すると CPU の大半を使っていた sim_acc = sim_acc + dt local d = 0 if sim_acc >= SIM_MS then d = math.min(sim_acc, 50) / 1000 * G.speed sim_acc = 0 end if d > 0 then local ev = waves.update(d, zombies) if ev then wave_event(ev) end zombies.hunt = soldiers.count() + buildings.count() > 0 zombies.update(d) soldiers.update(d) buildings.update(d) -- バリケードが変わったら流れの場を作り直す(少しずつ。作っている間は前の流れのまま) if G.flow_dirty then G.flow_dirty = false local ok, err = iso.flow_build(goals_around(arena.base.x, arena.base.z), FLOW_RULES) if not ok then G.say("道を作り直せません: " .. tostring(err)); G.flow_retry = 2 end end local fi = iso.flow_info() if fi.building then iso.flow_step(800) elseif fi.failed or G.flow_retry then -- メモリが足りず作り直しが止まった(前の流れのまま動く): 2秒後にやり直す G.flow_retry = (G.flow_retry or 2) - d if G.flow_retry <= 0 then G.flow_retry = nil; G.flow_dirty = true end end if iso.crowd(2, CROWD_RULES) > 0 then zombies.sync() soldiers.sync() end combat.step(d) end if (perf.frames % 15) == 0 then pico.invalidate(hud) end if (#G.sel == 1 or G.bsel) and (perf.frames % 10) == 0 then pico.invalidate(panel) end end if tutorial.active and G.mode == "play" then if tutorial.update(dt / 1000) then pico.invalidate(view) if not tutorial.active then save.set("tut", true) end end end sfx.flush() if G.info_msg then G.info_t = G.info_t - dt / 1000 if G.info_t <= 0 then G.info_msg = false; pico.invalidate(panel) end end cursor.update(dt / 1000) perf.lua_us = perf.lua_us + us_since(t0) end -- ---------------------------------------------------------------- 始める math.randomseed(pico.millis()) local saved = save.load() sfx.on = saved.snd ~= false if saved.game and saved.game.seed then -- 途中の保存があれば、続きからか新しく始めるかを選ぶ(閉じた/キャンセルは続きから。うっかり保存を失わないように) local game = saved.game ui.menu("ゾンビTD", { string.format("続きから W%d", game.w or 1), "新しく始める" }, function(i) if i == 2 then new_game() else new_map(game.seed, game) end end, function() new_map(game.seed, game) end) saved = nil else new_game() end if TEST then TEST.env = { G = G, waves = waves, save = save, zombies = zombies, soldiers = soldiers, buildings = buildings, orders = orders, ui = ui, new_map = new_map, tutorial = tutorial, sfx = sfx, next_wave = next_wave, back = back, scroll = scroll, center_on = center_on, origin = function() return OX, OY end, perf = perf, cursor = cursor } end 00                                    z            ….,*( ÿÀ-- ゾンビTD: 斜め上から見た箱庭のタワーディフェンス(仕様は docs/ZOMBIE_TD.md)。 -- 本体は game.lua(main.lua は16KiBまでなので、32KiBまで読める require のモジュールに分けた)。 require("game") -- ゾンビTD: 選ぶ・雇う・建てる・強化・修理・売る・移動の指示(画面の操作から呼ぶ)。 -- 状態は state.lua(G)。G.S_STAND(兵士と建てる所の規則)は game.lua が入れる。 local iso = pico.iso local G = require("state") local combat = require("combat") local soldiers = require("soldiers") local buildings = require("buildings") local sfx = require("sfx") local M = {} function M.deselect_all() local sel = G.sel for i = #sel, 1, -1 do soldiers.select(sel[i], false); sel[i] = nil end if G.bsel then if G.bsel.id then iso.entity_set(G.bsel.id, { mark = false }) end iso.cursor(0, 0, 0, false) G.bsel = false end pico.invalidate(G.panel) end function M.set_selection(list) M.deselect_all() for i, s in ipairs(list) do G.sel[i] = s; soldiers.select(s, true) end end -- 建物を選ぶ(タワーは頭の上の印、バリケードはそのブロックにカーソル) function M.select_building(b) M.deselect_all() G.bsel = b if b.id then iso.entity_set(b.id, { mark = 14 }) else iso.cursor(math.floor(b.x), b.y, math.floor(b.z), true) end end -- 倒れた兵士・壊された建物は選択からも外す soldiers.on_lost = function(s) for i = #G.sel, 1, -1 do if G.sel[i] == s then table.remove(G.sel, i) end end pico.invalidate(G.panel) end buildings.on_lost = function(b) if G.bsel == b then G.bsel = false; iso.cursor(0, 0, 0, false) end pico.invalidate(G.panel) end local function pay(c) if G.money < c then G.say("お金が足りません"); return false end G.add_money(-c) sfx.coin() return true end function M.hire(kind) local t = soldiers.TYPES[kind] if soldiers.count() >= soldiers.MAX then return G.say("兵士は" .. soldiers.MAX .. "人までです") end if G.money < t.cost then return G.say("お金が足りません") end local s = soldiers.hire(kind) if not s then return G.say("これ以上置けません") end pay(t.cost) M.set_selection({ s }) end -- 建てる(G.build_mode の種類を、タップした柱へ) function M.build_at(bx, bz) local kind = G.build_mode local ok, why = buildings.can_place(kind, bx, bz, G.S_STAND) if not ok then return G.say(why) end if G.money < buildings.TYPES[kind].cost then return G.say("お金が足りません") end local b = buildings.place(kind, bx, bz, G.S_STAND) if not b then return G.say("これ以上置けません") end pay(buildings.TYPES[kind].cost) G.build_mode = false M.select_building(b) end function M.upgrade() local b = G.bsel if b then local c = buildings.upgrade_cost(b) if c and pay(c) then buildings.upgrade(b) end return end local s = G.sel[1] if #G.sel ~= 1 then return end local c = soldiers.upgrade_cost(s) if c and pay(c) then soldiers.upgrade(s) end end function M.repair() local b = G.bsel local c = b and buildings.repair_cost(b) if c and pay(c) then buildings.repair(b) end end function M.sell() local v if G.bsel then local b = G.bsel if not buildings.sell_value(b) then return G.say("建設中・強化中は売れません") end M.deselect_all() v = buildings.sell(b) elseif #G.sel == 1 then local s = G.sel[1] M.deselect_all() v = soldiers.sell(s) else return end G.add_money(v) sfx.coin() end -- 選ぶ(選んでいるものをもう一度選ぶと外す) local function toggle(u) if u.side == "soldier" then if #G.sel == 1 and G.sel[1] == u then M.deselect_all() else M.set_selection({ u }) end elseif G.bsel == u then M.deselect_all() else M.select_building(u) end end -- 選んでいる兵士を柱 (bx, bz) へ動かす local function move_to(bx, bz) if #G.sel == 0 then return end if soldiers.order_group(G.sel, bx + 0.5, bz + 0.5) then G.moved = true else G.say("そこへは行けません") end end -- 柱の真ん中の近くに立っている兵士 local function soldier_near(bx, bz) local best, bd = nil, 0.75 for _, s in ipairs(soldiers.list) do local d = math.max(math.abs(s.x - bx - 0.5), math.abs(s.z - bz - 0.5)) if d < bd then best, bd = s, d end end return best end -- 地図のタップ: 兵士・建物なら選ぶ、地面なら選んでいる兵士を動かす/「建設」で選んだ建物を建てる function M.tap_map(px, py) local id = not G.build_mode and iso.entity_at(px, py) local u = id and combat.units[id] if u and (u.side == "soldier" or u.side == "building") then return toggle(u) end local bx, _, bz = iso.pick(px, py) if not bx then return end if G.build_mode then return M.build_at(bx, bz) end local w = buildings.wall_at(bx, bz) if w then return toggle(w) end move_to(bx, bz) end -- カーソル(コントローラー)で柱 (bx, bz) を選んだ: そこの兵士・建物を選ぶ/選んでいる兵士を動かす/建てる function M.act_at(bx, bz) if G.build_mode then return M.build_at(bx, bz) end local u = soldier_near(bx, bz) or buildings.at[buildings.col(bx, bz)] if u then return toggle(u) end move_to(bx, bz) end -- 範囲選択: 画面の矩形の中に立っている兵士 function M.select_rect(x0, y0, x1, y1) if x0 > x1 then x0, x1 = x1, x0 end if y0 > y1 then y0, y1 = y1, y0 end local got = {} for _, s in ipairs(soldiers.list) do local sx, sy = iso.to_screen(s.x, s.y + 0.6, s.z) if sx >= x0 and sx <= x1 and sy >= y0 and sy <= y1 then got[#got + 1] = s end end M.set_selection(got) if #got == 0 then G.say("兵士がいません") end end return M -- script/assets/generate_blocks_sheet.py が作る。手で直さないこと。 -- 1〜14番の色(pico.set_palette で入れる)と、空・水・UIに使う番号。 return { colors = { { 56, 96, 232 }, { 91, 163, 61 }, { 72, 72, 71 }, { 150, 91, 63 }, { 75, 49, 33 }, { 175, 140, 80 }, { 192, 240, 255 }, { 142, 142, 140 }, { 51, 87, 31 }, { 69, 224, 46 }, { 222, 213, 163 }, { 204, 43, 10 }, { 107, 93, 65 }, { 236, 229, 70 }, }, sky = 7, water = 1, dark = 3, mid = 8, light = 11, accent = 12, green = 2, } -- ゾンビTD: 途中の保存とハイスコア(アプリのフォルダの store.json。pico.store_save)。 -- マップは種から作り直すので、保存するのは種・ウェーブ数・お金・ベースの耐久・建物と兵士の一覧だけ。 -- 保存するのは準備時間の間(閉じたとき)とウェーブの始め。ウェーブ中に閉じたら、そのウェーブの頭からやり直す。 -- 形: { best = {w=, hp=, earned=}, game = {seed=, w=, money=, earned=, hp=, b = {{種類, x, z, lv, hp}, ...}, -- s = {{種類, lv, hp, x, z}, ...}} } local M = {} function M.load() local st = pico.store_load() if type(st) ~= "table" then st = {} end return st end -- スコアの比べ方: 耐えたウェーブ数 → 残ったベースの耐久 → 稼いだ合計 function M.better(a, b) if not b then return true end if a.w ~= b.w then return a.w > b.w end if a.hp ~= b.hp then return a.hp > b.hp end return a.earned > b.earned end -- 今の状態を書く(game = nil なら途中の保存を消す) function M.write(game, score) local st = M.load() st.game = game if score and M.better(score, st.best) then st.best = score end return pico.store_save(st) end -- 設定などを1つ書く(tut = 説明を終えた、snd = 効果音) function M.set(key, value) local st = M.load() st[key] = value return pico.store_save(st) end -- 建物と兵士の一覧を表にする function M.dump(G, waves, soldiers, buildings) local b, s = {}, {} for _, x in ipairs(buildings.list) do -- 建設中・強化中のものは、終わった後(払った分)として残す b[#b + 1] = { x.kind, math.floor(x.x), math.floor(x.z), x.lv, math.ceil(x.hp) } end for _, x in ipairs(soldiers.list) do s[#s + 1] = { x.kind, x.lv, x.hp, math.floor(x.px * 100 + 0.5) / 100, math.floor(x.pz * 100 + 0.5) / 100 } end return { seed = G.seed, w = waves.n, money = G.money, earned = G.earned, hp = G.base.hp, b = b, s = s } end -- 保存した一覧を戻す(マップを作り終えて start_play した後に呼ぶ) function M.restore(game, G, waves, soldiers, buildings, stand_rules) G.money, G.earned = game.money or 0, game.earned or 0 G.base.hp = game.hp or G.base.hp waves.reset(game.w or 1) for _, e in ipairs(game.b or {}) do if buildings.TYPES[e[1]] and buildings.can_place(e[1], e[2], e[3], stand_rules) then buildings.restore(e[1], e[2], e[3], e[4], e[5], stand_rules) end end for _, e in ipairs(game.s or {}) do if soldiers.TYPES[e[1]] then soldiers.restore(e[1], e[2], e[3], e[4], e[5]) end end end return M -- ゾンビTD: 効果音とジングル。 -- 効果音はチャンネル2で、1フレームに1つ・優先度の高いものだけ鳴らす(ブロック崩しと同じ。大勢が同時に -- 叩いたり撃ったりするので、全部鳴らすと命令の列(32件)が溢れる)。ジングルは短いMMLを曲として鳴らす(チャンネル1)。 local M = {} M.on = true local pri, freq, ms, wave = 0, 0, 0, nil local OPTS = { wave = "pulse25", volume = 9, envelope = -3 } -- sound_play に渡す表(使い回す) -- 鳴らしたい音を出す(このフレームで一番優先度の高いものだけが、flush で鳴る) function M.play(p, f, t, w) if M.on and p > pri then pri, freq, ms, wave = p, f, t, w end end -- 毎フレームの最後に1回呼ぶ function M.flush() if pri > 0 then OPTS.wave = wave or "pulse25" pico.sound_play(2, freq, ms, OPTS) pri = 0 end end function M.jingle(mml) if M.on then pico.music_play_text("#tempo 180\nA @pulse25 v10 q7 o5 l16 " .. mml) end end -- 決まった音(優先度・高さ・長さ・波形) function M.arrow() M.play(1, 1600, 25, "noise") end -- 矢を射る function M.hit() M.play(2, 260, 40, "noise") end -- 叩く function M.kill() M.play(3, 196, 70, "pulse25") end -- ゾンビが倒れる function M.base() M.play(4, 98, 80, "pulse50") end -- ベースが叩かれる function M.built() M.play(5, 880, 90, "pulse50") end -- 建設・強化が終わった function M.coin() M.play(6, 1320, 50, "pulse12") end -- 雇う・建てる・強化・売る function M.lost() M.play(7, 147, 220, "triangle") end -- 兵士が倒れる・建物が壊れる function M.wave_start() M.jingle("c e g > c4") end function M.wave_clear() M.jingle("g > c e g4 e g2") end function M.game_over() M.jingle("o4 g f e d c4 < g2") end return M -- ゾンビTD: 兵士(雇う・移動の指示・戦い・回復・レベルアップ・売却)。仕様は docs/ZOMBIE_TD.md「兵士」。 -- 兵士はベースから出てきて、指示された位置(持ち場)へ経路探索(pico.iso.path)の道で歩く。 -- 持ち場では射程に入ったゾンビを自動で攻撃し、近接兵は持ち場から LEASH マスまで追いかけて、それより離れたら戻る。 -- ベースを攻撃しているゾンビ・遠くから投げているゾンビ・自分を狙うゾンビだけは LEASH + SIEGE_EXTRA マスまで追う -- (持ち場の少し外から石を投げられ続け、回復兵が回復し続けてウェーブが終わらなくならないように)。 -- 移動中は攻撃されたときだけ反撃する(相手が倒れるか離れたら移動を続ける)。 -- 押し合いは game.lua が C++(pico.iso.crowd)で行い、その後に M.sync() で位置を読み戻す。 -- 1人の表は16個以内のキーにする(zombies.lua と同じ理由)。種類ごとに同じ値はメタテーブルに置く。 local iso = pico.iso local combat = require("combat") local sfx = require("sfx") local M = {} M.MAX = 32 -- 同時に雇える数 M.TAG = 3 -- 人や物の tag(兵士) local LEASH = 3 -- 持ち場からこれ以上離れたら追うのをやめて戻る(マス) local SIEGE_EXTRA = 4 -- 遠くまで追う相手(上)は、持ち場から LEASH + これまで追う(マス) local MELEE_GAP = 0.25 -- 当たりの円の隙間がこれ以下なら近接で叩ける local COUNTER_EXTRA = 1.0 -- 移動中の反撃: 間合い + これ以内の相手にだけ向かう local GIVE_UP_TIME = 3 -- この秒数攻撃できなければ(近づけなければ)諦める local HEAL_RANGE = 2.5 -- 回復兵が回復できる範囲(マス) local SCAN_GAP = 0.3 -- 攻撃する相手を探し直す間隔(秒) local SELL_RATE = 0.7 -- 売ると払った合計のこの割合が戻る -- 種類とレベルごとの値(数値は初期値)。up[i] は Lv i → i+1 の費用 M.TYPES = { melee = { name = "近接", prio = 1, cost = 60, sx = 124, speed = 1.6, up = { 50, 80, 120 }, lv = { { hp = 60, dmg = 6, rate = 0.8 }, -- 短剣 { hp = 90, dmg = 6, rate = 0.8 }, -- 鎧付き短剣 { hp = 110, dmg = 10, rate = 0.8 }, -- 鎧付き槍 { hp = 150, dmg = 12, rate = 0.8 }, -- 強化鎧付き槍 }, }, healer = { name = "回復", prio = 2, cost = 70, sx = 148, speed = 1.4, up = { 50, 80, 120 }, lv = { { hp = 45, heal = 6, rate = 2.0 }, { hp = 50, heal = 10, rate = 2.0 }, -- 回復量強化 { hp = 55, heal = 12, rate = 1.5 }, -- 回復量強化・間隔短縮 { hp = 65, heal = 16, rate = 1.2 }, -- 回復量強化Ⅱ・間隔短縮 }, }, ranged = { name = "弓", prio = 3, cost = 80, sx = 172, speed = 1.4, range = 4.5, up = { 60, 90, 130 }, lv = { { hp = 40, dmg = 5, rate = 1.2 }, -- 弓 { hp = 45, dmg = 7, rate = 1.2 }, -- ダメージ強化 { hp = 50, dmg = 9, rate = 0.95 }, -- 間隔短縮・ダメージ強化 { hp = 55, dmg = 11, rate = 0.8 }, -- 間隔短縮・ダメージ強化Ⅱ }, }, } M.KINDS = { "melee", "healer", "ranged" } local W, H, R = 12, 22, 0.22 -- 絵の大きさと当たりの半径(3種類とも同じ) local SET = {} -- entity_set に渡す表(使い回す) local HEAL = { color = 10, size = 2, speed = 10, arc = 0 } -- 回復の光(何も起こさない弾) local img local list = {} local base -- ベースのユニット local stand_rules, path_rules local clock = 0 M.list = list M.stats = { hired = 0, lost = 0 } M.on_lost = nil -- function(兵士) 倒されたとき M.blocked = nil -- function(x, z) その柱に建物があるか(game.lua が入れる。持ち場にも道にもしない) M.pressing = nil -- function(ゾンビ) 遠くまで追うべき相手か(zombies.pressing。game.lua が入れる) -- 最大の耐久 local function max_hp(s) return s.t.lv[s.lv].hp end M.max_hp = max_hp local function set_bar(s) SET.bar = math.max(0, s.hp) * 100 // max_hp(s) iso.entity_set(s.id, SET) SET.bar = nil end local function post_dist(u, s) local dx, dz = u.x - s.px, u.z - s.pz return math.sqrt(dx * dx + dz * dz) end -- 兵士 s が持ち場からどこまで z を追うか(M.pressing の相手と、s 自身を狙う相手は遠くまで。 -- 近づくと相手が s を狙い直すので、それでも追うのをやめないように) local function leash_for(z, s) if z.tgt == s or (M.pressing and M.pressing(z)) then return LEASH + SIEGE_EXTRA end return LEASH end -- 攻撃の間合い(弓兵は高低差で伸び縮みする。近接は nil) local function range_of(s, tgt) local r = s.t.range return r and r * combat.height_factor(s, tgt) end -- 叩かれた/弾が当たった。まだ誰とも戦っていなければ、近くの相手に反撃する local function hurt(s, dmg, src) if s.hp <= 0 then return end s.hp = s.hp - dmg if s.hp <= 0 then return end set_bar(s) if s.tgt or not src or src.side ~= "zombie" or src.hp <= 0 or s.kind == "healer" then return end local d = combat.dist(s, src) local reach = range_of(s, src) or (s.r + src.r + MELEE_GAP) local ok if s.state == "hold" then ok = post_dist(src, s) <= leash_for(src, s) + (s.t.range or 0) else ok = d <= reach + COUNTER_EXTRA end if ok then s.tgt, s.wait = src, 0 end end local META = {} for kind, t in pairs(M.TYPES) do META[kind] = { __index = { t = t, kind = kind, side = "soldier", hurt = hurt, r = R, prio = t.prio } } end function M.init(units_image, base_unit, rules) img = units_image base = base_unit stand_rules = rules -- 道の規則: 水には入らない。段差は1段まで登り、2段まで降りる。遠い所も探せるよう点の上限は大きめ path_rules = { max_up = 1, max_down = 2, height = rules.height or 2, avoid = rules.avoid, diagonal = true, partial = true, max_nodes = 3200, -- タワーの柱は通らない(バリケードは avoid のブロックで避ける) edge = function(_, _, _, nx, _, nz) if M.blocked and M.blocked(nx, nz) then return false end end, } M.clear() end function M.clear() for i = #list, 1, -1 do combat.remove(list[i]) iso.entity_remove(list[i].id) list[i] = nil end end function M.count() return #list end -- 立てる柱か(水・世界の外・ベースの上は不可)。立つ高さを返す function M.standable(x, z) local y = iso.stand(x, z, nil, stand_rules) if not y then return nil end if base and math.abs(math.floor(x) - base.x) < 2 and math.abs(math.floor(z) - base.z) < 2 then return nil end if M.blocked and M.blocked(x, z) then return nil end return y end -- 持ち場を (px, pz) にして、そこへの道を探す(道の何番目へ向かっているかは path.i) local function order(s, px, pz) s.px, s.pz = px, pz s.tgt, s.wait = false, 0 s.state = "move" local path, why = iso.path(s.x, nil, s.z, px, nil, pz, path_rules) if path and #path >= 2 then path.i = 2; s.path = path else s.path = false end -- メモリが足りず探せなかった: 1秒は探し直さず、持ち場へまっすぐ歩く(wait が負の間) if why == "memory" then s.wait = -1 end end M.order = order -- 雇う(ベースの前に出てきて、少し前へ出る)。数の上限なら nil function M.hire(kind) if #list >= M.MAX then return nil end local t = M.TYPES[kind] local x = base.x + 0.5 + (math.random() - 0.5) * 2 local z = base.z + 2.6 local y = iso.stand(x, z, nil, stand_rules) or base.y local id = iso.entity_add(img, x, y, z, { sx = t.sx, sy = 40 - H, w = W, h = H, r = R, height = H / 16, crowd = "move", mass = 1, tag = M.TAG, shadow_color = 3, bar = 100, bar_color = 10, }) if not id then return nil end -- look = コマ(0/1) + 左向きなら2。wait = 追っても攻撃できない時間/道で詰まっている時間 local s = setmetatable({ id = id, lv = 1, x = x, y = y, z = z, gy = y, hp = t.lv[1].hp, px = x, pz = z, path = false, state = "move", tgt = false, atk = 0, scan = 0, look = 0, wait = 0, }, META[kind]) list[#list + 1] = s combat.add(s) M.stats.hired = M.stats.hired + 1 -- 持ち場: ベースの前(出現位置の側)の、立てる所 local px, pz = x + (math.random() - 0.5) * 2, base.z + 4 + math.random() * 1.5 if not M.standable(px, pz) then px, pz = x, z end order(s, px, pz) return s end -- 保存から戻す: 持ち場 (px, pz) に立った状態で出す function M.restore(kind, lv, hp, px, pz) local s = M.hire(kind) if not s then return nil end s.lv = math.max(1, math.min(lv or 1, #s.t.lv)) s.hp = math.max(1, math.min(hp or max_hp(s), max_hp(s))) local y = M.standable(px, pz) if y then s.x, s.z, s.px, s.pz, s.y, s.gy = px, pz, px, pz, y, y iso.entity_move(s.id, px, y, pz) end s.path, s.state = false, "hold" s.look = -1 set_bar(s) return s end -- 何人かを (x, z) のまわりへ散らばらせて動かす。動かせたら true function M.order_group(group, x, z) local cx, cz = math.floor(x), math.floor(z) local cy = M.standable(cx, cz) if not cy then return false end -- 近い柱から順に、立てて高さの近い所を候補にする local cand = {} for r = 0, 3 do for dx = -r, r do for dz = -r, r do if math.max(math.abs(dx), math.abs(dz)) == r then local y = M.standable(cx + dx, cz + dz) if y and math.abs(y - cy) <= 1 then cand[#cand + 1] = cx + dx; cand[#cand + 1] = cz + dz end end end end if #cand >= 2 * #group then break end end local nc = #cand // 2 for i, s in ipairs(group) do local k = (i - 1) % nc -- 1つの柱に何人も入るときは柱の中で少しずらす local spread = (i - 1) >= nc and 0.6 or 0 order(s, cand[2 * k + 1] + 0.5 + (math.random() - 0.5) * spread, cand[2 * k + 2] + 0.5 + (math.random() - 0.5) * spread) end return true end -- 選んだ印 function M.select(s, on) SET.mark = on and 14 or false iso.entity_set(s.id, SET) SET.mark = nil end -- 次のレベルへの費用(最大なら nil) function M.upgrade_cost(s) return s.t.up[s.lv] end function M.upgrade(s) local c = s.t.up[s.lv] if not c then return false end local old = max_hp(s) s.lv = s.lv + 1 s.hp = s.hp + (max_hp(s) - old) s.look = -1 -- 次の step で新しいレベルの絵にする set_bar(s) return true end -- 払った合計(雇った費用 + レベルアップの費用) function M.paid(s) local p = s.t.cost for i = 1, s.lv - 1 do p = p + s.t.up[i] end return p end function M.sell_value(s) return math.floor(M.paid(s) * SELL_RATE + 0.5) end local function remove_at(i) local s = list[i] combat.remove(s) iso.entity_remove(s.id) table.remove(list, i) end -- 売る(取り除く)。戻るお金を返す function M.sell(s) for i = 1, #list do if list[i] == s then remove_at(i) return M.sell_value(s) end end return 0 end -- ---------------------------------------------------------------- 戦い -- 攻撃できる間合いか local function in_reach(s, tgt, d) local rg = range_of(s, tgt) if rg then return d <= rg and iso.sight(s.x, s.y + 1.2, s.z, tgt.x, tgt.y + 1, tgt.z, combat.SIGHT_PASS) end return d - s.r - tgt.r <= MELEE_GAP end -- 持ち場で攻撃する相手を選ぶ(近い順に、弓兵は間合いに入っている・近接は持ち場から LEASH 以内のゾンビ) local function choose(s) local range = s.t.range for _, id in ipairs(iso.nearby(s.x, s.z, range and range * 1.5 or LEASH + SIEGE_EXTRA + 1, M.ZOMBIE_TAG, 8)) do local z = combat.units[id] if z and z.hp > 0 then if range then if in_reach(s, z, combat.dist(s, z)) then return z end elseif post_dist(z, s) <= leash_for(z, s) then return z end end end return false end -- 狙った相手と戦う。返り値: やめたら nil、歩いたら true、止まって戦ったら false local function engage(s, tgt, dt) local d = combat.dist(s, tgt) if in_reach(s, tgt, d) then s.atk = s.atk - dt if s.atk <= 0 then local lv = s.t.lv[s.lv] s.atk = lv.rate if s.t.range then combat.shoot(s, tgt, math.floor(lv.dmg * combat.height_factor(s, tgt) + 0.5), 5, 2, 12, 0.15) sfx.arrow() else tgt:hurt(lv.dmg, s) sfx.hit() end end s.wait = 0 return false end -- 弓兵は追いかけない(間合いの外へ出たら別の相手を探す) if s.t.range then return nil end -- 近接: 持ち場では LEASH まで、移動中は反撃の間合いまで追う if s.state == "hold" then if post_dist(tgt, s) > leash_for(tgt, s) then return nil end elseif d > s.r + tgt.r + MELEE_GAP + COUNTER_EXTRA then return nil end s.wait = s.wait + dt if s.wait > GIVE_UP_TIME then return nil end return combat.walk(s, tgt.x, tgt.z, s.t.speed * dt, stand_rules) end -- 回復兵: 自分(減っていれば) → 範囲の中で一番減っている(割合で)兵士 local function heal(s, dt) s.atk = s.atk - dt if s.atk > 0 then return end local best = false if s.hp < max_hp(s) then best = s else local br = 1 for _, id in ipairs(iso.nearby(s.x, s.z, HEAL_RANGE, M.TAG, 8)) do local o = combat.units[id] if o and o.hp > 0 and o.hp / max_hp(o) < br then best, br = o, o.hp / max_hp(o) end end end if not best then s.atk = 0.3; return end local lv = s.t.lv[s.lv] s.atk = lv.rate best.hp = math.min(max_hp(best), best.hp + lv.heal) set_bar(best) if best ~= s then HEAL.target = best.id iso.shot_add(s.x, s.y + 1.2, s.z, HEAL) end end -- 持ち場へ向かう(道をたどる/押し出されたら戻る)。返り値: 歩いたら true と向かう先 local function go(s, dt) local v = s.t.speed * dt local path = s.path if path then local p = path[path.i] local tx, tz = p.x + 0.5, p.z + 0.5 if path.i == #path then tx, tz = s.px, s.pz end local dx, dz = tx - s.x, tz - s.z if dx * dx + dz * dz < 0.02 then path.i = path.i + 1 if path.i > #path then s.path = false end return false end if combat.walk(s, tx, tz, v, stand_rules) then s.wait = 0 return true, tx, tz end -- 押されて道から外れた: しばらく進めなければ探し直す s.wait = s.wait + dt if s.wait > 1 then order(s, s.px, s.pz) end return false end local d = post_dist(s, s) if s.wait < 0 then s.wait = math.min(0, s.wait + dt) end if d > LEASH + 0.5 and s.wait >= 0 then order(s, s.px, s.pz) -- 遠くまで追いかけた/押された: 道を探して戻る elseif d > 0.3 then if s.state == "move" and d < 0.6 then s.state = "hold" end if combat.walk(s, s.px, s.pz, v, stand_rules) then return true, s.px, s.pz end else s.state = "hold" end return false end local function step(s, dt) local tgt = s.tgt if tgt and not combat.alive(tgt) then tgt = false; s.tgt = false end -- 柱が変わったら立つ高さを合わせる(押されたとき) s.gy = iso.stand(s.x, s.z, nil, stand_rules) or s.gy if s.kind == "healer" then heal(s, dt) elseif not tgt and s.state == "hold" then s.scan = s.scan - dt if s.scan <= 0 then s.scan = SCAN_GAP tgt = choose(s) s.tgt, s.wait = tgt, 0 end end local moved, lx, lz = false, nil, nil if tgt then moved = engage(s, tgt, dt) if moved == nil then s.tgt, s.wait, s.scan = false, 0, SCAN_GAP tgt = false else lx, lz = tgt.x, tgt.z end end if not tgt then moved, lx, lz = go(s, dt) end if s.y ~= s.gy then local st = 6 * dt if math.abs(s.gy - s.y) <= st then s.y = s.gy elseif s.gy > s.y then s.y = s.y + st else s.y = s.y - st end end -- 見た目: 歩く2コマと向き(画面の左 = x - z が減る方を向くと左右反転) local left = s.look >= 2 if lx then local sdx = (lx - s.x) - (lz - s.z) if sdx < -0.05 then left = true elseif sdx > 0.05 then left = false end end local look = ((moved and math.floor(clock * 4.5) % 2) or 0) + (left and 2 or 0) if look ~= s.look then s.look = look SET.sx = s.t.sx + (look % 2) * W SET.sy = s.lv * 40 - H -- units.pimg はレベルごとに40pxの段 SET.flip = left iso.entity_set(s.id, SET) SET.sx, SET.sy, SET.flip = nil, nil, nil end end function M.update(dt) clock = clock + dt for i = 1, #list do local s = list[i] if s.hp > 0 then step(s, dt) iso.entity_move(s.id, s.x, s.y, s.z) end end for i = #list, 1, -1 do local s = list[i] if s.hp <= 0 then remove_at(i) M.stats.lost = M.stats.lost + 1 sfx.lost() if M.on_lost then M.on_lost(s) end end end end function M.sync() for i = 1, #list do local s = list[i] local x, _, z = iso.entity_pos(s.id) if x then s.x, s.z = x, z end end end return M -- ゾンビTD: モジュールどうしで共有する状態(お金・選択・画面の Canvas など)。 -- require("state") はどこから呼んでも同じ表を返す。 local G = { mode = "load", -- load(マップを作っている/始め方を選んでいる)/ play / over(ベースが壊れた) money = 0, earned = 0, -- 稼いだ合計(スコアの同点を比べる) base = false, -- ベースのユニット sel = {}, -- 選んでいる兵士 bsel = false, -- 選んでいる建物(兵士とは同時に選ばない) build_mode = false, -- 「建設」で選んだ種類(次に地図をタップした所へ建てる) select_mode = false, -- 「選択」: ドラッグが範囲選択になる flow_dirty = false, -- バリケードが変わったので流れの場を作り直す speed = 1, -- 1 / 3 / 0(一時停止) info_msg = false, -- 下の欄に少しの間だけ出す言葉 info_t = 0, hud = 0, view = 0, panel = 0, -- Canvas } function G.say(msg) G.info_msg, G.info_t = msg, 2.5 pico.invalidate(G.panel) end -- お金が増えた/減った(上の行と下の欄の値段を描き直す) function G.add_money(n, earned) G.money = G.money + n if earned then G.earned = G.earned + n end pico.invalidate(G.hud) pico.invalidate(G.panel) end return G -- ゾンビTD: 説明(初めて遊ぶときだけ、地図の上に操作を1つずつ出す)。 -- 言われたことをすると次へ進む(時間で進むものもある)。案内をタップすると説明を終える。 -- 終えたことは store.json の tut に覚える(save.lua)。 local G = require("state") local soldiers = require("soldiers") local buildings = require("buildings") local waves = require("waves") local M = {} M.active = false local step, t = 1, 0 local STEPS = { { "「建設」で塔を道の近くに建てよう(同じお金なら兵士より強い)", function() return buildings.count() > 0 end }, { "柵の中のゾンビは遅く、傷つきやすい。塔の前の道に並べよう", 8 }, { "「雇う」の兵士は動かせる。穴をふさぐのに使おう", function() return soldiers.count() > 0 end }, { "兵士を選んで地面をタップ(A)すると移動", function() return G.moved end }, { "準備ができたら上の「次へ」かSTART(早いとお金)", function() return waves.phase == "wave" end }, { "選ぶと下で強化・修理・売却ができる", 8 }, { "ゾンビからベースを守り抜こう!", 5 }, } function M.start() M.active, step, t = true, 1, 0 G.moved = false end function M.stop() M.active = false end function M.text() return M.active and STEPS[step][1] or nil end -- dt 秒進める。案内が変わったら true function M.update(dt) if not M.active then return false end local s = STEPS[step] t = t + dt local done if type(s[2]) == "number" then done = t >= s[2] else done = s[2]() end if not done then return false end step, t = step + 1, 0 if step > #STEPS then M.active = false end return true end return M -- ゾンビTD: 下の欄(情報の行 + ボタンの行)。 -- 情報の行: 選んだ兵士/建物の情報と[強化][修理][売却]、建てる所の案内、準備時間の次のウェーブの予告 -- ボタン: [戻る][雇う][建設][選択](範囲選択)[x1/x3/停止](速さ)[他] -- 選ぶ一覧(メニュー)もここ。地図の上に枠を出し、タッチでもコントローラー/キーボードでも選べる -- (OSのダイアログはタッチでしか選べないので使わない)。 -- コントローラーで下の欄を選んでいる間は、そのボタンを黄色の枠で囲む(M.focus。cursor.lua が動かす)。 local G = require("state") local PAL = require("palette") local orders = require("orders") local soldiers = require("soldiers") local buildings = require("buildings") local waves = require("waves") local sfx = require("sfx") local M = {} local CX, PY, CW, INFO_H, BTN_H -- 置き場所(init で決まる) local VY, VH -- 地図の表示範囲の縦(メニューの置き場所) local actions -- game.lua の操作 {back=, center=, new_game=, sound=, tutorial=} local held = nil function M.box(x, y, w, h, label, on) pico.fill_rect(x, y, w, h, on and PAL.mid or PAL.light) pico.draw_rect(x, y, w, h, 0) if label then pico.draw_text(x + w // 2, y + (h - 16) // 2, label, 0, 0, "center") end end local box = M.box local BUTTONS = { { id = "back" }, { id = "hire", label = "雇う" }, { id = "build", label = "建設" }, { id = "select", label = "選択" }, { id = "speed" }, { id = "more", label = "他" }, } local SOLDIER_BUTTONS, BUILDING_BUTTONS M.buttons = BUTTONS local function info_buttons() if G.info_msg or G.build_mode then return nil end if G.bsel then return BUILDING_BUTTONS end if #G.sel == 1 then return SOLDIER_BUTTONS end end local function info_label(b) local bs = G.bsel if bs then local v if b.id == "upgrade" then v = buildings.upgrade_cost(bs) elseif b.id == "repair" then v = buildings.repair_cost(bs) else v = buildings.sell_value(bs) end local head = b.id == "upgrade" and "強" or (b.id == "repair" and "修" or "売") return v and (head .. v) or "-" end local s = G.sel[1] if b.id == "upgrade" then local c = soldiers.upgrade_cost(s) return c and ("強化" .. c) or "最大" end return "売" .. soldiers.sell_value(s) end -- 建物の情報の行: 名前とレベル・耐久(建設中/強化中/修理中は残り秒。耐久は頭の上のバーで見える) local function building_text(b) local left = buildings.remaining(b) if left then return string.format("%s %s%d秒", b.t.name, b.state == "build" and "建設" or (b.state == "up" and "強化" or "修理"), math.ceil(left)) end return string.format("%s%d %d/%d", b.t.name, b.lv, math.max(0, math.floor(b.hp)), buildings.max_hp(b)) end local function focus_list() local list = {} for _, b in ipairs(info_buttons() or {}) do list[#list + 1] = b end for _, b in ipairs(BUTTONS) do list[#list + 1] = b end return list end local function render() pico.fill_rect(CX, PY, CW, INFO_H + BTN_H, PAL.dark) local buttons = info_buttons() if G.info_msg then pico.draw_text(CX + 4, PY + 2, G.info_msg, 14, 0) elseif G.build_mode then local t = buildings.TYPES[G.build_mode] pico.draw_text(CX + 4, PY + 2, string.format("%s($%d) 置く所をタップ", t.name, t.cost), 14, 0) elseif G.bsel then pico.draw_text(CX + 2, PY + 2, building_text(G.bsel), 15, 0) elseif #G.sel == 1 then local s = G.sel[1] pico.draw_text(CX + 2, PY + 2, string.format("%sLv%d %d/%d", s.t.name, s.lv, math.max(0, math.floor(s.hp)), soldiers.max_hp(s)), 15, 0) elseif #G.sel > 1 then pico.draw_text(CX + 4, PY + 2, #G.sel .. "人選択中 タップで移動", 15, 0) elseif G.select_mode then pico.draw_text(CX + 4, PY + 2, "ドラッグで囲んで選ぶ", 14, 0) elseif G.mode == "play" and waves.phase == "prep" then pico.draw_text(CX + 4, PY + 2, waves.preview(waves.n), 14, 0) else pico.draw_text(CX + 4, PY + 2, string.format("兵士%d人 タップで選ぶ", soldiers.count()), 8, 0) end for _, b in ipairs(buttons or {}) do box(CX + b.x, PY + 1, b.w - 2, INFO_H - 2, nil, held == b) pico.draw_text(CX + b.x + (b.w - 2) // 2, PY + 2, info_label(b), 0, 0, "center") if M.focus == b then pico.draw_rect(CX + b.x, PY + 1, b.w - 2, INFO_H - 2, 14) end end for _, b in ipairs(BUTTONS) do local label = b.label or (b.id ~= "back" and (G.speed == 0 and "停止" or ("x" .. G.speed))) or nil box(CX + b.x + 1, PY + INFO_H + 2, b.w - 2, BTN_H - 4, label, held == b or (b.id == "select" and G.select_mode)) if b.id == "back" then -- 戻るは左向きの矢印 local mx, my = CX + b.x + b.w // 2, PY + INFO_H + 2 + (BTN_H - 4) // 2 pico.fill_triangle(mx - 6, my, mx + 5, my - 6, mx + 5, my + 6, 0) end if M.focus == b then pico.draw_rect(CX + b.x + 1, PY + INFO_H + 2, b.w - 2, BTN_H - 4, 14) pico.draw_rect(CX + b.x + 2, PY + INFO_H + 3, b.w - 4, BTN_H - 6, 14) end end end -- ---------------------------------------------------------------- メニュー local MENU_X, MENU_W, MENU_IH = 20, 200, 26 local menu = nil -- {title=, items=, fn=, cancel=, idx=} local function menu_rect() local h = 26 + #menu.items * MENU_IH + 4 return CX + MENU_X, VY + (VH - h) // 2, MENU_W, h end function M.menu_active() return menu ~= nil end -- 選ぶ一覧を出す。選んだら fn(番号。1始まり)、やめたら cancel() function M.menu(title, items, fn, cancel) menu = { title = title, items = items, fn = fn, cancel = cancel, idx = 1 } pico.invalidate(G.view) end function M.menu_close(i) local m = menu if not m then return end menu = nil pico.invalidate(G.view) if i then m.fn(i) elseif m.cancel then m.cancel() end end function M.menu_draw() if not menu then return end local x, y, w, h = menu_rect() pico.fill_rect(x, y, w, h, PAL.light) pico.draw_rect(x, y, w, h, 0) pico.draw_text(x + w // 2, y + 5, menu.title, 0, 0, "center") for i, s in ipairs(menu.items) do local iy = y + 26 + (i - 1) * MENU_IH box(x + 6, iy, w - 12, MENU_IH - 2, s, i == menu.idx) if i == menu.idx then pico.draw_rect(x + 5, iy - 1, w - 10, MENU_IH, 14) end end end -- 地図のタップ: 項目なら選ぶ、枠の外ならやめる function M.menu_tap(px, py) local x, y, w, h = menu_rect() if px < x or px >= x + w or py < y or py >= y + h then return M.menu_close(nil) end local i = (py - y - 26) // MENU_IH + 1 if py >= y + 26 and i >= 1 and i <= #menu.items then M.menu_close(i) end end -- 上下で選び、ok で決める、cancel でやめる function M.menu_input(up, down, ok, cancel) if not menu then return end local n = #menu.items if up or down then menu.idx = (menu.idx - 1 + (up and -1 or 1)) % n + 1 pico.mark_dirty(menu_rect()) end if ok then M.menu_close(menu.idx) elseif cancel then M.menu_close(nil) end end function M.press(b) local id = b.id if id == "back" then actions.back() elseif id == "hire" or id == "build" then if G.mode ~= "play" then return end if G.build_mode then G.build_mode = false; pico.invalidate(G.panel); return end local mod = id == "hire" and soldiers or buildings local items = {} for i, k in ipairs(mod.KINDS) do local t = mod.TYPES[k] items[i] = string.format("%s%s $%d", t.name, id == "hire" and "兵" or "", t.cost) end M.menu(string.format("%s($%d)", id == "hire" and "雇う" or "建てる", G.money), items, function(i) if id == "hire" then orders.hire(mod.KINDS[i]) else orders.deselect_all(); G.build_mode = mod.KINDS[i] end pico.invalidate(G.panel) end) elseif id == "select" then G.select_mode = not G.select_mode elseif id == "speed" then G.speed = G.speed == 1 and 3 or (G.speed == 3 and 0 or 1) elseif id == "more" then local more = { "ベースを見る", "選択を解除", sfx.on and "効果音を切る" or "効果音を入れる", "説明をもう一度", "新しく始める" } M.menu("その他", more, function(i) if i == 1 then actions.center() elseif i == 2 then orders.deselect_all() elseif i == 3 then actions.sound(not sfx.on) elseif i == 4 then actions.tutorial() else M.menu("今のゲームをやめますか?", { "続ける", "新しいマップで始める" }, function(k) if k == 2 then actions.new_game() end end) end end) elseif id == "upgrade" then orders.upgrade() elseif id == "repair" then orders.repair() elseif id == "sell" then orders.sell() end pico.invalidate(G.panel) end -- ---------------------------------------------------------------- フォーカス(コントローラー) M.focus = nil function M.set_focus(on) M.focus = on and BUTTONS[2] or nil -- 「雇う」から end function M.focus_move(d) local list = focus_list() local i = 0 for k, b in ipairs(list) do if b == M.focus then i = k end end if i == 0 then i = d > 0 and 0 or 1 end M.focus = list[(i - 1 + d) % #list + 1] pico.invalidate(G.panel) end -- フォーカスのボタンを押す。速さ・強化・修理はそのまま、他は押したら地図へ戻る local STAY = { speed = true, upgrade = true, repair = true } function M.focus_press() local b = M.focus local found = false for _, x in ipairs(focus_list()) do if x == b then found = true end end if not found then M.focus = BUTTONS[2]; pico.invalidate(G.panel); return end if not STAY[b.id] then M.focus = nil end M.press(b) end local function button_at(lx, ly) if ly >= INFO_H then for _, b in ipairs(BUTTONS) do if lx >= b.x and lx < b.x + b.w then return b end end else for _, b in ipairs(info_buttons() or {}) do if lx >= b.x and lx < b.x + b.w then return b end end end end -- layout = {x=, y=, w=, info_h=, btn_h=} function M.init(layout, acts) CX, PY, CW, INFO_H, BTN_H = layout.x, layout.y, layout.w, layout.info_h, layout.btn_h VY, VH = layout.vy, layout.vh MENU_X, MENU_W = 20, CW - 40 actions = acts local bw = CW // #BUTTONS for i, b in ipairs(BUTTONS) do b.x = (i - 1) * bw; b.w = (i == #BUTTONS) and (CW - b.x) or bw end SOLDIER_BUTTONS = { { id = "upgrade", x = CW - 96, w = 52 }, { id = "sell", x = CW - 42, w = 42 } } BUILDING_BUTTONS = { { id = "upgrade", x = CW - 120, w = 40 }, { id = "repair", x = CW - 80, w = 40 }, { id = "sell", x = CW - 40, w = 40 } } local panel = G.panel pico.on(panel, "render", render) pico.on(panel, "press_start", function(_, _, _, lx, ly) if menu then return end M.focus = nil held = button_at(lx, ly) pico.invalidate(panel) end) pico.on(panel, "press_end", function(_, _, _, lx, ly) local b = held held = nil pico.invalidate(panel) if b and b == button_at(lx, ly) then M.press(b) end end) pico.on(panel, "press_out", function() held = nil; pico.invalidate(panel) end) end return M  ÿÿÿÿä è ç è  ç  è k|  jz  iy  f{  <  {  ;   w ;           [ ;   Y                          (                                          $   )      &           "      "                                                                                                             L#"O'S"P'ÿÿÿÿå   á   á  ã  ã  è è  æ  å  â  æ  æ  æ ç ˆ      ‡    (                 (€      €                            €  €€  €  €€ÚÙÙÿÿÿÿå   á   á  ã  á  ã ã  ã  ã  ã  ã  ä  ä å †    ‡    (                 (€  €                            €  €€  €  €€ÚÙÙÿÿÿÿå  á  á  ã  á  ã ã  ã  ã  ã  ã  ä  ä å †    ‡(          (€  €                            €  €€  €  €€ÚÙÙ-- ゾンビTD: ウェーブ(準備時間 → ウェーブ → 準備時間 …)。仕様は docs/ZOMBIE_TD.md「流れ」「ゾンビ側」。 -- 準備時間は30秒(最初だけ60秒)。「次へ」で早く呼ぶと残り秒数×2のお金。 -- ウェーブはそのウェーブのゾンビが全部出て(順番待ちを含む)、全部倒れたら終わりで、30 + 10×ウェーブ数 のお金。 -- ウェーブが進むほど数が増え、遠距離・重量級の割合と体力・攻撃力が上がる。倒したときのお金は ×(1 + 0.05×(n-1))。 -- 序盤(W1〜4)は弱いノーマルを多めに出す「稼ぎどき」: 守りを固めておけば倒した分のお金が貯まる -- (EARLY_EXTRA × (5 - n) 体を足し、体力は ×0.6〜0.9。W1 は 9 → 17 体で、合計の体力は約1.1倍・お金は約1.9倍)。 local M = {} M.FIRST_PREP = 60 M.EARLY = 4 -- 稼ぎどきのウェーブ(ここまで) local EARLY_EXTRA = 2 M.PREP = 30 M.phase = "prep" -- prep(準備時間)/ wave M.n = 1 -- 今の(準備時間なら次の)ウェーブ M.timer = M.FIRST_PREP -- 準備時間の残り秒 function M.reset(n) M.n = n or 1 M.phase = "prep" M.timer = M.n == 1 and M.FIRST_PREP or M.PREP end -- n 番目のウェーブの中身: ノーマル・遠距離・重量級の数 function M.mix(n) local total = 6 + 3 * n + EARLY_EXTRA * math.max(0, M.EARLY + 1 - n) local ranged = n >= 2 and math.floor(total * math.min(0.3, 0.08 + 0.02 * n)) or 0 local heavy = n >= 3 and math.max(1, math.floor(total * math.min(0.25, 0.03 * (n - 2)))) or 0 return total - ranged - heavy, ranged, heavy end function M.hp_mul(n) local m = 1 + 0.08 * (n - 1) if n <= M.EARLY then m = m * (0.5 + 0.1 * n) end return m end function M.dmg_mul(n) return 1 + 0.04 * (n - 1) end function M.money_mul(n) return 1 + 0.05 * (n - 1) end function M.bonus(n) return 30 + 10 * n end -- 準備時間に出す予告 function M.preview(n) local a, b, c = M.mix(n) local s = string.format("次 W%d: %d体", n, a + b + c) if c > 0 and select(3, M.mix(n - 1)) == 0 then return s .. " 重量級が来る!" end if b > 0 and select(2, M.mix(n - 1)) == 0 then return s .. " 遠距離が来る!" end if c > 0 then return string.format("%s(遠%d・重%d)", s, b, c) end if b > 0 then s = string.format("%s(遠%d)", s, b) end if n <= M.EARLY then s = s .. " 稼ぎどき" end return s end -- ウェーブを始める: ゾンビの強さを決め、順番待ちへ混ぜて積む(重量級は散らばる) function M.start(zombies) local n = M.n zombies.hp_mul, zombies.dmg_mul = M.hp_mul(n), M.dmg_mul(n) local a, b, c = M.mix(n) local list = {} for _ = 1, a do list[#list + 1] = "normal" end for _ = 1, b do list[#list + 1] = "ranged" end for _ = 1, c do list[#list + 1] = "heavy" end for i = #list, 2, -1 do local j = math.random(1, i) list[i], list[j] = list[j], list[i] end for _, k in ipairs(list) do zombies.queue(k) end M.phase = "wave" end -- 「次へ」: 残り秒数×2 のお金を返して、すぐ始められるようにする function M.skip() if M.phase ~= "prep" then return 0 end local bonus = math.floor(M.timer) * 2 M.timer = 0 return bonus end -- dt 秒進める。返り値: "start"(準備時間が終わった)/ "clear"(ウェーブを越えた)/ nil function M.update(dt, zombies) if M.phase == "prep" then M.timer = M.timer - dt if M.timer <= 0 then return "start" end elseif zombies.alive() == 0 and zombies.waiting() == 0 then M.n = M.n + 1 M.phase = "prep" M.timer = M.PREP return "clear" end return nil end return M -- ゾンビTD: ゾンビ(出現・流れの場をたどって歩く・兵士を狙う・ベースへの攻撃・倒れる)。仕様は docs/ZOMBIE_TD.md。 -- 道は C++ の流れの場(pico.iso.flow_*)。ゾンビは自分の柱の「次の柱」の真ん中へ向かって歩くだけ。 -- 気づく範囲に兵士かタワーがいれば、優先度と距離の点数で1つを選んで追う(粘着。離れすぎ・近づけないときだけ諦める。 -- 遠距離は同じ相手へ ENGAGE_MAX 秒投げたら諦めてベースへ進む)。 -- バリケード(ブロック)の中は遅くなり(レベルごとの slow)、通っている間はバリケードの耐久を削る。 -- 中にいる間は身動きが取りにくく、受けるダメージが WALL_EXPOSE 倍になる(タワーと組み合わせる意味を持たせる)。 -- 押し合いは game.lua が C++(pico.iso.crowd)で行い、その後に M.sync() で位置を読み戻す。 -- 1匹の表は16個以内のキーにする(Luaの表はキーが16個を超えると倍の大きさになり、40匹ぶんで約16KB変わる)。 -- 種類ごとに同じ値(t・r・お金・hurt)はメタテーブルに置く。 local iso = pico.iso local combat = require("combat") local sfx = require("sfx") local M = {} local MAX_ALIVE = 40 -- 同時に出ているゾンビの上限(超えた分は順番待ち) local SPAWN_GAP = 0.35 -- 同じ出現位置から次を出すまでの秒 M.TAG = 1 -- 人や物の tag(ゾンビ) local AWARE = 4 -- 兵士に気づく範囲(マス) local GIVE_UP_DIST = AWARE * 1.5 -- これより離れたら諦める local GIVE_UP_TIME = 3 -- この秒数攻撃できない(近づけない)なら諦める local SCAN_GAP = 0.5 -- 狙う相手を探し直す間隔(秒) local COOL_DOWN = 5 -- 諦めた後、次に探すまでの秒 -- 遠距離ゾンビが同じ相手へ投げ続けてよい秒。過ぎたら諦めてベースへ向かう(COOL_DOWN の間は探さない)。 -- 兵士が持ち場から届かず、回復兵が回復し続けると、投げ合いが終わらずウェーブが終わらなかったため local ENGAGE_MAX = 6 local MELEE_GAP = 0.25 -- 当たりの円の隙間がこれ以下なら近接で叩ける -- 種類(数値は初期値。docs/ZOMBIE_TD.md「ゾンビ側」) -- bmul = 建物(タワー・バリケード)へのダメージの倍率(重量級は突破・破壊が得意) M.TYPES = { normal = { hp = 30, speed = 1.0, dmg = 5, rate = 1.0, reach = 0, sx = 0, w = 12, h = 22, r = 0.22, mass = 1, money = 5, bmul = 1 }, ranged = { hp = 20, speed = 0.9, dmg = 4, rate = 1.6, reach = 5, sx = 24, w = 12, h = 22, r = 0.22, mass = 1, money = 7, bmul = 0.5 }, heavy = { hp = 90, speed = 0.55, dmg = 12, rate = 1.4, reach = 0, sx = 48, w = 16, h = 26, r = 0.32, mass = 3, money = 12, bmul = 3 }, } local WALL_WEAR = 0.5 -- 中を通るときに削る量 = 1秒あたりの攻撃力 × 建物への倍率 × これ local WALL_EXPOSE = 1.3 -- バリケードの中で受けるダメージの倍率 local SET = {} -- entity_set に渡す表(毎回作るとゴミになり、実機でメモリが尽きるので使い回す) local img -- units.pimg local list = {} -- 出ているゾンビ local queue = {} -- 順番待ち(種類の名前) local spawns = {} -- 出現位置 {x=, z=, y=, wait=} local base -- ベースのユニット(id=, x=, y=, z=, r=, hurt=) local stand_rules local clock = 0 -- 歩くコマの時計(秒) M.list = list M.stats = { spawned = 0, killed = 0 } M.on_kill = nil -- function(ゾンビ) 倒されたとき(お金を足す) M.hunt = false -- 狙う相手を探すか(兵士もタワーも無ければ探さない。探すと毎回小さな表ができる) M.TARGET_TAGS = nil -- 狙う相手(兵士・タワー)の人や物の tag(game.lua が入れる) M.wall_at = nil -- function(x, z) その柱のバリケード(game.lua が入れる) M.hp_mul, M.dmg_mul = 1, 1 -- ウェーブごとの体力・攻撃力の倍率(waves.lua が入れる) local slow = 1 -- 今の1匹の速さの倍率(バリケードの中ならそのレベルの slow) -- 立っている柱のバリケード(高さも合っているもの。無ければ nil) local function wall_here(zb) local w = M.wall_at and M.wall_at(zb.x, zb.z) if w and w.y >= zb.gy - 0.5 and w.y <= zb.gy + 1.5 then return w end return nil end -- 叩かれた/弾が当たった(hp が 0 以下 = 倒れた。取り除くのは sweep) local function hurt(zb, dmg) if zb.hp <= 0 then return end if wall_here(zb) then dmg = math.floor(dmg * WALL_EXPOSE + 0.5) end zb.hp = zb.hp - dmg if zb.hp <= 0 then return end SET.bar = math.floor(zb.hp * 100 / (zb.t.hp * M.hp_mul)) iso.entity_set(zb.id, SET) SET.bar = nil end local META = {} for kind, t in pairs(M.TYPES) do META[kind] = { __index = { t = t, kind = kind, side = "zombie", hurt = hurt, r = t.r, money = t.money } } end function M.init(units_image, spawn_points, base_unit, rules) img = units_image spawns = {} for i, s in ipairs(spawn_points) do spawns[i] = { x = s.x, z = s.z, y = s.y, wait = 0 } end base = base_unit stand_rules = rules M.clear() end function M.clear() for i = #list, 1, -1 do combat.remove(list[i]) iso.entity_remove(list[i].id) list[i] = nil end for i = #queue, 1, -1 do queue[i] = nil end end function M.queue(kind, n) for _ = 1, n or 1 do queue[#queue + 1] = kind end end function M.alive() return #list end function M.waiting() return #queue end -- 立つ高さ(柱の一番上。バリケードの中も立てる) local function stand(x, z, y) return iso.stand(x, z, nil, stand_rules) or y end local function spawn_one(kind) local t = M.TYPES[kind] -- 空いている出現位置から(全部が待ち中なら出さない) local best for _, s in ipairs(spawns) do if s.wait <= 0 and (not best or s.wait < best.wait) then best = s end end if not best then return false end best.wait = SPAWN_GAP local x = best.x + 0.5 + (math.random() - 0.5) * 1.6 local z = best.z + 0.5 + (math.random() - 0.5) * 1.6 local y = stand(x, z, best.y) local id = iso.entity_add(img, x, y, z, { sx = t.sx, sy = 40 - t.h, w = t.w, h = t.h, r = t.r, height = t.h / 16, crowd = "move", mass = t.mass, tag = M.TAG, shadow_color = 3, bar_color = 12, }) if not id then return false end -- look = コマ(0/1) + 左向きなら2。tgt は無いとき false(キーを消さず、表の大きさを変えない) local zb = setmetatable({ id = id, x = x, y = y, z = z, gy = y, hp = math.floor(t.hp * M.hp_mul), col = -1, tx = x, tz = z, look = 0, atk = 0, tgt = false, scan = math.random() * SCAN_GAP, throw = false, eng = 0, }, META[kind]) list[#list + 1] = zb combat.add(zb) M.stats.spawned = M.stats.spawned + 1 return true end -- 気づく範囲の兵士から、点数(距離 + 優先度の順位 × 1.5)の一番小さい1人 local function find_target(zb) local best, best_s = false, nil for _, id in ipairs(iso.nearby(zb.x, zb.z, AWARE, M.TARGET_TAGS, 8)) do local s = combat.units[id] if s and s.hp > 0 then local sc = combat.dist(zb, s) + s.prio * 1.5 if not best_s or sc < best_s then best, best_s = s, sc end end end return best end -- 向き: 画面の左右(x - z が減る = 左) local function face(zb, ux, uz) local sdx = (ux - zb.x) - (uz - zb.z) if sdx < -0.05 then return true elseif sdx > 0.05 then return false end return zb.look >= 2 end local function attack(zb, tgt, dt) zb.atk = zb.atk - dt if zb.atk <= 0 then local t = zb.t zb.atk = t.rate -- 整数にする(倍率で小数になると、耐久を %d で出すところでエラーになった) local dmg = math.max(1, math.floor(t.dmg * M.dmg_mul * (tgt.side == "building" and t.bmul or 1) + 0.5)) if t.reach > 0 then combat.shoot(zb, tgt, dmg, 8, 3, 6, 0.4) else tgt:hurt(dmg, zb) end end end -- 兵士を追う/叩く。返り値: 諦めたら nil、歩いたら true、止まって戦ったら false local function fight(zb, tgt, dt) local d = combat.dist(zb, tgt) if d > GIVE_UP_DIST then return nil end local t = zb.t local reach if t.reach > 0 then reach = d <= t.reach and iso.sight(zb.x, zb.y + 1.2, zb.z, tgt.x, tgt.y + 1, tgt.z, combat.SIGHT_PASS) else reach = d - zb.r - tgt.r <= MELEE_GAP end if reach then -- 遠距離は投げ続けた時間を数え、長すぎたら諦める(届かない相手との膠着を終わらせる) if t.reach > 0 then zb.eng = zb.eng + dt if zb.eng > ENGAGE_MAX then return nil end end attack(zb, tgt, dt) zb.scan = 0 return false end -- 近づく。GIVE_UP_TIME 秒たっても攻撃できなければ諦める(scan を追った時間に使う) zb.scan = zb.scan + dt if zb.scan > GIVE_UP_TIME then return nil end return combat.walk(zb, tgt.x, tgt.z, t.speed * slow * dt, stand_rules) end -- ベースへ向かう(流れの場)。歩いたら true local function march(zb, dt) local t = zb.t local cx, cz = math.floor(zb.x), math.floor(zb.z) local col = cx * 1024 + cz if col ~= zb.col then -- 柱が変わったら、次に向かう柱と立つ高さを引き直す zb.col = col zb.gy = stand(zb.x, zb.z, zb.gy) local d, nx, nz = iso.flow_get(cx, cz) -- 遠距離ゾンビ: 届く所まで来て、地形に遮られずベースが見えたら止まって投げる zb.throw = t.reach > 0 and d ~= nil and d <= t.reach and iso.sight(zb.x, zb.gy + 1.2, zb.z, base.x + 0.5, base.y + 1.5, base.z + 0.5, combat.SIGHT_PASS) if nx then zb.tx, zb.tz = nx + 0.5, nz + 0.5 elseif d then zb.tx, zb.tz = false, false -- 目的地(ベースの隣) else zb.tx, zb.tz = base.x + 0.5, base.z + 0.5 end -- 届かない所に押し出された: ベースの方へ end if not zb.tx or zb.throw then attack(zb, base, dt) return false end local dx, dz = zb.tx - zb.x, zb.tz - zb.z local len = math.sqrt(dx * dx + dz * dz) if len <= 1e-4 then return false end local v = t.speed * slow * dt if v > len then v = len end zb.x = zb.x + dx / len * v zb.z = zb.z + dz / len * v return true end -- 1匹を dt 秒進める local function step(zb, dt) -- バリケードの中: 遅くなり、通っている間はバリケードを削る slow = 1 local w = wall_here(zb) if w then slow = w.t.lv[w.lv].slow w:hurt(zb.t.dmg / zb.t.rate * zb.t.bmul * WALL_WEAR * dt, zb) end local tgt = zb.tgt if tgt and not combat.alive(tgt) then tgt = false; zb.tgt = false; zb.scan = 0 end if not tgt and M.hunt then zb.scan = zb.scan - dt if zb.scan <= 0 then tgt = find_target(zb) zb.tgt, zb.eng = tgt, 0 zb.scan = tgt and 0 or SCAN_GAP end end local moved, lx, lz if tgt then moved = fight(zb, tgt, dt) if moved == nil then -- 諦めて流れの場へ戻る(しばらく探さない) zb.tgt, zb.scan, zb.col = false, COOL_DOWN, -1 moved = false end lx, lz = tgt.x, tgt.z else moved = march(zb, dt) if zb.tx then lx, lz = zb.tx, zb.tz else lx, lz = base.x + 0.5, base.z + 0.5 end end -- 段差は少しずつ上り下り(見た目だけ。位置の判定は柱で決まる) if zb.y ~= zb.gy then local s = 6 * dt if math.abs(zb.gy - zb.y) <= s then zb.y = zb.gy elseif zb.gy > zb.y then zb.y = zb.y + s else zb.y = zb.y - s end end -- 見た目: 歩く2コマ(時計で決める)と向き。変わったときだけ entity_set local look = ((moved and math.floor(clock * 4) % 2) or 0) + (face(zb, lx, lz) and 2 or 0) if look ~= zb.look then zb.look = look SET.sx = zb.t.sx + (look % 2) * zb.t.w SET.flip = look >= 2 iso.entity_set(zb.id, SET) SET.sx, SET.flip = nil, nil end end -- 倒れたゾンビを取り除く local function sweep() for i = #list, 1, -1 do local zb = list[i] if zb.hp <= 0 then combat.remove(zb) iso.entity_remove(zb.id) table.remove(list, i) M.stats.killed = M.stats.killed + 1 sfx.kill() if M.on_kill then M.on_kill(zb) end end end end function M.update(dt) clock = clock + dt for _, s in ipairs(spawns) do s.wait = s.wait - dt end sweep() while #queue > 0 and #list < MAX_ALIVE do if not spawn_one(queue[1]) then break end table.remove(queue, 1) end for i = 1, #list do local zb = list[i] if zb.hp > 0 then step(zb, dt) iso.entity_move(zb.id, zb.x, zb.y, zb.z) end end sweep() end -- 押し合い(iso.crowd)の後に位置を読み戻す function M.sync() for i = 1, #list do local zb = list[i] local x, _, z = iso.entity_pos(zb.id) if x then zb.x, zb.z = x, z end end end -- 兵士が持ち場から遠くまで追うべき相手か: ベースを攻撃している(石を投げている/ベースの隣で叩いている)か、 -- 遠距離で誰かへ投げている(近接のゾンビは自分から近づいて来るので含めない) function M.pressing(zb) if zb.tgt then return zb.t.reach > 0 end return zb.throw or not zb.tx end -- 倒す(テスト用) function M.kill(i) local zb = list[i] if not zb then return end zb.hp = 0 sweep() end M.MAX_ALIVE = MAX_ALIVE return M