Files
kidgo/app/lib/src/game/level.dart
T

792 lines
28 KiB
Dart

// 关卡解析与运行时: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<String, Tile> 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<String, String> 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<String, List<int>> 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<Tile> solidTiles = {
Tile.block, Tile.grass, Tile.brick, Tile.metal, Tile.ice, Tile.vanish,
};
const Set<Tile> 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<Cell> 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<String, dynamic> data;
String name;
String type;
late int w, h;
late double pxW, pxH;
late List<List<Tile>> tiles;
late List<Entity> entities;
late List<Mover> movers;
late List<Boss> bosses;
late List<dynamic> events;
late Particles particles;
late List<DeathMark> deathMarks;
late List<DeathFX> deathFX;
late Map<int, int> vanish;
Map<int, int>? vanishT;
late List<Bullet> bullets;
late List<EBullet> ebullets;
late List<dynamic> srcEntities;
late List<dynamic> moversSrc;
late List<dynamic> eventSrc;
late List<dynamic> msgs;
late List<dynamic> bossSrc;
late List<dynamic> warpSrc;
late List<dynamic> turretSrc;
late List<dynamic> 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<String, dynamic> 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 = <Map<String, dynamic>>[];
final order = <String, int>{};
for (int y = 0; y < h; y++) {
final line = rows[y];
final row = <Tile>[];
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<Map>() ?? const <Map>[];
final door = doors
.firstWhere((d) => d['tx'] == tx && d['ty'] == ty, orElse: () => <String, dynamic>{});
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<int>.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;
}