786 lines
27 KiB
Dart
786 lines
27 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';
|
|
String? 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 door = (data['doors'] as List?)?.cast<Map>()
|
|
.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;
|
|
}
|