// 玩家:还原 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); } }