390 lines
12 KiB
Dart
390 lines
12 KiB
Dart
// 玩家:还原 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 {
|
|
// 2026-10-25:手感「稍微飘一点」。飘 = 更低重力换更长滞空,但高度由
|
|
// v²/(2g) 决定——同比例降重力并提初速即可让顶点高度零变化:
|
|
// GRAV 0.25→0.23、GRAV_FALL 0.32→0.30、GRAV_DJ 0.35→0.322
|
|
// JUMP 6.6→6.326、DJUMP 6.7→6.420(按引擎积分顺序反解,单跳顶点仍
|
|
// 83.85px、二段自身仍 60.80px,总高仍 4.5 格;滞空 49→51 帧 ≈ +4%)。
|
|
// HSPD / MAXFALL / DJ_INERTIA / WATER_* / ICE_* / SLIDE_* 未动。
|
|
static const double GRAV = 0.23;
|
|
static const double GRAV_FALL = 0.30;
|
|
static const double GRAV_DJ = 0.322;
|
|
static const double MAXFALL = 9;
|
|
static const double JUMP = 6.326;
|
|
static const double DJUMP = 6.420;
|
|
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);
|
|
}
|
|
}
|