refactor: 使用flutter对原先的web项目重构,正在进行中
This commit is contained in:
1 parent
273224f5cf
commit
18017f819a
205 files changed
+13563
No files matched your search
@@ -0,0 +1,287 @@
|
||||
// BOSS 战系统:多阶段弹幕 / 冲撞 / 刺雨。对应 web 版 boss.js。
|
||||
import 'dart:ui' show Canvas, Paint, Path, Color, Rect, Offset, PaintingStyle;
|
||||
import 'dart:math' show sin, cos, atan2, pi, min, max;
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
import 'package:iwbtc/src/audio/sfx.dart';
|
||||
import 'package:iwbtc/src/game/level.dart' show Level, Entity, EBullet;
|
||||
import 'package:iwbtc/src/game/views.dart' show PlayerView;
|
||||
|
||||
const Map<String, dynamic> defs = {
|
||||
'guy': {'hp': 42, 'w': 64, 'h': 74, 'name': 'THE GUY'},
|
||||
'mecha': {'hp': 62, 'w': 78, 'h': 62, 'name': 'MECHA KID'},
|
||||
'devil': {'hp': 84, 'w': 74, 'h': 90, 'name': 'THE DEVIL'},
|
||||
};
|
||||
|
||||
class Boss {
|
||||
final Level level;
|
||||
final String kind;
|
||||
final String name;
|
||||
double x, y, w, h;
|
||||
double maxHp;
|
||||
double hp;
|
||||
int flash = 0;
|
||||
bool dead = false;
|
||||
int deathT = 0;
|
||||
bool bodyKill = true;
|
||||
int t = 0;
|
||||
int phase = 1;
|
||||
double vx = 0, vy = 0;
|
||||
String state = 'idle';
|
||||
int stateT = 0;
|
||||
late double homeX, homeY;
|
||||
double wing = 0;
|
||||
late Map bounds;
|
||||
void Function()? onDefeat;
|
||||
|
||||
Boss(this.level, Entity e)
|
||||
: kind = e.kind,
|
||||
name = (defs[e.kind] ?? defs['guy'])['name'],
|
||||
x = e.x, y = e.y,
|
||||
w = (defs[e.kind] ?? defs['guy'])['w'].toDouble(),
|
||||
h = (defs[e.kind] ?? defs['guy'])['h'].toDouble(),
|
||||
maxHp = (e.hp ?? (defs[e.kind] ?? defs['guy'])['hp']).toDouble(),
|
||||
hp = (e.hp ?? (defs[e.kind] ?? defs['guy'])['hp']).toDouble() {
|
||||
bounds = {
|
||||
'x0': 40.0, 'x1': level.pxW - 40 - w,
|
||||
'y0': 40.0, 'y1': level.pxH - 32 - h,
|
||||
};
|
||||
homeY = clamp(e.y, bounds['y0'], bounds['y1']);
|
||||
y = homeY;
|
||||
homeX = e.x;
|
||||
}
|
||||
|
||||
Box get rect => Box(x, y, w, h);
|
||||
|
||||
void shoot(double x2, double y2, double vx, double vy, [String col = '#ff5c7a', bool big = false]) {
|
||||
level.ebullets.add(EBullet(x: x2 - 4, y: y2 - 4, w: big ? 12 : 8, h: big ? 12 : 8, vx: vx, vy: vy, col: col, big: big));
|
||||
}
|
||||
|
||||
void hit([int dmg = 1]) {
|
||||
if (dead || flash > 6) return;
|
||||
hp -= dmg;
|
||||
flash = 10;
|
||||
Sfx.instance.bossHit();
|
||||
level.particles.spawn(x + w / 2, y + h / 2, 6, colors: const ['#fff', '#ffc63a'], speed: 2.6, life: 18);
|
||||
final r = hp / maxHp;
|
||||
phase = r > 0.6 ? 1 : r > 0.3 ? 2 : 3;
|
||||
if (hp <= 0) die();
|
||||
}
|
||||
|
||||
void die() {
|
||||
dead = true; hp = 0; bodyKill = false;
|
||||
Sfx.instance.bossDie();
|
||||
level.shake = 16;
|
||||
level.particles.burst(x + w / 2, y + h / 2,
|
||||
n: 40, colors: const ['#ff5c7a', '#ffc63a', '#fff', '#e03050'], speed: 5, life: 60, size: 4);
|
||||
}
|
||||
|
||||
void update(int dt, PlayerView player) {
|
||||
if (dead) {
|
||||
deathT++;
|
||||
if (deathT % 8 == 0 && deathT < 90) {
|
||||
level.particles.burst(x + rand(0, w), y + rand(0, h),
|
||||
n: 6, colors: const ['#ffc63a', '#ff5c7a', '#fff'], speed: 3, life: 30, size: 3);
|
||||
Sfx.instance.hit();
|
||||
}
|
||||
if (deathT == 90 && onDefeat != null) onDefeat!();
|
||||
return;
|
||||
}
|
||||
t++;
|
||||
stateT++;
|
||||
if (flash > 0) flash--;
|
||||
|
||||
if (kind == 'guy') updateGuy(player);
|
||||
else if (kind == 'mecha') updateMecha(player);
|
||||
else updateDevil(player);
|
||||
|
||||
x += vx; y += vy;
|
||||
x = clamp(x, bounds['x0'], max(bounds['x0'], bounds['x1']));
|
||||
y = clamp(y, bounds['y0'], max(bounds['y0'], bounds['y1']));
|
||||
|
||||
for (final b in level.bullets) {
|
||||
if (b.dead) continue;
|
||||
if (overlap(Box(b.x, b.y, b.w, b.h), rect)) {
|
||||
b.dead = true;
|
||||
hit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateGuy(PlayerView player) {
|
||||
final L = level;
|
||||
if (phase == 1) {
|
||||
vx = sin(t / 60) * 1.5;
|
||||
y = homeY + sin(t / 45) * 8;
|
||||
if (t % 96 == 0) {
|
||||
final cx = x + w / 2, cy = y + h / 2;
|
||||
for (int i = -1; i <= 1; i++) {
|
||||
final a = atan2(player.cy - cy, player.cx - cx) + i * 0.28;
|
||||
shoot(cx, cy, cos(a) * 2.8, sin(a) * 2.8);
|
||||
}
|
||||
Sfx.instance.shoot();
|
||||
}
|
||||
} else {
|
||||
final dir = sgn(player.cx - (x + w / 2));
|
||||
if (state == 'idle') {
|
||||
vx = dir * 1.9;
|
||||
y = homeY + sin(t / 30) * 6;
|
||||
if (stateT > 70) { state = 'slam'; stateT = 0; vy = 0; }
|
||||
} else if (state == 'slam') {
|
||||
vx *= 0.9;
|
||||
vy = min(vy + 0.7, 11);
|
||||
if (y + h >= bounds['y1'] + h * 0.2 || y >= homeY + 150) {
|
||||
state = 'idle'; stateT = 0; vy = -3;
|
||||
L.shake = 10; Sfx.instance.hit();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
shoot(x + w / 2, y + h - 6, -2.6 - i * 0.4, -1.2 - i * 0.15, '#ffc63a');
|
||||
shoot(x + w / 2, y + h - 6, 2.6 + i * 0.4, -1.2 - i * 0.15, '#ffc63a');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateMecha(PlayerView player) {
|
||||
final L = level;
|
||||
if (state == 'idle') {
|
||||
vx *= 0.92;
|
||||
y = homeY + sin(t / 40) * 5;
|
||||
if (stateT > 100 - phase * 18) {
|
||||
state = 'dash'; stateT = 0;
|
||||
vx = sgn(player.cx - (x + w / 2)) * (5 + phase * 0.8);
|
||||
Sfx.instance.shoot();
|
||||
}
|
||||
if (t % 150 == 0) {
|
||||
for (int i = 0; i < 4 + phase; i++) {
|
||||
final tx = randInt(1, L.w - 2);
|
||||
L.entities.add(Entity('dropspike', 0, 0, 0, tx * TILE, -TILE, TILE, TILE)
|
||||
..falling = true
|
||||
..vy = 0
|
||||
..homeY = -TILE
|
||||
..temp = true);
|
||||
}
|
||||
L.showToast('刺雨来了!', 80);
|
||||
Sfx.instance.troll();
|
||||
}
|
||||
} else if (state == 'dash') {
|
||||
vy = 0;
|
||||
if (x <= bounds['x0'] + 1 || x >= bounds['x1'] - 1 || stateT > 90) {
|
||||
state = 'idle'; stateT = 0; vx = 0;
|
||||
L.shake = 6;
|
||||
L.particles.spawn(x + w / 2, y + h / 2, 8, colors: const ['#9ad8ff', '#fff'], speed: 3, life: 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateDevil(PlayerView player) {
|
||||
final L = level;
|
||||
wing += 0.16;
|
||||
final cx = x + w / 2, cy = y + h / 2;
|
||||
final p2 = phase;
|
||||
if (p2 == 1) { vx = sin(t / 70) * 1.6; y = homeY + sin(t / 50) * 8; }
|
||||
else if (p2 == 2) { vx = sin(t / 40) * 2.6; y = homeY + sin(t / 28) * 11; }
|
||||
else { vx = sin(t / 25) * 3.4; y = homeY + sin(t / 20) * 14; }
|
||||
|
||||
if (p2 == 1) {
|
||||
if (t % 60 == 0) {
|
||||
final a = atan2(player.cy - cy, player.cx - cx);
|
||||
for (int i = -1; i <= 1; i++) shoot(cx, cy, cos(a + i * 0.2) * 3, sin(a + i * 0.2) * 3, '#ff9a3a');
|
||||
Sfx.instance.shoot();
|
||||
}
|
||||
} else if (p2 == 2) {
|
||||
if (t % 110 == 0) { state = 'laser'; stateT = 0; }
|
||||
if (state == 'laser' && stateT < 40 && stateT % 5 == 0) {
|
||||
final yy = 40 + (stateT / 40) * (L.pxH - 90);
|
||||
shoot(8, yy, 6.5, 0, '#ff4060', true);
|
||||
shoot(L.pxW - 12, L.pxH - yy, -6.5, 0, '#ff4060', true);
|
||||
Sfx.instance.shoot();
|
||||
}
|
||||
if (stateT > 44) state = 'idle';
|
||||
} else {
|
||||
if (t % 34 == 0) {
|
||||
const n = 10;
|
||||
final off = t / 200;
|
||||
for (int i = 0; i < n; i++) {
|
||||
final a = off + (i / n) * 2 * pi;
|
||||
shoot(cx, cy, cos(a) * 2.6, sin(a) * 2.6, '#ff5c7a');
|
||||
}
|
||||
Sfx.instance.shoot();
|
||||
}
|
||||
if (t % 170 == 0) {
|
||||
final a = atan2(player.cy - cy, player.cx - cx);
|
||||
shoot(cx, cy, cos(a) * 4.4, sin(a) * 4.4, '#ffc63a', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 绘制 ----------------
|
||||
void draw(Canvas c, int t) {
|
||||
if (dead && deathT > 96) return;
|
||||
final x = this.x, y = this.y, w = this.w, h = this.h;
|
||||
final fl = flash > 0;
|
||||
c.save();
|
||||
if (kind == 'guy') drawGuy(c, x, y, w, h, fl, t);
|
||||
else if (kind == 'mecha') drawMecha(c, x, y, w, h, fl, t);
|
||||
else drawDevil(c, x, y, w, h, fl, t);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
void drawGuy(Canvas c, double x, double y, double w, double h, bool fl, int t) {
|
||||
final c1 = fl ? const Color(0xffffffff) : const Color(0xff2a1020);
|
||||
final c2 = fl ? const Color(0xffffffff) : const Color(0xff5a1a34);
|
||||
c.drawCircle(Offset(x + w / 2, y + h * 0.62), w * 0.42, Paint()..color = c1);
|
||||
c.drawCircle(Offset(x + w / 2, y + h * 0.3), w * 0.36, Paint()..color = c2);
|
||||
final horn = fl ? const Color(0xffffffff) : const Color(0xffc8305a);
|
||||
_tri(c, x + w * 0.2, y + h * 0.16, x + w * 0.05, y - h * 0.12, x + w * 0.34, y + h * 0.06, horn);
|
||||
_tri(c, x + w * 0.8, y + h * 0.16, x + w * 0.95, y - h * 0.12, x + w * 0.66, y + h * 0.06, horn);
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.3, y + h * 0.28, w * 0.14, h * 0.1), Paint()..color = fl ? const Color(0xff000000) : const Color(0xffffe14a));
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.56, y + h * 0.28, w * 0.14, h * 0.1), Paint()..color = fl ? const Color(0xff000000) : const Color(0xffffe14a));
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.34, y + h * 0.46, w * 0.32, h * 0.06), Paint()..color = fl ? const Color(0xff000000) : const Color(0xff801025));
|
||||
if (phase >= 2) {
|
||||
final sp = Paint()
|
||||
..color = fl ? const Color(0xff000000) : const Color(0xffff4060)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
c.drawCircle(Offset(x + w / 2, y + h / 2), w * 0.62 + sin(t / 90) * 3, sp);
|
||||
}
|
||||
}
|
||||
|
||||
void drawMecha(Canvas c, double x, double y, double w, double h, bool fl, int t) {
|
||||
c.drawRect(Rect.fromLTWH(x + 4, y + 6, w - 8, h - 10), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff3a4258));
|
||||
c.drawRect(Rect.fromLTWH(x + 8, y + 10, w - 16, h - 20), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff68728e));
|
||||
final blink = (t ~/ 200) % 5 == 0;
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.22, y + h * 0.34, w * 0.18, h * 0.14), Paint()..color = fl ? const Color(0xff000000) : (state == 'dash' ? const Color(0xffff4060) : const Color(0xff7ae0ff)));
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.6, y + h * 0.34, w * 0.18, h * 0.14), Paint()..color = fl ? const Color(0xff000000) : (state == 'dash' ? const Color(0xffff4060) : const Color(0xff7ae0ff)));
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.42, y + h - 8, w * 0.16, 10), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff2a2f40));
|
||||
c.drawRect(Rect.fromLTWH(x + w / 2 - 1, y - 6, 2, 8), Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff8a94ad));
|
||||
c.drawCircle(Offset(x + w / 2, y - 8), 3, Paint()..color = (t ~/ 120) % 2 == 0 ? const Color(0xffff4060) : const Color(0xff601020));
|
||||
if (state == 'dash') {
|
||||
c.drawRect(Rect.fromLTWH(x - 6, y + h * 0.4, 8, h * 0.2), Paint()..color = const Color(0xffffb040));
|
||||
c.drawRect(Rect.fromLTWH(x + w - 2, y + h * 0.4, 8, h * 0.2), Paint()..color = const Color(0xffffb040));
|
||||
}
|
||||
}
|
||||
|
||||
void drawDevil(Canvas c, double x, double y, double w, double h, bool fl, int t) {
|
||||
final fl2 = sin(this.wing) * 8;
|
||||
final wingCol = fl ? const Color(0xffffffff) : const Color(0xff4a0f22);
|
||||
_tri(c, x + 6, y + h * 0.4, x - 26, y + h * 0.1 + fl2, x - 4, y + h * 0.72 + fl2, wingCol);
|
||||
_tri(c, x + w - 6, y + h * 0.4, x + w + 26, y + h * 0.1 + fl2, x + w + 4, y + h * 0.72 + fl2, wingCol);
|
||||
c.drawCircle(Offset(x + w / 2, y + h * 0.6), w * 0.4, Paint()..color = fl ? const Color(0xffffffff) : const Color(0xff8e1030));
|
||||
c.drawCircle(Offset(x + w / 2, y + h * 0.32), w * 0.34, Paint()..color = fl ? const Color(0xffffffff) : const Color(0xffc01a3a));
|
||||
final horn = fl ? const Color(0xffffffff) : const Color(0xffffc63a);
|
||||
_tri(c, x + w * 0.18, y + h * 0.14, x - w * 0.06, y - h * 0.16, x + w * 0.32, y + h * 0.04, horn);
|
||||
_tri(c, x + w * 0.82, y + h * 0.14, x + w * 1.06, y - h * 0.16, x + w * 0.68, y + h * 0.04, horn);
|
||||
final ec = phase == 3 ? const Color(0xff7ae0ff) : phase == 2 ? const Color(0xffffb040) : const Color(0xffffe14a);
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.26, y + h * 0.26, w * 0.16, h * 0.12), Paint()..color = fl ? const Color(0xff000000) : ec);
|
||||
c.drawRect(Rect.fromLTWH(x + w * 0.58, y + h * 0.26, w * 0.16, h * 0.12), Paint()..color = fl ? const Color(0xff000000) : ec);
|
||||
for (int i = 0; i < 4; i++) c.drawRect(Rect.fromLTWH(x + w * 0.32 + i * w * 0.1, y + h * 0.46, 4, 6), Paint()..color = fl ? const Color(0xff000000) : const Color(0xffffffff));
|
||||
}
|
||||
|
||||
void _tri(Canvas c, double ax, double ay, double bx, double by, double cx, double cy, Color col) {
|
||||
final p = Path();
|
||||
p.moveTo(ax, ay); p.lineTo(bx, by); p.lineTo(cx, cy); p.close();
|
||||
c.drawPath(p, Paint()..color = col);
|
||||
}
|
||||
}
|
||||
|
||||
String bossName(String kind) => (defs[kind] ?? defs['guy'])['name'];
|
||||
@@ -0,0 +1,124 @@
|
||||
// 摄像机:跟随 / 锁定 / 缩放(坑向关的"摄像头"道具靠它搞事)。
|
||||
// 与 web 版 camera.js 1:1 对应。渲染变换通过 apply()。
|
||||
import 'dart:ui' show Canvas;
|
||||
import 'dart:math' show min, max;
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
import 'package:iwbtc/src/game/views.dart' show PlayerView;
|
||||
|
||||
class Camera {
|
||||
double w, h;
|
||||
double x = 0, y = 0;
|
||||
double zoom = 1;
|
||||
double tz = 1;
|
||||
String mode = 'follow';
|
||||
double lockX = 0, lockY = 0;
|
||||
double shakeX = 0, shakeY = 0;
|
||||
|
||||
Camera([this.w = 800, this.h = 608]);
|
||||
|
||||
void reset(LevelLike level, PlayerView player) {
|
||||
zoom = 1;
|
||||
tz = 1;
|
||||
mode = 'follow';
|
||||
snapTo(player, level);
|
||||
}
|
||||
|
||||
double viewW() => w / zoom;
|
||||
double viewH() => h / zoom;
|
||||
|
||||
void snapTo(PlayerView player, LevelLike level) {
|
||||
x = clamp(player.cx - viewW() / 2, 0, max(0, level.pxW - viewW()));
|
||||
y = clamp(player.cy - viewH() / 2, 0, max(0, level.pxH - viewH()));
|
||||
mode = 'follow';
|
||||
zoom = 1;
|
||||
tz = 1;
|
||||
}
|
||||
|
||||
void update(LevelLike level, PlayerView player, int dt) {
|
||||
final zone = level.camZone;
|
||||
double? tx, ty, tz = 1;
|
||||
double speed = 0.16;
|
||||
|
||||
if (zone != null) {
|
||||
if (zone.mode == 'lock') {
|
||||
mode = 'lock';
|
||||
tx = zone.lx != null ? zone.lx! : zone.x;
|
||||
ty = zone.ly != null ? zone.ly! : zone.y;
|
||||
tz = zone.zoom ?? 1;
|
||||
speed = 0.05;
|
||||
} else if (zone.mode == 'zoom') {
|
||||
mode = 'zoom';
|
||||
tz = zone.zoom ?? 1;
|
||||
tx = player.cx - w / tz / 2;
|
||||
ty = player.cy - h / tz / 2;
|
||||
speed = 0.14;
|
||||
} else if (zone.mode == 'off') {
|
||||
mode = 'off';
|
||||
tx = x;
|
||||
ty = y;
|
||||
tz = zone.zoom ?? zoom;
|
||||
speed = 0;
|
||||
}
|
||||
} else {
|
||||
mode = 'follow';
|
||||
tx = player.cx - w / zoom / 2;
|
||||
ty = player.cy - h / zoom / 2;
|
||||
tz = 1;
|
||||
speed = 0.16;
|
||||
}
|
||||
|
||||
this.tz = lerp(this.tz, tz, 0.06);
|
||||
if ((this.tz - zoom).abs() > 0.002) zoom = this.tz;
|
||||
|
||||
if (speed > 0) {
|
||||
x = lerp(x, tx!, speed);
|
||||
y = lerp(y, ty!, speed);
|
||||
}
|
||||
_clampToLevel(level);
|
||||
|
||||
final sh = level.shake;
|
||||
if (sh > 0.2) {
|
||||
shakeX = (rand(-0.5, 0.5)) * sh;
|
||||
shakeY = (rand(-0.5, 0.5)) * sh;
|
||||
} else {
|
||||
shakeX = 0;
|
||||
shakeY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _clampToLevel(LevelLike level) {
|
||||
final vw = viewW(), vh = viewH();
|
||||
if (level.pxW <= vw) {
|
||||
x = (level.pxW - vw) / 2;
|
||||
} else {
|
||||
x = clamp(x, 0, level.pxW - vw);
|
||||
}
|
||||
if (level.pxH <= vh) {
|
||||
y = (level.pxH - vh) / 2;
|
||||
} else {
|
||||
y = clamp(y, 0, level.pxH - vh);
|
||||
}
|
||||
}
|
||||
|
||||
void apply(Canvas canvas) {
|
||||
canvas.translate(shakeX, shakeY);
|
||||
if (zoom != 1) canvas.scale(zoom, zoom);
|
||||
canvas.translate(-x, -y);
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄像机只关心关卡/玩家的少量字段,用抽象接口解耦避免循环依赖。
|
||||
abstract class LevelLike {
|
||||
double get pxW;
|
||||
double get pxH;
|
||||
double get shake;
|
||||
CamZoneLike? get camZone;
|
||||
}
|
||||
abstract class CamZoneLike {
|
||||
String get mode;
|
||||
double? get zoom;
|
||||
double get x;
|
||||
double get y;
|
||||
double? get lx;
|
||||
double? get ly;
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
// 关卡解析与运行时: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;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 关卡清单:从 assets/levels.json 加载内置关卡,并提供与 web 版
|
||||
// levels/index.js 一致的 TYPE_LABEL / TYPE_CLASS / makeCustomLevel / findLevel。
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:iwbtc/src/core/save.dart';
|
||||
|
||||
const Map<String, String> TYPE_LABEL = {
|
||||
'jump': '跳刺',
|
||||
'endur': '耐久',
|
||||
'boss': 'BOSS',
|
||||
'troll': '坑向',
|
||||
'custom': '自制',
|
||||
};
|
||||
|
||||
const Map<String, String> TYPE_CLASS = {
|
||||
'jump': 't-jump',
|
||||
'endur': 't-endur',
|
||||
'boss': 't-boss',
|
||||
'troll': 't-troll',
|
||||
'custom': 't-custom',
|
||||
};
|
||||
|
||||
List<Map<String, dynamic>> _builtin = <Map<String, dynamic>>[];
|
||||
bool _loaded = false;
|
||||
|
||||
/// 加载内置关卡(assets/levels.json)。重复调用安全。
|
||||
Future<void> loadLevels() async {
|
||||
if (_loaded) return;
|
||||
final s = await rootBundle.loadString('assets/levels.json');
|
||||
final m = (jsonDecode(s) as Map).cast<String, dynamic>();
|
||||
final list = ((m['levels'] as List?) ?? <dynamic>[])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.toList();
|
||||
for (int i = 0; i < list.length; i++) {
|
||||
list[i] = <String, dynamic>{...list[i], 'index': i, 'builtin': true};
|
||||
}
|
||||
_builtin = list;
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
/// 内置关卡清单(已标注 index / builtin)。
|
||||
List<Map<String, dynamic>> get LEVELS => _builtin;
|
||||
|
||||
/// 合成一张自制关卡数据对象(与 web makeCustomLevel 一致)。
|
||||
Map<String, dynamic> makeCustomLevel(Map<String, dynamic> data) {
|
||||
return <String, dynamic>{
|
||||
...data,
|
||||
'id': 'u' + (DateTime.now().millisecondsSinceEpoch % 100000).toString() + (_uid++).toString(),
|
||||
'type': 'custom',
|
||||
'builtin': false,
|
||||
'index': -1,
|
||||
};
|
||||
}
|
||||
|
||||
int _uid = 0;
|
||||
|
||||
/// 按 id 查找关卡(内置优先,其次自制)。
|
||||
Map<String, dynamic>? findLevel(String id) {
|
||||
for (final l in _builtin) {
|
||||
if (l['id'] == id) return l;
|
||||
}
|
||||
for (final l in Save.customs()) {
|
||||
if (l['id'] == id) return l as Map<String, dynamic>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 粒子系统。与 web 版 particles.js 1:1 对应。
|
||||
import 'dart:ui' show Canvas, Paint, Color, Rect;
|
||||
import 'dart:math' show cos, sin, min, max, sqrt, atan2, pi;
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
|
||||
class Particle {
|
||||
double x, y, vx, vy;
|
||||
int life, max;
|
||||
Color c;
|
||||
double s, g;
|
||||
Particle(this.x, this.y, this.vx, this.vy, this.life, this.max, this.c, this.s, this.g);
|
||||
}
|
||||
|
||||
class Particles {
|
||||
final List<Particle> list = [];
|
||||
|
||||
void clear() => list.clear();
|
||||
|
||||
void spawn(double x, double y, int n,
|
||||
{List<String> colors = const ['#fff', '#ff5c7a', '#ffc63a'],
|
||||
double speed = 3,
|
||||
int life = 40,
|
||||
double size = 3,
|
||||
double grav = 0.12,
|
||||
double spread = 6.283185307179586,
|
||||
double dir = 0}) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
final a = dir + (rand(-0.5, 0.5)) * spread;
|
||||
final s = rand(speed * 0.35, speed);
|
||||
list.add(Particle(
|
||||
x,
|
||||
y,
|
||||
cos(a) * s,
|
||||
sin(a) * s,
|
||||
(life * rand(0.6, 1.25)).round(),
|
||||
life,
|
||||
hex(colors[randInt(0, colors.length - 1)]),
|
||||
size * rand(0.6, 1.3),
|
||||
grav,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void burst(double x, double y, {int n = 18, double speed = 3, int life = 40, double size = 3, double grav = 0.12, List<String> colors = const ['#fff', '#ff5c7a', '#ffc63a']}) {
|
||||
spawn(x, y, n, colors: colors, speed: speed, life: life, size: size, grav: grav);
|
||||
}
|
||||
|
||||
void update() {
|
||||
for (int i = list.length - 1; i >= 0; i--) {
|
||||
final p = list[i];
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += p.g;
|
||||
p.vx *= 0.985;
|
||||
if (--p.life <= 0) list.removeAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
void draw(Canvas canvas) {
|
||||
final paint = Paint();
|
||||
for (final p in list) {
|
||||
paint.color = p.c.withAlpha((min(1.0, p.life / (p.max * 0.5)) * 255).round().clamp(0, 255));
|
||||
canvas.drawRect(Rect.fromLTWH(p.x.floorToDouble(), p.y.floorToDouble(), p.s.floorToDouble(), p.s.floorToDouble()), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// 玩家:还原 IWBT 的硬核手感 —— 松手即停、无加速、可变跳跃高度、二段跳。
|
||||
// 与 web 版 player.js 1:1 对应(逐帧积分完全一致,保证手感)。
|
||||
import 'dart:ui' show Canvas;
|
||||
import 'dart:math' show pi, max, min;
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
import 'package:iwbtc/src/core/input.dart';
|
||||
import 'package:iwbtc/src/audio/sfx.dart';
|
||||
import 'package:iwbtc/src/game/sprites.dart';
|
||||
import 'package:iwbtc/src/game/views.dart';
|
||||
import 'package:iwbtc/src/game/level.dart' show Level, Tile, Mover, Cell, Bullet;
|
||||
|
||||
class SavePoint {
|
||||
double x, y;
|
||||
int face;
|
||||
SavePoint(this.x, this.y, this.face);
|
||||
}
|
||||
|
||||
class PHY {
|
||||
static const double GRAV = 0.25;
|
||||
static const double GRAV_FALL = 0.32;
|
||||
static const double GRAV_DJ = 0.35;
|
||||
static const double MAXFALL = 9;
|
||||
static const double JUMP = 6.6;
|
||||
static const double DJUMP = 6.7;
|
||||
static const double DJ_INERTIA = 0;
|
||||
static const double HSPD = 2.6;
|
||||
static const double WATER_GRAV = 0.16;
|
||||
static const double WATER_FALL = 2.6;
|
||||
static const double WATER_RISE = 0.42;
|
||||
static const double ICE_ACC = 0.18;
|
||||
static const double ICE_FRIC = 0.07;
|
||||
static const double SLIDE_FRIC = 0.12;
|
||||
static const double SLIDE_AIR = 0.02;
|
||||
static const double SLIDE_EASE = 0.14;
|
||||
static const double SLIDE_BRAKE = 0.45;
|
||||
static const double SLIDE_MIN = 0.35;
|
||||
static const double SLIDE_STOP = 0.05;
|
||||
static const double ICE_TOP = 1.6;
|
||||
}
|
||||
|
||||
class Player implements PlayerView {
|
||||
Level level;
|
||||
double w = 11, h = 21;
|
||||
double x = 0, y = 0;
|
||||
double vx = 0, vy = 0;
|
||||
int jumps = 0;
|
||||
bool djUp = false;
|
||||
bool grounded = false;
|
||||
int facing = 1;
|
||||
bool alive = true;
|
||||
bool dead = false;
|
||||
int deadT = 0;
|
||||
double deathX = 0, deathY = 0;
|
||||
bool inWater = false;
|
||||
int animT = 0;
|
||||
int shootCool = 0;
|
||||
Mover? mover;
|
||||
bool sliding = false;
|
||||
bool justRespawned = false;
|
||||
bool hasGun = false;
|
||||
int apples = 0;
|
||||
SavePoint? savePoint;
|
||||
int saveIdx = 0;
|
||||
bool clearing = false;
|
||||
|
||||
// 回调(由 World 设置)
|
||||
void Function()? onDeath;
|
||||
void Function()? onClear;
|
||||
void Function(dynamic e)? onPortal;
|
||||
void Function()? onRespawnFace;
|
||||
|
||||
Player(this.level) {
|
||||
reset(true);
|
||||
}
|
||||
|
||||
double get cx => x + w / 2;
|
||||
double get cy => y + h / 2;
|
||||
|
||||
void reset(bool full) {
|
||||
final sp = (full || savePoint == null)
|
||||
? SavePoint(level.spawn.x, level.spawn.y, level.faceIndex ?? 0)
|
||||
: savePoint!;
|
||||
x = sp.x; y = sp.y;
|
||||
vx = 0; vy = 0;
|
||||
jumps = 0;
|
||||
djUp = false;
|
||||
grounded = false;
|
||||
facing = 1;
|
||||
alive = true;
|
||||
dead = false;
|
||||
deadT = 0;
|
||||
deathX = 0; deathY = 0;
|
||||
inWater = false;
|
||||
animT = 0;
|
||||
shootCool = 0;
|
||||
mover = null;
|
||||
sliding = false;
|
||||
justRespawned = false;
|
||||
if (full) {
|
||||
hasGun = level.data['startGun'] == true;
|
||||
apples = 0;
|
||||
savePoint = null;
|
||||
saveIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环。
|
||||
void deathBurst() {
|
||||
final L = level;
|
||||
final dx = clamp(cx, 10, max(10, L.pxW - 10));
|
||||
final dy = clamp(cy, 10, max(10, L.pxH - 10));
|
||||
Sfx.instance.death();
|
||||
L.particles.spawn(dx, dy, 16, colors: const ['#ff5c7a', '#ff8095', '#fff', '#ffc63a'], speed: 3.4, life: 34, size: 3, grav: 0.16);
|
||||
L.shake = max(L.shake, 6);
|
||||
L.addDeathFX(dx, dy);
|
||||
deathX = dx; deathY = dy;
|
||||
}
|
||||
|
||||
void kill([Map opts = const {}]) {
|
||||
if (dead || !alive) return;
|
||||
dead = true; alive = false; deadT = 0;
|
||||
vx = 0; vy = 0; sliding = false;
|
||||
deathBurst();
|
||||
if (opts['mark'] != false) level.addDeathMark(deathX, deathY);
|
||||
if (opts['count'] != false && onDeath != null) onDeath!();
|
||||
}
|
||||
|
||||
void respawn() {
|
||||
dead = false; alive = true;
|
||||
final L = level;
|
||||
final sp = savePoint ?? SavePoint(L.spawn.x, L.spawn.y, 0);
|
||||
x = sp.x; y = sp.y;
|
||||
vx = 0; vy = 0; jumps = 0;
|
||||
djUp = false;
|
||||
shootCool = 0; sliding = false;
|
||||
deadT = 0;
|
||||
int guard = 0;
|
||||
while (L.solidRect(x, y, w, h) && guard++ < 48) y -= 2;
|
||||
L.softReset();
|
||||
justRespawned = true;
|
||||
}
|
||||
|
||||
void update(int dt) {
|
||||
final L = level;
|
||||
animT++;
|
||||
if (shootCool > 0) shootCool--;
|
||||
|
||||
if (dead) { deadT++; return; }
|
||||
|
||||
final inWater = L.waterRect(x + 2, y + 4, w - 4, h - 4);
|
||||
if (inWater && !this.inWater) Sfx.instance.splash();
|
||||
this.inWater = inWater;
|
||||
|
||||
final moveL = Input.held('left'), moveR = Input.held('right');
|
||||
final onIce = grounded && L.iceAtBottom(x, y, w, h);
|
||||
double target = 0;
|
||||
if (moveL && !moveR) target = -PHY.HSPD;
|
||||
else if (moveR && !moveL) target = PHY.HSPD;
|
||||
|
||||
final ICE_MAX = PHY.HSPD * PHY.ICE_TOP;
|
||||
final decel = (double f) {
|
||||
if (vx.abs() <= f) vx = 0;
|
||||
else vx -= sgn(vx) * f;
|
||||
};
|
||||
|
||||
if (onIce) {
|
||||
if (target != 0) vx += sgn(target) * PHY.ICE_ACC;
|
||||
else decel(PHY.ICE_FRIC);
|
||||
vx = clamp(vx, -ICE_MAX, ICE_MAX);
|
||||
if (vx.abs() > PHY.SLIDE_MIN) sliding = true;
|
||||
} else if (sliding && vx.abs() > PHY.SLIDE_STOP) {
|
||||
if (target != 0) {
|
||||
final dir = sgn(vx);
|
||||
if (dir == 0 || sgn(target) == dir) {
|
||||
if (vx.abs() >= PHY.HSPD) {
|
||||
vx -= dir * PHY.SLIDE_EASE;
|
||||
if (vx.abs() < PHY.HSPD) { vx = target; sliding = false; }
|
||||
} else {
|
||||
vx = target; sliding = false;
|
||||
}
|
||||
} else {
|
||||
vx -= dir * PHY.SLIDE_BRAKE;
|
||||
if (sgn(vx) == sgn(target)) { vx = target; sliding = false; }
|
||||
}
|
||||
vx = clamp(vx, -ICE_MAX, ICE_MAX);
|
||||
} else {
|
||||
decel(grounded ? PHY.SLIDE_FRIC : PHY.SLIDE_AIR);
|
||||
if (vx == 0) sliding = false;
|
||||
}
|
||||
if (vx != 0 && vx.abs() < PHY.SLIDE_STOP) { vx = 0; sliding = false; }
|
||||
} else {
|
||||
vx = target;
|
||||
sliding = false;
|
||||
}
|
||||
if (target != 0) facing = sgn(target).toInt();
|
||||
|
||||
if (inWater) {
|
||||
if (Input.held('jump')) vy -= PHY.WATER_RISE;
|
||||
if (Input.held('down')) vy += 0.25;
|
||||
vy = clamp(vy, -3.2, PHY.WATER_FALL);
|
||||
jumps = 0; djUp = false;
|
||||
} else {
|
||||
if (Input.hit('jump')) {
|
||||
if (grounded || jumps == 0) {
|
||||
vy = -PHY.JUMP; jumps = 1; grounded = false;
|
||||
Sfx.instance.jump();
|
||||
L.particles.spawn(cx, y + h, 5, colors: const ['#fff', '#cfcfe0'], speed: 1.6, life: 16, size: 2, dir: -pi / 2, spread: 2);
|
||||
} else if (jumps == 1) {
|
||||
vy = max(vy, 0) * PHY.DJ_INERTIA - PHY.DJUMP; jumps = 2;
|
||||
djUp = vy < 0;
|
||||
Sfx.instance.djump();
|
||||
L.particles.spawn(cx, cy, 10, colors: const ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: pi * 2);
|
||||
}
|
||||
}
|
||||
if (!Input.held('jump') && vy < -3) vy = -3;
|
||||
vy = min(vy + (vy < 0 ? (djUp ? PHY.GRAV_DJ : PHY.GRAV) : PHY.GRAV_FALL), PHY.MAXFALL);
|
||||
if (djUp && vy >= 0) djUp = false;
|
||||
}
|
||||
|
||||
if (mover != null && !(mover!.gone)) {
|
||||
x += mover!.dx;
|
||||
y += mover!.dy;
|
||||
}
|
||||
|
||||
// X 轴移动
|
||||
final prevBottom = y + h;
|
||||
mover = null;
|
||||
double steps = vx.abs();
|
||||
final dx = sgn(vx).toDouble();
|
||||
while (steps > 0) {
|
||||
final m = min(1, steps);
|
||||
if (!L.solidRect(x + dx * m, y, w, h)) { x += dx * m; steps -= m; }
|
||||
else break;
|
||||
}
|
||||
if (L.solidRect(x + dx, y, w, h) && vx.abs() > 0) vx = 0;
|
||||
|
||||
// Y 轴移动
|
||||
final dropThrough = Input.held('down');
|
||||
steps = vy.abs();
|
||||
final dy = sgn(vy).toDouble();
|
||||
bool hitGround = false;
|
||||
while (steps > 0) {
|
||||
final m = min(1, steps);
|
||||
final ny = y + dy * m;
|
||||
final solidHere = L.solidRect(x, ny, w, h, prevBottom: prevBottom, dropThrough: dropThrough);
|
||||
if (!solidHere) { y = ny; steps -= m; }
|
||||
else {
|
||||
if (dy > 0) hitGround = true;
|
||||
vy = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (final m in L.movers) {
|
||||
if (m.gone) continue;
|
||||
if (x + w > m.x && x < m.x + m.w) {
|
||||
if (y + h >= m.y - 2 && y + h <= m.y + m.h) {
|
||||
mover = m; hitGround = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final wasAir = !grounded;
|
||||
grounded = hitGround || (vy == 0 && L.solidRect(x, y + 1, w, h));
|
||||
if (grounded) {
|
||||
jumps = 0; djUp = false;
|
||||
}
|
||||
|
||||
if (sliding && grounded && vx.abs() > PHY.HSPD && animT % 5 == 0) {
|
||||
L.particles.spawn(cx - sgn(vx) * 5, y + h - 2, 2, colors: const ['#dff4ff', '#8fd8f0', '#fff'], speed: 1.1, life: 16, size: 2, dir: -pi / 2, spread: 1.7, grav: 0.1);
|
||||
}
|
||||
|
||||
x = clamp(x, 0, L.pxW - w);
|
||||
if (y > L.pxH + 200) { kill(); return; }
|
||||
if (y < -300) { y = -300; vy = 0; }
|
||||
|
||||
if (L.deadlyRect(x, y, w, h)) { kill(); return; }
|
||||
|
||||
if (hasGun && Input.hit('shoot') && shootCool == 0 && L.bullets.length < 5) {
|
||||
shoot();
|
||||
}
|
||||
|
||||
touchEntities();
|
||||
|
||||
if (grounded) {
|
||||
final ty = ((y + h + 2) / TILE).floor();
|
||||
for (int tx = (x / TILE).floor(); tx <= ((x + w - 1) / TILE).floor(); tx++) {
|
||||
if (ty < L.tiles.length && tx >= 0 && tx < L.tiles[0].length && L.tiles[ty][tx] == Tile.vanish && L.vanishState(tx, ty) == 0) {
|
||||
L.setVanish(tx, ty, 1);
|
||||
L.vanishT ??= {};
|
||||
L.vanishT![ty * 1000 + tx] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void shoot() {
|
||||
final L = level;
|
||||
shootCool = 9;
|
||||
L.bullets.add(Bullet(
|
||||
x: facing > 0 ? x + w - 2 : x - 6,
|
||||
y: y + 8, w: 10.0, h: 5.0,
|
||||
vx: 9.0 * facing, vy: 0.0, life: 130,
|
||||
));
|
||||
Sfx.instance.shoot();
|
||||
L.particles.spawn(facing > 0 ? x + w : x, y + 10, 3, colors: const ['#fff6a0'], speed: 1.5, life: 10, size: 2);
|
||||
}
|
||||
|
||||
void touchEntities() {
|
||||
final L = level;
|
||||
for (final e in L.entities) {
|
||||
if (e.dead) continue;
|
||||
final hit = e.type == 'save'
|
||||
? overlap(Box(x - 6, y - 6, w + 12, h + 12), Box(e.x, e.y, e.w, e.h))
|
||||
: overlap(Box(x, y, w, h), Box(e.x, e.y, e.w, e.h));
|
||||
if (!hit) continue;
|
||||
switch (e.type) {
|
||||
case 'save':
|
||||
if (!e.active) {
|
||||
e.active = true;
|
||||
savePoint = SavePoint(e.x + e.w / 2 - w / 2, e.y + e.h - h, level.faceIndex ?? 0);
|
||||
saveIdx = e.no ?? 1;
|
||||
Sfx.instance.save();
|
||||
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18, colors: const ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30);
|
||||
if (e.fake) { L.showToast('假的。', 90); kill(); return; }
|
||||
}
|
||||
break;
|
||||
case 'goal':
|
||||
if (!clearing) { clearing = true; if (onClear != null) onClear!(); }
|
||||
break;
|
||||
case 'gun':
|
||||
if (!e.taken) {
|
||||
e.taken = true; hasGun = true;
|
||||
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 12, colors: const ['#dfe0f0', '#fff'], speed: 2.2, life: 24);
|
||||
L.showToast('获得手枪!按 X 射击', 120);
|
||||
Sfx.instance.save();
|
||||
}
|
||||
break;
|
||||
case 'apple':
|
||||
if (!e.taken) { e.taken = true; apples++; Sfx.instance.save(); }
|
||||
break;
|
||||
case 'jumpref':
|
||||
if (animT % 4 == 0) jumps = 0;
|
||||
break;
|
||||
case 'warp':
|
||||
if (e.cool > 0) break;
|
||||
e.cool = 40;
|
||||
final to = e.to;
|
||||
if (to != null && to is Map && !to.containsKey('x') && !to.containsKey('y') && to['face'] is int) {
|
||||
if (onPortal != null) onPortal!(e);
|
||||
break;
|
||||
}
|
||||
double? nx, ny;
|
||||
if (to == 'spawn') {
|
||||
nx = L.spawn.x; ny = L.spawn.y;
|
||||
} else if (to != null && to is Map && to.containsKey('x')) {
|
||||
nx = (to['x'] as num) * TILE; ny = (to['y'] as num) * TILE;
|
||||
} else {
|
||||
final p = L.entities.firstWhere((o) => o.type == 'warp' && o.idx == e.pair, orElse: () => e);
|
||||
if (p != e) {
|
||||
nx = p.x + p.w / 2 - w / 2; ny = p.y + p.h - h;
|
||||
}
|
||||
}
|
||||
if (nx != null && ny != null) {
|
||||
x = nx; y = ny;
|
||||
vx = 0; vy = 0;
|
||||
final partner = L.entities.firstWhere((o) => o.type == 'warp' && o.idx == e.pair, orElse: () => e);
|
||||
if (partner != e) partner.cool = 40;
|
||||
Sfx.instance.warp();
|
||||
L.particles.spawn(cx, cy, 12, colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24);
|
||||
}
|
||||
break;
|
||||
case 'sign':
|
||||
if (animT % 30 == 0) L.showToast(e.text ?? '', 90);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void draw(Canvas c) {
|
||||
if (dead) return;
|
||||
drawKid(c, this, animT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// 程序化像素绘制(无外部图片资源)。对应 web 版 sprites.js。
|
||||
// 全部使用 Flutter dart:ui 的 Canvas / Paint / Path,逐函数镜像。
|
||||
import 'dart:ui' show Canvas, Paint, Path, Rect, Color, Offset, PaintingStyle, StrokeCap, TextAlign;
|
||||
import 'dart:math' show sin, cos, pi, max, min, sqrt, atan2;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
import 'package:iwbtc/src/game/views.dart';
|
||||
|
||||
Color rgba(int r, int g, int b, double a) =>
|
||||
Color.fromARGB((a * 255).round().clamp(0, 255), r, g, b);
|
||||
|
||||
class C {
|
||||
static const Color stone = Color(0xff6b6b7e);
|
||||
static const Color stoneDark = Color(0xff3a3a48);
|
||||
static const Color stoneLite = Color(0xff8f8fa6);
|
||||
static const Color grass = Color(0xff4f9e46);
|
||||
static const Color grassDark = Color(0xff2c5c2b);
|
||||
static const Color brick = Color(0xffa8543c);
|
||||
static const Color brickDark = Color(0xff632a1d);
|
||||
static const Color metal = Color(0xff5a6478);
|
||||
static const Color metalDark = Color(0xff333a4a);
|
||||
static const Color ice = Color(0xff8fd8f0);
|
||||
static const Color iceDark = Color(0xff4a90b8);
|
||||
static const Color water = Color(0xff2a6fd8);
|
||||
static const Color spike = Color(0xffcfdde9);
|
||||
static const Color spikeDark = Color(0xffa8bccf);
|
||||
static const Color kid = Color(0xffd94a55);
|
||||
static const Color kidDark = Color(0xff8e2530);
|
||||
static const Color skin = Color(0xfff5cfa8);
|
||||
static const Color hair = Color(0xff5a3a22);
|
||||
static const Color pant = Color(0xff2c3a7a);
|
||||
static const Color save = Color(0xffb06bff);
|
||||
static const Color goal = Color(0xffffc63a);
|
||||
static const Color gun = Color(0xffb8b8c8);
|
||||
static const Color apple = Color(0xffe03050);
|
||||
}
|
||||
|
||||
void px(Canvas c, double x, double y, double w, double h, Color col) {
|
||||
final p = Paint()..color = col;
|
||||
c.drawRect(Rect.fromLTWH(x, y, w, h), p);
|
||||
}
|
||||
|
||||
// ---------------- 地形 ----------------
|
||||
void drawBlock(Canvas c, double x, double y, int variant, [bool lit = false]) {
|
||||
const s = TILE;
|
||||
Color base = C.stone, dark = C.stoneDark, lite = C.stoneLite;
|
||||
if (variant == 1) {
|
||||
base = C.grass; dark = C.grassDark; lite = const Color(0xff7fd06a);
|
||||
} else if (variant == 2) {
|
||||
base = C.brick; dark = C.brickDark; lite = const Color(0xffd1785c);
|
||||
} else if (variant == 3) {
|
||||
base = C.metal; dark = C.metalDark; lite = const Color(0xff8a94ad);
|
||||
}
|
||||
px(c, x, y, s, s, dark);
|
||||
px(c, x + 1, y + 1, s - 2, s - 2, base);
|
||||
if (variant == 2) {
|
||||
px(c, x + 1, y + 8, s - 2, 1, dark);
|
||||
px(c, x + 1, y + 17, s - 2, 1, dark);
|
||||
px(c, x + 10, y + 1, 1, 7, dark);
|
||||
px(c, x + 20, y + 9, 1, 8, dark);
|
||||
px(c, x + 6, y + 18, 1, 7, dark);
|
||||
} else if (variant == 1) {
|
||||
px(c, x + 1, y + 1, s - 2, 5, lite);
|
||||
for (int i = 0; i < 4; i++) px(c, x + 3 + i * 7, y + 2, 1, 4, dark);
|
||||
} else {
|
||||
final n = hashNoise(x.round(), y.round());
|
||||
px(c, x + 2, y + 2, 6, 2, lite);
|
||||
if (n > 0.5) px(c, x + 18, y + 12, 8, 2, dark);
|
||||
if (n < 0.35) px(c, x + 6, y + 22, 7, 2, dark);
|
||||
}
|
||||
px(c, x, y, s, 1, lite);
|
||||
px(c, x, y, 1, s, lite);
|
||||
px(c, x, y + s - 1, s, 1, dark);
|
||||
px(c, x + s - 1, y, 1, s, dark);
|
||||
if (lit) px(c, x, y, s, s, rgba(255, 255, 255, 0.09));
|
||||
}
|
||||
|
||||
void drawIce(Canvas c, double x, double y) {
|
||||
const s = TILE;
|
||||
px(c, x, y, s, s, C.iceDark);
|
||||
px(c, x + 1, y + 1, s - 2, s - 2, C.ice);
|
||||
px(c, x + 2, y + 3, 10, 3, const Color(0xffe6fbff));
|
||||
px(c, x + 18, y + 16, 8, 2, rgba(255, 255, 255, 0.7));
|
||||
px(c, x, y, s, 1, const Color(0xffe6fbff));
|
||||
px(c, x, y + s - 1, s, 1, C.iceDark);
|
||||
}
|
||||
|
||||
void drawWater(Canvas c, double x, double y, int t) {
|
||||
const s = TILE;
|
||||
px(c, x, y, s, s, rgba(40, 110, 215, 0.55));
|
||||
final off = sin(t / 260 + x * 0.09) * 3;
|
||||
px(c, x, y + 4 + off, s, 3, rgba(120, 200, 255, 0.35));
|
||||
px(c, x, y + 16 - off, s, 2, rgba(120, 200, 255, 0.35));
|
||||
}
|
||||
|
||||
void drawOneWay(Canvas c, double x, double y) {
|
||||
const s = TILE;
|
||||
px(c, x, y, s, 6, C.metalDark);
|
||||
px(c, x, y, s, 3, const Color(0xff9aa6bd));
|
||||
for (int i = 0; i < 8; i++) px(c, x + i * 4, y + 6, 2, 3, C.metalDark);
|
||||
}
|
||||
|
||||
void drawSpike(Canvas c, double x, double y, int dir, int t) {
|
||||
const s = TILE;
|
||||
late double ax, ay, bx, by, cx2, cy2;
|
||||
if (dir == 0) {
|
||||
ax = x + s / 2; ay = y; bx = x; by = y + s; cx2 = x + s; cy2 = y + s;
|
||||
} else if (dir == 1) {
|
||||
ax = x + s / 2; ay = y + s; bx = x; by = y; cx2 = x + s; cy2 = y;
|
||||
} else if (dir == 2) {
|
||||
ax = x; ay = y + s / 2; bx = x + s; by = y; cx2 = x + s; cy2 = y + s;
|
||||
} else {
|
||||
ax = x + s; ay = y + s / 2; bx = x; by = y; cx2 = x; cy2 = y + s;
|
||||
}
|
||||
late Offset from, to;
|
||||
if (dir == 0) { from = Offset(x, y); to = Offset(x, y + s); }
|
||||
else if (dir == 1) { from = Offset(x, y + s); to = Offset(x, y); }
|
||||
else if (dir == 2) { from = Offset(x, y); to = Offset(x + s, y); }
|
||||
else { from = Offset(x + s, y); to = Offset(x, y); }
|
||||
final rect = Rect.fromLTWH(x, y, s, s);
|
||||
final g = LinearGradient(
|
||||
begin: _gradAlign(from, rect), end: _gradAlign(to, rect),
|
||||
colors: const [Color(0xfff4f9fd), Color(0xffcfdde9), Color(0xffa8bccf)],
|
||||
);
|
||||
final p = Path();
|
||||
p.moveTo(ax, ay); p.lineTo(bx, by); p.lineTo(cx2, cy2); p.close();
|
||||
final paint = Paint()..shader = g.createShader(rect);
|
||||
c.drawPath(p, paint);
|
||||
final stroke = Paint()
|
||||
..color = rgba(150, 175, 200, 0.9)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
c.drawPath(p, stroke);
|
||||
}
|
||||
|
||||
void drawKiller(Canvas c, double x, double y, int t) {
|
||||
const s = TILE;
|
||||
final bob = sin(t / 300 + x) * 1.5;
|
||||
c.save();
|
||||
c.translate(x + s / 2, y + s / 2 + bob);
|
||||
final p = Paint()..color = C.apple;
|
||||
c.drawCircle(Offset(0, 2), 11, p);
|
||||
final p2 = Paint()..color = const Color(0xffff7a90);
|
||||
c.drawCircle(Offset(-4, -2), 4, p2);
|
||||
px(c, -1, -13, 2, 6, const Color(0xff6b4423));
|
||||
final leaf = Paint()..color = const Color(0xff3ddc84);
|
||||
c.drawOval(Rect.fromLTWH(1, -13, 10, 5.2), leaf);
|
||||
px(c, -5, 0, 2, 2, const Color(0xff000000));
|
||||
px(c, 3, 0, 2, 2, const Color(0xff000000));
|
||||
c.restore();
|
||||
}
|
||||
|
||||
void drawVanish(Canvas c, double x, double y, int state, int t) {
|
||||
const s = TILE;
|
||||
final alpha = state == 0 ? 1.0 : state == 1 ? (sin(t / 60) > 0 ? 0.55 : 0.9) : 0.14;
|
||||
drawBlock(c, x, y, 3);
|
||||
if (alpha < 1) {
|
||||
// 模拟 globalAlpha:用半透明黑覆盖来压暗
|
||||
px(c, x, y, s, s, Color.fromARGB(((1 - alpha) * 255).round().clamp(0, 255), 0, 0, 0));
|
||||
}
|
||||
if (state != 2) {
|
||||
final sp = Paint()
|
||||
..color = rgba(255, 200, 60, 0.55)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
c.drawRect(Rect.fromLTWH(x + 2.5, y + 2.5, s - 5, s - 5), sp);
|
||||
}
|
||||
}
|
||||
|
||||
void drawJumpRef(Canvas c, double x, double y, int t) {
|
||||
const s = TILE;
|
||||
final b = sin(t / 200 + x) * 2;
|
||||
final p = Path();
|
||||
p.moveTo(x + s / 2, y + 8 + b);
|
||||
p.lineTo(x + s - 6, y + s - 10 + b);
|
||||
p.lineTo(x + 6, y + s - 10 + b);
|
||||
p.close();
|
||||
c.drawPath(p, Paint()..color = const Color(0xff3ddc84));
|
||||
final sp = Paint()
|
||||
..color = const Color(0xff0d5c30)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
c.drawPath(p, sp);
|
||||
}
|
||||
|
||||
// ---------------- 玩家 ----------------
|
||||
void drawKid(Canvas c, PlayerView p, int t) {
|
||||
final x = p.x.roundToDouble(), y = p.y.roundToDouble();
|
||||
final w = p.w, h = p.h;
|
||||
final air = !p.grounded;
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
// 身体(红衣)
|
||||
px(c, 2, 7, w - 4, 7, C.kid);
|
||||
px(c, 2, 7, w - 4, 1, shade(C.kid, 40));
|
||||
if (air) {
|
||||
px(c, p.facing > 0 ? w - 3 : 0, 5, 3, 5, C.skin);
|
||||
} else {
|
||||
px(c, p.facing > 0 ? w - 3 : 0, 9, 3, 5, C.skin);
|
||||
}
|
||||
if (air) {
|
||||
px(c, 3, 14, 4, 4, C.pant);
|
||||
px(c, w - 7, 14, 4, 3, C.pant);
|
||||
} else {
|
||||
px(c, 2, 14, 4, 4, C.pant);
|
||||
px(c, w - 6, 14, 4, 4, C.pant);
|
||||
}
|
||||
px(c, 1, 0, w - 2, 8, C.skin);
|
||||
px(c, 1, 0, w - 2, 3, C.hair);
|
||||
px(c, p.facing > 0 ? w - 5 : 3, 1, 3, 2, C.hair);
|
||||
px(c, p.facing > 0 ? w - 6 : 3, 4, 2, 2, const Color(0xff101018));
|
||||
px(c, p.facing > 0 ? w - 3 : 0, 4, 1, 2, const Color(0xff101018));
|
||||
c.restore();
|
||||
|
||||
if (p.hasGun && !p.dead) {
|
||||
final gx = p.facing > 0 ? x + w - 1 : x - 7;
|
||||
final gy = y + 9;
|
||||
px(c, gx, gy, 8, 3, C.gun);
|
||||
px(c, p.facing > 0 ? gx + 6 : gx, gy + 1, 3, 2, const Color(0xff7a7a8c));
|
||||
px(c, gx + 1, gy + 3, 3, 3, const Color(0xff6a6a7c));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 实体 ----------------
|
||||
void drawSave(Canvas c, EntityView e, int t) {
|
||||
final cx = e.x + e.w / 2, cy = e.y + e.h / 2;
|
||||
final on = e.active;
|
||||
final pulse = 1 + sin(t / 180) * 0.09;
|
||||
if (on) {
|
||||
final p = Paint()..color = rgba(176, 107, 255, 0.28);
|
||||
c.drawCircle(Offset(cx, cy), 15 * pulse, p);
|
||||
} else {
|
||||
final r = 13 + sin(t / 26) * 2.2;
|
||||
final sp = Paint()
|
||||
..color = rgba(138, 106, 184, 0.30 + sin(t / 26) * 0.12)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
c.drawCircle(Offset(cx, cy), r, sp);
|
||||
}
|
||||
final col = on ? C.save : const Color(0xff8f74b5);
|
||||
final p = Paint()..color = col;
|
||||
c.drawCircle(Offset(cx - 3, cy - 1), 4.2, p);
|
||||
c.drawCircle(Offset(cx + 4, cy + 1), 4.2, p);
|
||||
c.drawCircle(Offset(cx, cy + 5), 4.2, p);
|
||||
c.drawCircle(Offset(cx + 1, cy - 5), 4.2, p);
|
||||
if (on) {
|
||||
c.drawCircle(Offset(cx - 1, cy - 2), 1.6, Paint()..color = const Color(0xffe5d0ff));
|
||||
}
|
||||
px(c, cx + 1, cy - 9, 2, 4, const Color(0xff4a8a3a));
|
||||
}
|
||||
|
||||
void drawGoal(Canvas c, EntityView e, int t) {
|
||||
final x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
px(c, x + 3, y + 2, w - 6, h - 2, const Color(0xff2a1e3a));
|
||||
px(c, x + 5, y + 4, w - 10, h - 6, const Color(0xff140d20));
|
||||
final gr = Rect.fromLTWH(x + 5, y + 4, w - 10, h - 6);
|
||||
final g = LinearGradient(
|
||||
begin: _gradAlign(Offset(x, y + h), gr), end: _gradAlign(Offset(x, y), gr),
|
||||
colors: [rgba(255, 200, 60, 0.55), rgba(255, 200, 60, 0)],
|
||||
);
|
||||
c.drawRect(gr, Paint()..shader = g.createShader(gr));
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final pp = (((t / 700) + i / 3) % 1);
|
||||
px(c, x + 7 + ((i * 9) % (w - 12)), y + h - 4 - pp * (h - 8), 2, 2, C.goal);
|
||||
}
|
||||
px(c, x + 3, y + 2, w - 6, 2, C.goal);
|
||||
px(c, x + 3, y + 2, 2, h - 2, const Color(0xffc99a1e));
|
||||
px(c, x + w - 5, y + 2, 2, h - 2, const Color(0xffc99a1e));
|
||||
}
|
||||
|
||||
void drawGun(Canvas c, EntityView e, int t) {
|
||||
final x = e.x, y = e.y + sin(t / 250) * 2;
|
||||
px(c, x + 2, y + 6, 20, 7, const Color(0xff4a4a5c));
|
||||
px(c, x + 2, y + 6, 20, 2, const Color(0xff8a8aa0));
|
||||
px(c, x + 18, y + 8, 9, 3, C.gun);
|
||||
px(c, x + 6, y + 13, 5, 8, const Color(0xff3a3a4c));
|
||||
px(c, x + 8, y + 19, 3, 3, const Color(0xff2a2a3a));
|
||||
px(c, x + 24, y + 7, 2, 5, const Color(0xffd0d0e0));
|
||||
}
|
||||
|
||||
void drawApple(Canvas c, EntityView e, int t) {
|
||||
final cx = e.x + e.w / 2, cy = e.y + e.h / 2 + sin(t / 320 + e.x) * 1.5;
|
||||
c.drawCircle(Offset(cx, cy), 8, Paint()..color = C.apple);
|
||||
c.drawCircle(Offset(cx - 3, cy - 3), 3, Paint()..color = const Color(0xffff8095));
|
||||
px(c, cx - 1, cy - 11, 2, 4, const Color(0xff6b4423));
|
||||
c.drawOval(Rect.fromLTWH(cx + 4 - 4, cy - 9 - 2, 8, 4), Paint()..color = const Color(0xff3ddc84));
|
||||
}
|
||||
|
||||
void drawWarp(Canvas c, EntityView e, int t) {
|
||||
final cx = e.x + e.w / 2, cy = e.y + e.h / 2;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final r = 14 - i * 4;
|
||||
final a = t / (300 + i * 120);
|
||||
final sp = Paint()
|
||||
..color = Color.fromARGB(((0.85 - i * 0.2) * 255).round().clamp(0, 255), (170 - i * 30).clamp(0, 255), (90 + i * 40).clamp(0, 255), 255)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
c.drawArc(Rect.fromLTWH(cx - r, cy - r, r * 2, r * 2), a, pi * 1.4, false, sp);
|
||||
}
|
||||
c.drawCircle(Offset(cx, cy), 5, Paint()..color = rgba(120, 60, 200, 0.35));
|
||||
}
|
||||
|
||||
void drawCamera(Canvas c, EntityView e, int t) {
|
||||
final x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
_dashedRect(c, Rect.fromLTWH(x + 1, y + 1, w - 2, h - 2),
|
||||
rgba(80, 220, 255, 0.35 + sin(t / 300) * 0.15), 1, 4, 4);
|
||||
final ix = x + w / 2 - 9, iy = y + 6;
|
||||
px(c, ix, iy + 3, 18, 11, const Color(0xff1e1e2a));
|
||||
px(c, ix + 2, iy + 5, 14, 7, const Color(0xff3a3a4c));
|
||||
c.drawCircle(Offset(ix + 9, iy + 8.5), 3.5, Paint()..color = const Color(0xff7ae0ff));
|
||||
px(c, ix + 12, iy, 5, 3, const Color(0xff1e1e2a));
|
||||
px(c, ix + 13, iy - 1, 3, 2, const Color(0xffff4060));
|
||||
}
|
||||
|
||||
void drawSign(Canvas c, EntityView e, int t) {
|
||||
final x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
px(c, x + w / 2 - 1, y + h - 8, 3, 8, const Color(0xff6b4423));
|
||||
px(c, x, y + 2, w, h - 10, const Color(0xffc9a86b));
|
||||
px(c, x + 2, y + 4, w - 4, h - 14, const Color(0xffe8d5a8));
|
||||
px(c, x + 4, y + 8, w - 8, 2, const Color(0xff8a7550));
|
||||
px(c, x + 4, y + 13, w - 12, 2, const Color(0xff8a7550));
|
||||
}
|
||||
|
||||
void drawTurret(Canvas c, EntityView e, int t) {
|
||||
final x = e.x, y = e.y;
|
||||
const s = TILE;
|
||||
px(c, x + 2, y + 6, s - 4, s - 6, const Color(0xff4a4a5e));
|
||||
px(c, x + 4, y + 8, s - 8, s - 12, const Color(0xff6a6a80));
|
||||
final dirx = e.dir == 'left' ? -1 : e.dir == 'right' ? 1 : 0;
|
||||
final diry = e.dir == 'up' ? -1 : e.dir == 'down' ? 1 : 0;
|
||||
final cx = x + s / 2, cy = y + s / 2;
|
||||
px(c, cx + dirx * 8 - 3, cy + diry * 8 - 3, 7, 7, const Color(0xff2a2a38));
|
||||
final blink = (t ~/ 300) % 3 == 0;
|
||||
px(c, cx + dirx * 12 - 2, cy + diry * 12 - 2, 4, 4, blink ? const Color(0xffff4060) : const Color(0xff8a2030));
|
||||
}
|
||||
|
||||
void drawMovingPlatform(Canvas c, MoverView m) {
|
||||
final x = m.x, y = m.y, w = m.w, h = m.h;
|
||||
px(c, x, y, w, h, const Color(0xff3a3a4c));
|
||||
px(c, x + 1, y + 1, w - 2, h - 2, const Color(0xff6a6a84));
|
||||
px(c, x + 1, y + 1, w - 2, 2, const Color(0xff9a9ab4));
|
||||
for (double i = 0; i < w; i += 8) px(c, x + i + 2, y + h - 4, 4, 2, const Color(0xff3a3a4c));
|
||||
_dashedLine(c, Offset(m.ax + m.aw / 2, m.ay + m.ah / 2), Offset(m.bx + m.aw / 2, m.by + m.ah / 2),
|
||||
rgba(255, 255, 255, 0.10), 1, 3, 5);
|
||||
}
|
||||
|
||||
void drawDropSpike(Canvas c, EntityView e, int t) {
|
||||
const s = TILE;
|
||||
if (!e.falling) {
|
||||
px(c, e.x + 4, e.y + 4, s - 8, s - 8, rgba(74, 74, 92, 0.85));
|
||||
px(c, e.x + 6, e.y + 6, s - 12, s - 12, rgba(106, 106, 128, 0.85));
|
||||
} else {
|
||||
drawSpike(c, e.x, e.y, 2, t);
|
||||
}
|
||||
}
|
||||
|
||||
void drawBullet(Canvas c, BulletView b) {
|
||||
px(c, b.x - 1, b.y - 1, b.w + 2, b.h + 2, rgba(255, 240, 120, 0.35));
|
||||
px(c, b.x, b.y, b.w, b.h, const Color(0xfffff6a0));
|
||||
px(c, b.x, b.y + b.h - 2, b.w, 2, const Color(0xffffb040));
|
||||
}
|
||||
|
||||
void drawEBullet(Canvas c, BulletView b, int t) {
|
||||
final cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
||||
c.drawCircle(Offset(cx, cy), b.w / 2 + 1, Paint()..color = hex(b.col));
|
||||
c.drawCircle(Offset(cx - 1.5, cy - 1.5), b.w / 4, Paint()..color = const Color(0xffffffff));
|
||||
if (b.big) {
|
||||
final sp = Paint()
|
||||
..color = rgba(255, 255, 255, 0.4)
|
||||
..style = PaintingStyle.stroke;
|
||||
c.drawCircle(Offset(cx, cy), b.w / 2 + 4 + sin(t / 90) * 2, sp);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 死亡特效 ----------------
|
||||
void drawDeathRing(Canvas c, DeathFX fx) {
|
||||
final t = fx.t;
|
||||
if (t > 14) return;
|
||||
final r = 4 + t * 1.8;
|
||||
final a = max(0.0, 1 - t / 14);
|
||||
final sp = Paint()
|
||||
..color = rgba(255, 143, 163, a * 0.85)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
c.drawCircle(Offset(fx.x, fx.y), r, sp);
|
||||
c.drawCircle(Offset(fx.x, fx.y), r * 0.55, Paint()..color = rgba(255, 92, 122, a * 0.3));
|
||||
}
|
||||
|
||||
void drawDeathMark(Canvas c, DeathMark dm) {
|
||||
const s = 4.0;
|
||||
final sp = Paint()
|
||||
..color = rgba(0, 0, 0, 0.55)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round;
|
||||
_line(c, sp, dm.x - s, dm.y - s, dm.x + s, dm.y + s);
|
||||
_line(c, sp, dm.x + s, dm.y - s, dm.x - s, dm.y + s);
|
||||
final sp2 = Paint()
|
||||
..color = const Color(0xffff2535)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..strokeCap = StrokeCap.round;
|
||||
_line(c, sp2, dm.x - s, dm.y - s, dm.x + s, dm.y + s);
|
||||
_line(c, sp2, dm.x + s, dm.y - s, dm.x - s, dm.y + s);
|
||||
}
|
||||
|
||||
// ---------------- 文本 ----------------
|
||||
void drawText(Canvas c, String text, double x, double y,
|
||||
{Color color = const Color(0xffffffff), double size = 11, TextAlign align = TextAlign.center}) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: TextStyle(color: color, fontSize: size, fontFamily: 'Courier')),
|
||||
textAlign: align,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
tp.layout();
|
||||
tp.paint(c, Offset(x - (align == TextAlign.center ? tp.width / 2 : 0), y));
|
||||
}
|
||||
|
||||
// ---------------- 虚线辅助 ----------------
|
||||
void _dashedLine(Canvas c, Offset a, Offset b, Color col, double width, double dash, double gap) {
|
||||
final p = Paint()..color = col..strokeWidth = width;
|
||||
final dx = b.dx - a.dx, dy = b.dy - a.dy;
|
||||
final dist = sqrt(dx * dx + dy * dy);
|
||||
if (dist == 0) return;
|
||||
final ux = dx / dist, uy = dy / dist;
|
||||
double d = 0;
|
||||
while (d < dist) {
|
||||
final d2 = min(d + dash, dist);
|
||||
c.drawLine(Offset(a.dx + ux * d, a.dy + uy * d), Offset(a.dx + ux * d2, a.dy + uy * d2), p);
|
||||
d += dash + gap;
|
||||
}
|
||||
}
|
||||
|
||||
void _dashedRect(Canvas c, Rect r, Color col, double width, double dash, double gap) {
|
||||
_dashedLine(c, r.topLeft, r.topRight, col, width, dash, gap);
|
||||
_dashedLine(c, r.topRight, r.bottomRight, col, width, dash, gap);
|
||||
_dashedLine(c, r.bottomRight, r.bottomLeft, col, width, dash, gap);
|
||||
_dashedLine(c, r.bottomLeft, r.topLeft, col, width, dash, gap);
|
||||
}
|
||||
|
||||
void _line(Canvas c, Paint p, double x1, double y1, double x2, double y2) {
|
||||
c.drawLine(Offset(x1, y1), Offset(x2, y2), p);
|
||||
}
|
||||
|
||||
// 把像素空间的绝对 Offset 换算成 Flutter LinearGradient 所需的 Alignment。
|
||||
Alignment _gradAlign(Offset abs, Rect r) =>
|
||||
Alignment(((abs.dx - r.left) - r.width / 2) / (r.width / 2),
|
||||
((abs.dy - r.top) - r.height / 2) / (r.height / 2));
|
||||
@@ -0,0 +1,58 @@
|
||||
// 渲染层只读的"视图"接口:让 sprites.dart 不依赖完整游戏类,避免循环依赖。
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
abstract class PlayerView {
|
||||
double get x;
|
||||
double get y;
|
||||
double get w;
|
||||
double get h;
|
||||
double get cx;
|
||||
double get cy;
|
||||
bool get grounded;
|
||||
int get facing;
|
||||
bool get hasGun;
|
||||
bool get dead;
|
||||
}
|
||||
|
||||
abstract class EntityView {
|
||||
double get x;
|
||||
double get y;
|
||||
double get w;
|
||||
double get h;
|
||||
bool get active;
|
||||
bool get falling;
|
||||
String get dir;
|
||||
}
|
||||
|
||||
abstract class MoverView {
|
||||
double get x;
|
||||
double get y;
|
||||
double get w;
|
||||
double get h;
|
||||
double get ax;
|
||||
double get ay;
|
||||
double get aw;
|
||||
double get ah;
|
||||
double get bx;
|
||||
double get by;
|
||||
}
|
||||
|
||||
abstract class BulletView {
|
||||
double get x;
|
||||
double get y;
|
||||
double get w;
|
||||
double get h;
|
||||
String get col;
|
||||
bool get big;
|
||||
}
|
||||
|
||||
class DeathFX {
|
||||
double x, y;
|
||||
int t;
|
||||
DeathFX(this.x, this.y, this.t);
|
||||
}
|
||||
|
||||
class DeathMark {
|
||||
double x, y;
|
||||
DeathMark(this.x, this.y);
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
// 游戏循环 / 状态机(对应 web 版 main.js)。
|
||||
// 用固定 60Hz 时间步长驱动已移植的纯 Canvas 游戏逻辑,保证手感 1:1。
|
||||
// 渲染在 800×608 虚拟分辨率下完成,由 GameCanvas 做信箱(letterbox)缩放。
|
||||
import 'dart:ui' show Canvas, Color, Paint, Path, Rect, Offset, TextAlign, TextDirection;
|
||||
import 'dart:math' show sin;
|
||||
import 'package:flutter/widgets.dart'
|
||||
show TextPainter, TextSpan, TextStyle, LinearGradient, Alignment, FontWeight, ValueNotifier;
|
||||
import 'package:iwbtc/src/core/input.dart';
|
||||
import 'package:iwbtc/src/core/save.dart';
|
||||
import 'package:iwbtc/src/core/util.dart';
|
||||
import 'package:iwbtc/src/audio/sfx.dart';
|
||||
import 'package:iwbtc/src/game/level.dart';
|
||||
import 'package:iwbtc/src/game/player.dart';
|
||||
import 'package:iwbtc/src/game/camera.dart';
|
||||
import 'package:iwbtc/src/game/levels.dart' show LEVELS;
|
||||
|
||||
const double W = 800.0;
|
||||
const double H = 608.0;
|
||||
const double STEP = 1000 / 60;
|
||||
|
||||
/// 编辑器试玩回调(避免 World <-> EditorController 循环依赖)。
|
||||
abstract class LevelTestRunner {
|
||||
void startLevel(Map<String, dynamic> data, int idx);
|
||||
}
|
||||
|
||||
/// 渲染宿主回调:World 通过它把界面状态推给 Flutter 层。
|
||||
abstract class GameHost {
|
||||
/// 切换可见界面:''=游戏中,'scr-title'|'scr-levels'|'scr-settings'|
|
||||
/// 'scr-pause'|'scr-result'|'scr-editor'。
|
||||
void onShowScreen(String name);
|
||||
void onHud(Map<String, dynamic> hud);
|
||||
void onBossBar(dynamic boss);
|
||||
void onToast(String text);
|
||||
void onResult(Map<String, dynamic> r);
|
||||
/// 暂停菜单「退出」按钮文案随来源变化。
|
||||
void onPauseMenu(String label);
|
||||
/// 进入编辑器界面。
|
||||
void editorShow();
|
||||
void touchShow();
|
||||
void touchHide();
|
||||
void touchSetGun(bool has);
|
||||
}
|
||||
|
||||
class World implements LevelTestRunner {
|
||||
final GameHost host;
|
||||
|
||||
/// 游戏状态:menu | playing | paused | result | editor
|
||||
///
|
||||
/// 用 ValueNotifier 承载,是因为 Flutter 侧界面(HUD 显隐)必须能监听它重建:
|
||||
/// 若只存普通字段,`startLevel()` 改完状态不会触发 AppRoot 重建,HUD 永远不出现。
|
||||
/// (web 版由主循环每帧直接改 DOM,不存在这个问题。)
|
||||
final ValueNotifier<String> stateN = ValueNotifier<String>('menu');
|
||||
|
||||
String get state => stateN.value;
|
||||
set state(String v) {
|
||||
if (stateN.value != v) stateN.value = v;
|
||||
}
|
||||
|
||||
String screen = 'scr-title';
|
||||
Level? level;
|
||||
Player? player;
|
||||
Camera? cam;
|
||||
Map<String, dynamic>? data;
|
||||
int idx = -1;
|
||||
List<Level>? map;
|
||||
int face = 0;
|
||||
bool fromEditor = false;
|
||||
int deaths = 0;
|
||||
int ms = 0;
|
||||
int hudTick = 0;
|
||||
int time = 0;
|
||||
String? lastLevelId;
|
||||
bool useTouch = false;
|
||||
|
||||
double _acc = 0;
|
||||
String _lastToast = '';
|
||||
|
||||
World(this.host);
|
||||
|
||||
// ---------------- 关卡流程 ----------------
|
||||
Map<String, dynamic> faceData(Map<String, dynamic> map, Map<String, dynamic> f, int i) {
|
||||
final grid = (f['grid'] as List?)?.cast<String>() ?? <String>[];
|
||||
return <String, dynamic>{
|
||||
'id': (map['id'] ?? 'map').toString() + '-' + i.toString(),
|
||||
'name': f['name'] ?? ('面' + (i + 1).toString()),
|
||||
'type': 'custom',
|
||||
'width': f['width'] ?? (grid.isNotEmpty ? grid[0].length : 25),
|
||||
'grid': f['grid'],
|
||||
'doors': f['doors'] ?? <dynamic>[],
|
||||
'bg': 'custom',
|
||||
'hint': '',
|
||||
'messages': <dynamic>[],
|
||||
};
|
||||
}
|
||||
|
||||
void startLevel(Map<String, dynamic> data, int idx) {
|
||||
Sfx.instance.unlock();
|
||||
this.data = data;
|
||||
this.idx = idx;
|
||||
lastLevelId = data['id']?.toString();
|
||||
|
||||
final faces = data['faces'];
|
||||
if (faces is List && faces.isNotEmpty) {
|
||||
map = <Level>[];
|
||||
for (int i = 0; i < faces.length; i++) {
|
||||
final L = Level(faceData(data, faces[i] as Map<String, dynamic>, i));
|
||||
L.faceIndex = i;
|
||||
map!.add(L);
|
||||
}
|
||||
face = ((data['startFace'] as int?) ?? 0).clamp(0, map!.length - 1);
|
||||
} else {
|
||||
map = <Level>[Level(data)];
|
||||
face = 0;
|
||||
}
|
||||
|
||||
level = map![face];
|
||||
player = Player(level!);
|
||||
cam = Camera(W, H);
|
||||
cam!.reset(level!, player!);
|
||||
|
||||
player!.onDeath = () {
|
||||
deaths++;
|
||||
};
|
||||
player!.onClear = () {
|
||||
finish();
|
||||
};
|
||||
player!.onPortal = (e) {
|
||||
doPortal(e as Entity);
|
||||
};
|
||||
player!.onRespawnFace = () {
|
||||
doRespawnFace();
|
||||
};
|
||||
for (final b in level!.bosses) {
|
||||
b.onDefeat = () {
|
||||
finish();
|
||||
};
|
||||
}
|
||||
|
||||
deaths = 0;
|
||||
ms = 0;
|
||||
hudTick = 0;
|
||||
state = 'playing';
|
||||
host.onShowScreen('');
|
||||
if (useTouch) host.touchShow();
|
||||
host.onToast('');
|
||||
final hint = data['hint'];
|
||||
if (hint is String && hint.isNotEmpty) host.onToast(hint);
|
||||
}
|
||||
|
||||
void placeOnDoor(Entity dest) {
|
||||
final P = player!;
|
||||
P.x = dest.x + dest.w / 2 - P.w / 2;
|
||||
P.y = dest.y + dest.h - P.h;
|
||||
P.vx = 0;
|
||||
P.vy = 0;
|
||||
P.jumps = 0;
|
||||
}
|
||||
|
||||
void doPortal(Entity e) {
|
||||
final to = e.to;
|
||||
if (to is! Map) return;
|
||||
final tp = (to['face'] as int? ?? face).clamp(0, map!.length - 1);
|
||||
if (tp == face) {
|
||||
final dest = level!.entities
|
||||
.where((o) => o.type == 'warp' && o.doorId == to['door'])
|
||||
.firstOrNull;
|
||||
if (dest != null) {
|
||||
placeOnDoor(dest);
|
||||
dest.cool = 40;
|
||||
}
|
||||
Sfx.instance.warp();
|
||||
} else {
|
||||
switchFace(tp, to['door']);
|
||||
}
|
||||
}
|
||||
|
||||
void switchFace(int f, dynamic doorId) {
|
||||
face = f;
|
||||
level = map![f];
|
||||
level!.reset(true);
|
||||
player!.level = level!;
|
||||
final dest = level!.entities
|
||||
.where((o) => o.type == 'warp' && o.doorId == doorId)
|
||||
.firstOrNull;
|
||||
if (dest != null) {
|
||||
placeOnDoor(dest);
|
||||
dest.cool = 40;
|
||||
} else {
|
||||
final sp = level!.spawn;
|
||||
player!.x = sp.x;
|
||||
player!.y = sp.y;
|
||||
player!.vx = 0;
|
||||
player!.vy = 0;
|
||||
}
|
||||
cam!.reset(level!, player!);
|
||||
Sfx.instance.warp();
|
||||
level!.particles.spawn(player!.cx, player!.cy, 12,
|
||||
colors: const ['#a86bff', '#fff'], speed: 2.5, life: 24);
|
||||
}
|
||||
|
||||
void doRespawnFace() {
|
||||
final sp = player!.savePoint;
|
||||
if (sp != null && sp.face != face) {
|
||||
face = sp.face;
|
||||
level = map![face];
|
||||
level!.reset(true);
|
||||
player!.level = level!;
|
||||
cam!.reset(level!, player!);
|
||||
}
|
||||
}
|
||||
|
||||
void restart() {
|
||||
if (data == null) return;
|
||||
final d = deaths;
|
||||
startLevel(data!, idx);
|
||||
deaths = d;
|
||||
}
|
||||
|
||||
void suicide() {
|
||||
final P = player;
|
||||
if (P == null || state != 'playing') return;
|
||||
if (P.dead) {
|
||||
P.respawn();
|
||||
} else {
|
||||
P.deathBurst();
|
||||
P.respawn();
|
||||
}
|
||||
}
|
||||
|
||||
void shoot() {
|
||||
final P = player;
|
||||
if (state != 'playing' || P == null || P.dead || !P.hasGun || P.shootCool != 0) return;
|
||||
P.shoot();
|
||||
}
|
||||
|
||||
void finish() {
|
||||
if (state != 'playing') return;
|
||||
state = 'result';
|
||||
host.touchHide();
|
||||
Sfx.instance.goal();
|
||||
final apples = player!.apples;
|
||||
final total = level!.totalApples;
|
||||
final better = Save.clear(data!['id'].toString(), deaths, ms, apples);
|
||||
final hasNext = idx >= 0 && idx + 1 < LEVELS.length;
|
||||
host.onResult(<String, dynamic>{
|
||||
'name': data!['name'],
|
||||
'ms': ms,
|
||||
'deaths': deaths,
|
||||
'apples': apples,
|
||||
'totalApples': total,
|
||||
'better': better,
|
||||
'hasNext': hasNext,
|
||||
});
|
||||
host.onShowScreen('scr-result');
|
||||
}
|
||||
|
||||
void nextLevel() {
|
||||
if (idx >= 0 && idx + 1 < LEVELS.length) {
|
||||
startLevel(LEVELS[idx + 1], idx + 1);
|
||||
} else {
|
||||
toLevels();
|
||||
}
|
||||
}
|
||||
|
||||
void toLevels() {
|
||||
state = 'menu';
|
||||
screen = 'scr-levels';
|
||||
host.onShowScreen('scr-levels');
|
||||
host.touchHide();
|
||||
}
|
||||
|
||||
void toTitle() {
|
||||
state = 'menu';
|
||||
screen = 'scr-title';
|
||||
host.onShowScreen('scr-title');
|
||||
host.touchHide();
|
||||
}
|
||||
|
||||
void toEditor() {
|
||||
state = 'editor';
|
||||
screen = 'scr-editor';
|
||||
host.onShowScreen('scr-editor');
|
||||
host.touchHide();
|
||||
host.editorShow();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (state != 'playing') return;
|
||||
state = 'paused';
|
||||
// 暂停菜单末项:从编辑器试玩进来 → 「返回编辑器」;否则 → 「返回关卡选择」
|
||||
host.onPauseMenu(fromEditor ? '返回编辑器' : '返回关卡选择');
|
||||
host.onShowScreen('scr-pause');
|
||||
host.touchHide();
|
||||
}
|
||||
|
||||
void resume() {
|
||||
if (state == 'paused') {
|
||||
state = 'playing';
|
||||
host.onShowScreen('');
|
||||
if (useTouch) host.touchShow();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 更新 ----------------
|
||||
void update() {
|
||||
final P = player!;
|
||||
if (Input.hit('suicide')) {
|
||||
suicide();
|
||||
return;
|
||||
}
|
||||
if (Input.hit('restart')) {
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
if (Input.hit('pause')) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
|
||||
level!.update(1, P, cam!);
|
||||
for (final b in level!.bosses) b.update(1, P);
|
||||
P.update(1);
|
||||
|
||||
if (P.justRespawned) {
|
||||
if (P.savePoint != null && P.savePoint!.face != face) doRespawnFace();
|
||||
cam!.snapTo(P, level!);
|
||||
P.justRespawned = false;
|
||||
}
|
||||
|
||||
cam!.update(level!, P, 1);
|
||||
ms += STEP.round();
|
||||
time++;
|
||||
|
||||
if (++hudTick % 5 == 0) {
|
||||
final L = level!;
|
||||
host.onHud(<String, dynamic>{
|
||||
'name': (data!['name'] ?? 'STAGE') +
|
||||
(map!.length > 1 ? ' · 面${face + 1}/${map!.length}' : ''),
|
||||
'deaths': deaths,
|
||||
'ms': ms,
|
||||
'apples': P.apples,
|
||||
'totalApples': L.totalApples,
|
||||
'hasGun': P.hasGun,
|
||||
'save': P.saveIdx,
|
||||
'totalSaves': L.totalSaves,
|
||||
});
|
||||
host.touchSetGun(P.hasGun);
|
||||
final boss = L.bosses.where((b) => !b.dead).firstOrNull ?? L.bosses.firstOrNull;
|
||||
host.onBossBar(L.bosses.isNotEmpty ? boss : null);
|
||||
_pushToast(L.toast ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
void _pushToast(String t) {
|
||||
if (t != _lastToast) {
|
||||
_lastToast = t;
|
||||
host.onToast(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 循环驱动(由 Ticker 调用) ----------------
|
||||
void tick(int dtMs) {
|
||||
int dt = dtMs;
|
||||
if (dt > 200) dt = 200;
|
||||
_acc += dt;
|
||||
int guard = 0;
|
||||
while (_acc >= STEP && guard++ < 6) {
|
||||
_acc -= STEP;
|
||||
if (state == 'playing') {
|
||||
update();
|
||||
} else if (state == 'paused' && Input.hit('pause')) {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
Input.endFrame();
|
||||
// toast 推送必须在 Ticker 回调阶段(transientCallbacks)完成,
|
||||
// 不能放进 render() —— paint 阶段改被 ValueListenableBuilder 监听的
|
||||
// notifier 会触发 "Build scheduled during frame"(晚一帧且刷警告)。
|
||||
_pushToast(level?.toast ?? '');
|
||||
if (state != 'playing') time++;
|
||||
}
|
||||
|
||||
// ---------------- 渲染(800×608 虚拟空间) ----------------
|
||||
void render(Canvas c) {
|
||||
final t = time;
|
||||
if (state == 'playing' || state == 'paused' || state == 'result') {
|
||||
final L = level!;
|
||||
final P = player!;
|
||||
final C = cam!;
|
||||
c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..color = hex('#0a0a14'));
|
||||
L.drawBg(c, C, t);
|
||||
c.save();
|
||||
C.apply(c);
|
||||
L.draw(c, C, t);
|
||||
for (final b in L.bosses) b.draw(c, t);
|
||||
P.draw(c);
|
||||
c.restore();
|
||||
L.drawOverlay(c, C, t);
|
||||
|
||||
// 死亡中:提示手动重生
|
||||
if (state == 'playing' && P.dead) {
|
||||
final blink = sin(t / 12) > -0.3;
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: useTouch ? '按右下角重生键' : '按 R 重生',
|
||||
style: TextStyle(
|
||||
color: const Color(0xffff8fa3).withValues(alpha: (blink ? 1.0 : 0.3) * 255),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
tp.layout();
|
||||
tp.paint(c, Offset(W / 2 - tp.width / 2, H / 2 - 36 - tp.height / 2));
|
||||
final sub = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Backspace 整关重来',
|
||||
style: TextStyle(
|
||||
color: const Color(0xffa8a8c0).withValues(alpha: 0.7 * 255),
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
sub.layout();
|
||||
sub.paint(c, Offset(W / 2 - sub.width / 2, H / 2 - 12 - sub.height / 2));
|
||||
}
|
||||
} else {
|
||||
drawTitleBg(c);
|
||||
}
|
||||
}
|
||||
|
||||
void drawTitleBg(Canvas c) {
|
||||
final g = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[hex('#181828'), hex('#08080f')],
|
||||
).createShader(Rect.fromLTWH(0, 0, W, H));
|
||||
c.drawRect(Rect.fromLTWH(0, 0, W, H), Paint()..shader = g);
|
||||
|
||||
for (int i = 0; i < 26; i++) {
|
||||
final double x = ((i * 137 + time * 0.15) % (W + 60)) - 30;
|
||||
final double y = 120 + ((i * 71) % (H - 160)).toDouble();
|
||||
final col = i % 3 == 0 ? hex('#3a3a4c') : hex('#2a2a38');
|
||||
final p = Path()
|
||||
..moveTo(x, y + 18)
|
||||
..lineTo(x + 11, y)
|
||||
..lineTo(x + 22, y + 18)
|
||||
..close();
|
||||
c.drawPath(p, Paint()..color = col.withValues(alpha: 0.5 * 255));
|
||||
}
|
||||
|
||||
c.drawRect(Rect.fromLTWH(0, H - 30, W, 30), Paint()..color = hex('#1a1a26'));
|
||||
final spikePaint = Paint()..color = hex('#c8c8d8');
|
||||
for (double x = 0; x < W; x += 22) {
|
||||
final p = Path()
|
||||
..moveTo(x, H - 4)
|
||||
..lineTo(x + 11, H - 26)
|
||||
..lineTo(x + 22, H - 4)
|
||||
..close();
|
||||
c.drawPath(p, spikePaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user