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

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);
}
}