// 关卡解析与运行时:ASCII 网格 -> tiles + 实体 + 事件。 // 与 web 版 level.js 1:1 对应(含三角刺的 SAT 判定)。 import 'dart:ui' show Canvas, Paint, Color, Rect, Offset; import 'dart:math' show min, max, sqrt; import 'package:flutter/widgets.dart'; import 'package:iwbtc/src/core/util.dart'; import 'package:iwbtc/src/game/sprites.dart'; import 'package:iwbtc/src/game/particles.dart'; import 'package:iwbtc/src/game/boss.dart'; import 'package:iwbtc/src/game/camera.dart' show CamZoneLike, LevelLike, Camera; import 'package:iwbtc/src/game/views.dart'; import 'package:iwbtc/src/audio/sfx.dart'; import 'package:iwbtc/src/game/player.dart' show Player; enum Tile { empty, block, grass, brick, metal, ice, water, oneway, killer, vanish, spikeU, spikeD, spikeL, spikeR, } const Map char2tile = { '#': Tile.block, '@': Tile.grass, '=': Tile.brick, 'M': Tile.metal, '%': Tile.ice, '~': Tile.water, '-': Tile.oneway, '*': Tile.killer, 'V': Tile.vanish, '^': Tile.spikeU, 'v': Tile.spikeD, '<': Tile.spikeL, '>': Tile.spikeR, }; const Map char2ent = { 'S': 'spawn', 'G': 'save', 'X': 'goal', 'P': 'gun', 'F': 'apple', 'J': 'jumpref', 'W': 'warp', 'T': 'sign', 'O': 'turret', 'B': 'boss', 'D': 'dropspike', 'C': 'camzone', '!': 'trigger', }; const Map> esize = { 'spawn': [16, 26], 'save': [20, 22], 'goal': [30, 42], 'gun': [28, 24], 'apple': [18, 20], 'jumpref': [26, 26], 'warp': [28, 28], 'sign': [30, 26], 'turret': [32, 32], 'boss': [64, 64], 'dropspike': [32, 32], 'camzone': [96, 96], 'trigger': [32, 32], }; const Set solidTiles = { Tile.block, Tile.grass, Tile.brick, Tile.metal, Tile.ice, Tile.vanish, }; const Set deadlyTiles = { Tile.killer, Tile.spikeU, Tile.spikeD, Tile.spikeL, Tile.spikeR, }; class Entity implements EntityView, CamZoneLike { final String type; int idx; int tx, ty; double x, y, w, h; bool active = false; bool dead = false; bool taken = false; int t = 0; int? no; bool fake = false; String? text; String dir = 'left'; int period = 90; double speed = 2.4; int timer = 0; String kind = 'ball'; // 与 web 原版一致:doorId 既可来自 data.doors[].id(常为 String), // 也可在无匹配门时回退到实体序号 idx(int)。JS 弱类型下二者都能用 === 比较; // 移植到 Dart 后若声明为 String?,回退分支把 int 赋给 String? 会抛 // "type 'int' is not a subtype of type 'String?'"。故用 Object? 容纳两种类型, // == 比较(world.dart)对 String/String、int/int 走值相等,语义与 web 一致。 Object? doorId; int pair = 0; dynamic to; int cool = 0; bool hidden = false; String mode = 'lock'; double? zoom; double? lx; double? ly; bool falling = false; double vy = 0; double homeY = 0; bool landed = false; int landT = 0; bool temp = false; double? hp; Entity(this.type, this.idx, this.tx, this.ty, this.x, this.y, this.w, this.h); } class Mover implements MoverView { double x, y, w, h; List path; int i = 0; int dir = 1; double speed; int t = 0; String mode; double dx = 0, dy = 0; double ax, ay, aw, ah; double bx, by; bool gone = false; Mover(this.x, this.y, this.w, this.h, this.path, this.speed, this.mode, this.ax, this.ay, this.aw, this.ah, this.bx, this.by); } class Cell { final double x, y; Cell(this.x, this.y); } class Bullet implements BulletView { double x, y, w, h, vx, vy; int life; bool dead = false; String col = '#fff6a0'; bool big = false; Bullet({required this.x, required this.y, required this.w, required this.h, required this.vx, required this.vy, required this.life}); } class EBullet implements BulletView { double x, y, w, h, vx, vy; int life; bool dead = false; String col; bool big; EBullet({required this.x, required this.y, required this.w, required this.h, required this.vx, required this.vy, this.life = 500, this.col = '#ff5c7a', this.big = false}); } class Level implements LevelLike { Map data; String name; String type; late int w, h; late double pxW, pxH; late List> tiles; late List entities; late List movers; late List bosses; late List events; late Particles particles; late List deathMarks; late List deathFX; late Map vanish; Map? vanishT; late List bullets; late List ebullets; late List srcEntities; late List moversSrc; late List eventSrc; late List msgs; late List bossSrc; late List warpSrc; late List turretSrc; late List camSrc; dynamic spawnExtra; String hint = ''; String bgTheme = 'jump'; int time = 0; String? toast; int toastT = 0; double shake = 0; bool beat = false; CamZoneLike? camZone; late Cell spawn; int totalApples = 0; int totalSaves = 0; int? faceIndex; Level(this.data) : name = data['name'] ?? 'STAGE', type = data['type'] ?? 'jump' { parse(data); particles = Particles(); reset(true); } // ---------------- 解析 ---------------- void parse(Map data) { final rows = (data['grid'] as List?)?.map((e) => e as String).toList() ?? []; h = rows.length; w = (data['width'] as int?) ?? rows.map((r) => r.length).fold(0, max); pxW = w * TILE; pxH = h * TILE; tiles = []; final srcEnts = >[]; final order = {}; for (int y = 0; y < h; y++) { final line = rows[y]; final row = []; for (int x = 0; x < w; x++) { final ch = x < line.length ? line[x] : ' '; if (char2tile.containsKey(ch)) { row.add(char2tile[ch]!); } else if (char2ent.containsKey(ch)) { final type = char2ent[ch]!; final ew = esize[type]![0], eh = esize[type]![1]; final idx = (order[type] = (order[type] ?? 0) + 1) - 1; srcEnts.add({'type': type, 'idx': idx, 'tx': x, 'ty': y, 'ch': ch}); row.add(Tile.empty); } else { row.add(Tile.empty); } } tiles.add(row); } srcEntities = srcEnts; moversSrc = (data['movers'] as List?) ?? []; eventSrc = (data['events'] as List?) ?? []; msgs = (data['messages'] as List?) ?? []; bossSrc = (data['bosses'] as List?) ?? []; warpSrc = (data['warps'] as List?) ?? []; turretSrc = (data['turrets'] as List?) ?? []; camSrc = (data['camzones'] as List?) ?? []; spawnExtra = data['spawn']; hint = data['hint'] ?? ''; bgTheme = data['bg'] ?? (type == 'boss' ? 'boss' : type == 'troll' ? 'troll' : type == 'endur' ? 'endur' : 'jump'); } void reset([bool full = false]) { if (full) { deathMarks = []; deathFX = []; } vanish = {}; bullets = []; ebullets = []; entities = []; movers = []; bosses = []; events = []; time = 0; toast = null; toastT = 0; particles.clear(); shake = 0; beat = false; int saveNo = 0; for (final s in srcEntities) { final type = s['type'] as String; final ew = esize[type]![0], eh = esize[type]![1]; final idx = s['idx'] as int; final tx = s['tx'] as int, ty = s['ty'] as int; final e = Entity(type, idx, tx, ty, tx * TILE + (TILE - ew) / 2, ty * TILE + (TILE - eh), ew.toDouble(), eh.toDouble()); e.t = 0; if (type == 'goal') e.y = ty * TILE + TILE - eh; if (type == 'save') { e.fake = false; e.no = ++saveNo; } if (type == 'sign') e.text = (msgs.length > idx ? msgs[idx] : '...') as String?; if (type == 'turret') { final cfg = (idx < turretSrc.length ? turretSrc[idx] : {}) as Map? ?? {}; e.dir = cfg['dir'] ?? 'left'; e.period = cfg['period'] ?? 90; e.speed = (cfg['speed'] ?? 2.4).toDouble(); e.timer = cfg['offset'] ?? 0; e.kind = cfg['kind'] ?? 'ball'; } if (type == 'warp') { final cfg = (idx < warpSrc.length ? warpSrc[idx] : {}) as Map? ?? {}; final doors = (data['doors'] as List?)?.cast() ?? const []; final door = doors .firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => {}); e.doorId = (door != null && door.isNotEmpty) ? door['id'] : idx; e.pair = cfg['pair'] ?? (idx % 2 == 0 ? idx + 1 : idx - 1); e.to = (door != null && door.isNotEmpty && door['to'] != null) ? door['to'] : (cfg['to']); e.cool = 0; e.hidden = cfg['hidden'] ?? false; } if (type == 'camzone') { final cfg = (idx < camSrc.length ? camSrc[idx] : {}) as Map? ?? {}; e.w = ((cfg['w'] ?? 3) * TILE).toDouble(); e.h = ((cfg['h'] ?? 3) * TILE).toDouble(); e.x = tx * TILE; e.y = ty * TILE; e.mode = cfg['mode'] ?? 'lock'; e.zoom = cfg['zoom']?.toDouble(); e.lx = cfg['lx']?.toDouble(); e.ly = cfg['ly']?.toDouble(); } if (type == 'dropspike') { e.falling = false; e.vy = 0; e.homeY = e.y; } if (type == 'boss') { final cfg = (idx < bossSrc.length ? bossSrc[idx] : {}) as Map? ?? {'kind': 'guy'}; e.kind = cfg['kind'] ?? 'guy'; e.hp = (cfg['hp'] ?? 40).toDouble(); e.w = ((cfg['w'] ?? 64)).toDouble(); e.h = ((cfg['h'] ?? 64)).toDouble(); e.x = tx * TILE + TILE / 2 - e.w / 2; e.y = ty * TILE + TILE - e.h; } entities.add(e); } for (final m in moversSrc) { final pathRaw = (m['path'] as List?)?.map((p) => Cell((p[0] as num) * TILE, (p[1] as num) * TILE)).toList() ?? []; final a = pathRaw.isNotEmpty ? pathRaw[0] : Cell((m['x'] as num) * TILE, (m['y'] as num) * TILE); movers.add(Mover( a.x, a.y, ((m['w'] ?? 3) as num) * TILE, ((m['h'] ?? 1) as num) * TILE, pathRaw, (m['speed'] ?? 1.2).toDouble(), m['mode'] ?? 'loop', a.x, a.y, ((m['w'] ?? 3) as num) * TILE, ((m['h'] ?? 1) as num) * TILE, (pathRaw.length > 1 ? pathRaw[1] : a).x, (pathRaw.length > 1 ? pathRaw[1] : a).y, )); } for (final ev in eventSrc) { events.add({ 'x': (ev['x'] as num) * TILE, 'y': (ev['y'] as num) * TILE, 'w': ((ev['w'] ?? 1) as num) * TILE, 'h': ((ev['h'] ?? 1) as num) * TILE, 'once': ev['once'] ?? true, 'fired': false, 'actions': ev['actions'] ?? [], 'hidden': ev['hidden'] ?? true, }); } final camPlaced = srcEntities.where((s) => s['type'] == 'camzone').length; for (int i = camPlaced; i < camSrc.length; i++) { final cfg = camSrc[i] as Map; entities.add(Entity('camzone', i, cfg['x'] as int, cfg['y'] as int, (cfg['x'] as num) * TILE, (cfg['y'] as num) * TILE, ((cfg['w'] ?? 3) as num) * TILE, ((cfg['h'] ?? 3) as num) * TILE) ..mode = cfg['mode'] ?? 'lock' ..zoom = (cfg['zoom'] as num?)?.toDouble() ..lx = (cfg['lx'] as num?)?.toDouble() ..ly = (cfg['ly'] as num?)?.toDouble()); } for (final e in entities) { if (e.type == 'boss') bosses.add(Boss(this, e)); } final sp = entities.firstWhere((e) => e.type == 'spawn', orElse: () => Entity('spawn', 0, 0, 0, 0, 0, 0, 0)); if (sp.type == 'spawn') { spawn = Cell(sp.x + sp.w / 2 - 5.5, sp.y + sp.h - 21); } else if (spawnExtra != null) { spawn = Cell((spawnExtra[0] as num) * TILE, (spawnExtra[1] as num) * TILE); } else { spawn = Cell(40, 40); } totalApples = entities.where((e) => e.type == 'apple').length; totalSaves = saveNo; } /// 死亡后的软重置:保留存档点与已拾取道具,其余复位(不清粒子/震屏)。 void softReset() { vanish.clear(); vanishT?.clear(); bullets.clear(); ebullets.clear(); for (final m in movers) { final a = m.path.isNotEmpty ? m.path[0] : Cell(m.x, m.y); m.x = a.x; m.y = a.y; m.i = 0; m.dx = 0; m.dy = 0; final nx = m.path.length > 1 ? m.path[1] : a; m.ax = m.x; m.ay = m.y; m.bx = nx.x; m.by = nx.y; } for (final e in entities) { if (e.type == 'dropspike') { if (e.temp) { e.dead = true; continue; } e.y = e.homeY; e.falling = false; e.vy = 0; e.landed = false; e.landT = 0; } if (e.type == 'warp') e.cool = 0; } entities = entities.where((e) => !e.dead).toList(); for (final ev in events) ev['fired'] = false; for (final b in bosses) { b.hp = b.maxHp; b.dead = false; b.deathT = 0; b.bodyKill = true; b.t = 0; b.state = 'idle'; b.stateT = 0; b.phase = 1; b.x = b.homeX; b.y = b.homeY; b.vx = 0; b.vy = 0; } } void addDeathMark(double x, double y) { deathMarks.clear(); deathMarks.add(DeathMark(x, y)); } void addDeathFX(double x, double y) { deathFX.add(DeathFX(x, y, 0)); } // ---------------- 查询 ---------------- Tile tileAt(int tx, int ty) { if (tx < 0 || ty < 0 || tx >= w || ty >= h) return ty < 0 ? Tile.empty : Tile.block; return tiles[ty][tx]; } bool isSolidTile(Tile t, int tx, int ty) { if (!solidTiles.contains(t)) return false; if (t == Tile.vanish) { final st = vanishState(tx, ty); return st != 2; } return true; } int vanishState(int tx, int ty) => vanish[ty * 1000 + tx] ?? 0; void setVanish(int tx, int ty, int st) => vanish[ty * 1000 + tx] = st; bool solidRect(double x, double y, double ww, double hh, {bool dropThrough = false, double? prevBottom, Mover? moverExclude}) { final x0 = (x / TILE).floor(); final x1 = ((x + ww - 0.001) / TILE).floor(); final y0 = (y / TILE).floor(); final y1 = ((y + hh - 0.001) / TILE).floor(); for (int ty = y0; ty <= y1; ty++) { for (int tx = x0; tx <= x1; tx++) { final t = tileAt(tx, ty); if (t == Tile.oneway) { if (!dropThrough && prevBottom != null && prevBottom <= ty * TILE + 6) { if (y + hh <= ty * TILE + 8) continue; if (prevBottom > ty * TILE + 2) continue; return true; } continue; } if (isSolidTile(t, tx, ty)) return true; } } for (final m in movers) { if (m.gone) continue; if (x < m.x + m.w && x + ww > m.x && y < m.y + m.h && y + hh > m.y) { if (moverExclude == m) continue; return true; } } return false; } bool deadlyRect(double x, double y, double ww, double hh) { final x0 = (x / TILE).floor(); final x1 = ((x + ww - 0.001) / TILE).floor(); final y0 = (y / TILE).floor(); final y1 = ((y + hh - 0.001) / TILE).floor(); for (int ty = y0; ty <= y1; ty++) { for (int tx = x0; tx <= x1; tx++) { final t = tileAt(tx, ty); if (!deadlyTiles.contains(t)) continue; final tr = spikeTri(t, tx * TILE, ty * TILE); if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, ww, hh)) return true; } } for (final e in entities) { if (e.type == 'dropspike' && e.falling) { final tr = spikeTri(Tile.spikeL, e.x, e.y); if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, ww, hh)) return true; } } for (final b in ebullets) { if (b.dead) continue; if (overlap(Box(x, y, ww, hh), Box(b.x, b.y, b.w, b.h))) return true; } for (final bs in bosses) { if (bs.dead || !bs.bodyKill) continue; if (overlap(Box(x, y, ww, hh), Box(bs.x, bs.y, bs.w, bs.h))) return true; } return false; } bool waterRect(double x, double y, double ww, double hh) { final x0 = (x / TILE).floor(); final x1 = ((x + ww - 0.001) / TILE).floor(); final y0 = (y / TILE).floor(); final y1 = ((y + hh - 0.001) / TILE).floor(); for (int ty = y0; ty <= y1; ty++) for (int tx = x0; tx <= x1; tx++) if (tileAt(tx, ty) == Tile.water) return true; return false; } bool iceAtBottom(double x, double y, double ww, double hh) { final tx0 = (x / TILE).floor(); final tx1 = ((x + ww - 1) / TILE).floor(); final ty = ((y + hh + 1) / TILE).floor(); for (int tx = tx0; tx <= tx1; tx++) if (tileAt(tx, ty) == Tile.ice) return true; return false; } // ---------------- 更新 ---------------- void update(int dt, Player player, Camera cam) { time += dt; final t = time; for (int i = deathFX.length - 1; i >= 0; i--) { if (++deathFX[i].t > 14) deathFX.removeAt(i); } for (final m in movers) { if (m.path.isEmpty) continue; final tgt = m.path[(m.i + 1) % m.path.length]; final ox = m.x, oy = m.y; final dx = tgt.x - m.x, dy = tgt.y - m.y; final dist = sqrt(dx * dx + dy * dy); if (dist <= m.speed) { m.x = tgt.x; m.y = tgt.y; m.i = (m.i + 1) % m.path.length; } else { m.x += dx / dist * m.speed; m.y += dy / dist * m.speed; } m.dx = m.x - ox; m.dy = m.y - oy; final next = m.path[(m.i + 1) % m.path.length]; m.ax = m.x; m.ay = m.y; m.bx = next.x; m.by = next.y; } for (final key in List.from(vanish.keys)) { final st = vanish[key]!; if (st == 0) continue; final tt = (vanishT?[key] ?? 0) + 1; vanishT ??= {}; vanishT![key] = tt; if (st == 1 && tt > 55) { vanish[key] = 2; vanishT![key] = 0; } else if (st == 2 && tt > 110) { vanish[key] = 0; vanishT![key] = 0; } } for (final e in entities) { if (e.type != 'dropspike') continue; if (!e.falling) { final px = player.x + player.w / 2; if ((px - (e.x + e.w / 2)).abs() < 26 && (player.y - e.y).abs() < 260 && player.y > e.y) { e.falling = true; e.vy = 0; Sfx.instance.vanish(); } } else if (e.landed) { e.landT++; if (e.landT > 80) { e.dead = true; e.falling = false; } } else { e.vy = min(e.vy + 0.5, 11); e.y += e.vy; final ty = ((e.y + e.h) / TILE).floor(); final tx = ((e.x + e.w / 2) / TILE).floor(); if (isSolidTile(tileAt(tx, ty), tx, ty)) { e.y = ty * TILE - e.h; e.vy = 0; e.landed = true; e.landT = 0; shake = max(shake, 5); } if (e.y > pxH + 100) e.dead = true; } } for (final e in entities) { if (e.type != 'turret') continue; e.t++; if (e.t % (e.period) == 0) { final cx = e.x + e.w / 2, cy = e.y + e.h / 2; double vx = 0, vy = 0; if (e.dir == 'left') vx = -e.speed; else if (e.dir == 'right') vx = e.speed; else if (e.dir == 'up') vy = -e.speed; else vy = e.speed; if (e.kind == 'aim' && player.alive) { final dx = (player.x + player.w / 2) - cx, dy = (player.y + player.h / 2) - cy; final d = max(sqrt(dx * dx + dy * dy), 1e-6); vx = dx / d * e.speed; vy = dy / d * e.speed; } ebullets.add(EBullet(x: cx - 4, y: cy - 4, w: 8, h: 8, vx: vx, vy: vy, col: '#ff5c7a')); Sfx.instance.shoot(); } } for (final b in ebullets) { b.x += b.vx; b.y += b.vy; b.life--; if (b.life <= 0) b.dead = true; final tx = ((b.x + b.w / 2) / TILE).floor(), ty = ((b.y + b.h / 2) / TILE).floor(); if (isSolidTile(tileAt(tx, ty), tx, ty)) { b.dead = true; particles.spawn(b.x + 4, b.y + 4, 4, colors: const ['#ff5c7a', '#fff'], speed: 2, life: 14); } } ebullets.removeWhere((b) => b.dead); for (final b in bullets) { b.x += b.vx; b.y += b.vy; b.life--; if (b.life <= 0) b.dead = true; final tx = ((b.x + b.w / 2) / TILE).floor(), ty = ((b.y + b.h / 2) / TILE).floor(); final tt = tileAt(tx, ty); if (tt == Tile.block || tt == Tile.grass || tt == Tile.brick || tt == Tile.metal) { b.dead = true; particles.spawn(b.x + 3, b.y + 3, 5, colors: const ['#fff6a0', '#ffb040'], speed: 2.4, life: 16); } } bullets.removeWhere((b) => b.dead); for (final ev in events) { if (ev['fired'] && ev['once']) continue; if (overlap(Box(player.x, player.y, player.w, player.h), Box(ev['x'], ev['y'], ev['w'], ev['h']))) { fireEvent(ev, player); } } camZone = null; for (final e in entities) { if (e.type != 'camzone') continue; if (overlap(Box(player.x + player.w / 2 - 1, player.y + player.h / 2 - 1, 2, 2), Box(e.x, e.y, e.w, e.h))) { camZone = e; break; } } if (shake > 0) shake *= 0.86; if (toastT > 0 && --toastT == 0) toast = null; particles.update(); } void fireEvent(Map ev, Player player) { ev['fired'] = true; for (final a in (ev['actions'] as List)) { final type = a['type']; if (type == 'msg') { showToast(a['text'] ?? '', a['dur'] ?? 150); } else if (type == 'spikes') { for (int i = 0; i < (a['count'] ?? 5); i++) { final x = ((a['x'] + i * (a['gap'] ?? 1)) as num) * TILE; entities.add(Entity('dropspike', 0, 0, 0, x, ((a['y'] ?? 0) as num) * TILE, TILE, TILE) ..falling = true ..vy = 0 ..homeY = ((a['y'] ?? 0) as num) * TILE ..temp = true); } Sfx.instance.troll(); } else if (type == 'vanish') { for (int i = 0; i < (a['count'] ?? 3); i++) { final tx = (a['x'] + i) as int, ty = a['y'] as int; if (tileAt(tx, ty) == Tile.empty) continue; setVanish(tx, ty, 1); vanishT ??= {}; vanishT![ty * 1000 + tx] = 0; } Sfx.instance.vanish(); } else if (type == 'warp') { player.x = (a['x'] as num) * TILE; player.y = (a['y'] as num) * TILE; player.vx = 0; player.vy = 0; Sfx.instance.warp(); } else if (type == 'kill') { player.kill(); } else if (type == 'shake') { shake = (a['v'] ?? 10).toDouble(); } } } void showToast(String text, [int dur = 150]) { toast = text; toastT = dur; } // ---------------- 绘制 ---------------- void drawBg(Canvas c, Camera cam, int t) { final th = { 'jump': ['#1a1830', '#0a0a14'], 'endur': ['#101f24', '#080c10'], 'boss': ['#2a1018', '#0d0810'], 'troll': ['#221432', '#0b0812'], 'custom': ['#141428', '#08080f'], }[bgTheme] ?? ['#1a1830', '#0a0a14']; // 主题双色近似(顶/底) c.drawRect(Rect.fromLTWH(0, 0, cam.w, cam.h), Paint()..color = hex(th[1])); c.drawRect(Rect.fromLTWH(0, 0, cam.w, cam.h * 0.5), Paint()..color = hex(th[0])); final px = -cam.x * 0.25, py = -cam.y * 0.15; final sp = Paint()..color = const Color(0x29ffffff); for (int i = 0; i < 60; i++) { final nx = hashNoise(i, 7) * 2000, ny = hashNoise(i, 13) * 900; final sx = ((nx + px) % (cam.w + 200)) - 100; final sy = ((ny + py) % (cam.h + 120)) - 60; c.drawRect(Rect.fromLTWH(sx, sy, 2, 2), sp); } final gp = Paint()..color = const Color(0x0affffff); final off = TILE * 2; final sx0 = -((cam.x % off)); final sy0 = -((cam.y % off)); for (double x = sx0; x < cam.w; x += off) { c.drawLine(Offset(x + 0.5, 0), Offset(x + 0.5, cam.h), gp); } for (double y = sy0; y < cam.h; y += off) { c.drawLine(Offset(0, y + 0.5), Offset(cam.w, y + 0.5), gp); } } void draw(Canvas c, Camera cam, int t) { final vx0 = max(0, (cam.x / TILE).floor() - 1); final vx1 = min(w - 1, (((cam.x + cam.w / (cam.zoom)) / TILE).ceil() + 1)); final vy0 = max(0, (cam.y / TILE).floor() - 1); final vy1 = min(h - 1, (((cam.y + cam.h / (cam.zoom)) / TILE).ceil() + 1)); for (int ty = vy0; ty <= vy1; ty++) { for (int tx = vx0; tx <= vx1; tx++) { final v = tiles[ty][tx]; if (v == Tile.empty) continue; final x = tx * TILE, y = ty * TILE; switch (v) { case Tile.block: drawBlock(c, x, y, 0); break; case Tile.grass: drawBlock(c, x, y, 1); break; case Tile.brick: drawBlock(c, x, y, 2); break; case Tile.metal: drawBlock(c, x, y, 3); break; case Tile.ice: drawIce(c, x, y); break; case Tile.water: drawWater(c, x, y, t); break; case Tile.oneway: drawOneWay(c, x, y); break; case Tile.killer: drawKiller(c, x, y, t); break; case Tile.vanish: drawVanish(c, x, y, vanishState(tx, ty), t); break; case Tile.spikeU: drawSpike(c, x, y, 0, t); break; case Tile.spikeD: drawSpike(c, x, y, 1, t); break; case Tile.spikeL: drawSpike(c, x, y, 2, t); break; case Tile.spikeR: drawSpike(c, x, y, 3, t); break; default: break; } } } for (final m in movers) drawMovingPlatform(c, m); for (final e in entities) { if (e.dead) continue; switch (e.type) { case 'save': drawSave(c, e, t); break; case 'goal': drawGoal(c, e, t); break; case 'gun': if (!e.taken) drawGun(c, e, t); break; case 'apple': if (!e.taken) drawApple(c, e, t); break; case 'jumpref': drawJumpRef(c, e.x, e.y, t); break; case 'warp': if (!e.hidden) drawWarp(c, e, t); break; case 'sign': drawSign(c, e, t); break; case 'turret': drawTurret(c, e, t); break; case 'camzone': drawCamera(c, e, t); break; case 'dropspike': drawDropSpike(c, e, t); break; default: break; } } for (final b in bullets) drawBullet(c, b); for (final b in ebullets) drawEBullet(c, b, t); for (final dm in deathMarks) drawDeathMark(c, dm); for (final fx in deathFX) drawDeathRing(c, fx); particles.draw(c); } void drawOverlay(Canvas c, Camera cam, int t) { for (final e in entities) { if (e.type != 'sign') continue; final sx = (e.x + e.w / 2 - cam.x) * (cam.zoom); final sy = (e.y - 6 - cam.y) * (cam.zoom); if (sx < -80 || sx > cam.w + 80) continue; final txt = e.text ?? ''; final tp = TextPainter( text: TextSpan(text: txt, style: TextStyle(color: const Color(0xffffe9a8), fontSize: 11, fontFamily: 'Courier')), textDirection: TextDirection.ltr, ); tp.layout(); c.drawRect(Rect.fromLTWH(sx - tp.width / 2 - 5, sy - 13, tp.width + 10, 16), Paint()..color = rgba(0, 0, 0, 0.72)); tp.paint(c, Offset(sx - tp.width / 2, sy)); } } } // ---------------- 三角刺顶点与 SAT 判定 ---------------- class Tri { final double ax, ay, bx, by, cx, cy; Tri(this.ax, this.ay, this.bx, this.by, this.cx, this.cy); } Tri spikeTri(Tile t, double x, double y) { const s = TILE; if (t == Tile.spikeU) return Tri(x + s / 2, y, x, y + s, x + s, y + s); if (t == Tile.spikeD) return Tri(x + s / 2, y + s, x, y, x + s, y); if (t == Tile.spikeL) return Tri(x, y + s / 2, x + s, y, x + s, y + s); if (t == Tile.spikeR) return Tri(x + s, y + s / 2, x, y, x, y + s); return Tri(x, y, x + s, y, x, y + s); } bool triRectOverlap(double ax, double ay, double bx, double by, double cx, double cy, double rx, double ry, double rw, double rh) { final rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh; final tMinX = min(ax, min(bx, cx)), tMaxX = max(ax, max(bx, cx)); final tMinY = min(ay, min(by, cy)), tMaxY = max(ay, max(by, cy)); if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; final edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]]; for (final e in edges) { final ex = e[0] as double, ey = e[1] as double; final nx = -ey, ny = ex; final t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy; final tMin = min(t0, min(t1, t2)), tMax = max(t0, max(t1, t2)); final p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY; final p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY; final rMin = min(p0, min(p1, min(p2, p3))), rMax = max(p0, max(p1, max(p2, p3))); if (tMax < rMin || rMax < tMin) return false; } return true; }