Files
kidgo/js/sprites.js
T
2026-10-20 22:21:26 +08:00

367 lines
13 KiB
JavaScript

// 程序化像素绘制(无外部图片资源)
import { TILE, shade, hashNoise } from './util.js';
const C = {
stone: '#6b6b7e', stoneDark: '#3a3a48', stoneLite: '#8f8fa6',
grass: '#4f9e46', grassDark: '#2c5c2b',
brick: '#a8543c', brickDark: '#632a1d',
metal: '#5a6478', metalDark: '#333a4a',
ice: '#8fd8f0', iceDark: '#4a90b8',
water: '#2a6fd8',
spike: '#c8c8d8', spikeDark: '#5a5a6a',
kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a',
save: '#b06bff', goal: '#ffc63a', gun: '#b8b8c8', apple: '#e03050',
};
/** 归一化的像素块绘制助手 */
function px(ctx, x, y, w, h, col) { ctx.fillStyle = col; ctx.fillRect(x | 0, y | 0, w | 0, h | 0); }
// ---------------- 地形 ----------------
export function drawBlock(ctx, x, y, variant = 0, lit = false) {
const s = TILE;
let base = C.stone, dark = C.stoneDark, lite = C.stoneLite;
if (variant === 1) { base = C.grass; dark = C.grassDark; lite = '#7fd06a'; }
if (variant === 2) { base = C.brick; dark = C.brickDark; lite = '#d1785c'; }
if (variant === 3) { base = C.metal; dark = C.metalDark; lite = '#8a94ad'; }
px(ctx, x, y, s, s, dark);
px(ctx, x + 1, y + 1, s - 2, s - 2, base);
// 纹理
if (variant === 2) { // 砖
px(ctx, x + 1, y + 8, s - 2, 1, dark);
px(ctx, x + 1, y + 17, s - 2, 1, dark);
px(ctx, x + 10, y + 1, 1, 7, dark);
px(ctx, x + 20, y + 9, 1, 8, dark);
px(ctx, x + 6, y + 18, 1, 7, dark);
} else if (variant === 1) { // 草
px(ctx, x + 1, y + 1, s - 2, 5, lite);
for (let i = 0; i < 4; i++) px(ctx, x + 3 + i * 7, y + 2, 1, 4, dark);
} else { // 石
const n = hashNoise(x, y);
px(ctx, x + 2, y + 2, 6, 2, lite);
if (n > 0.5) px(ctx, x + 18, y + 12, 8, 2, dark);
if (n < 0.35) px(ctx, x + 6, y + 22, 7, 2, dark);
}
// 高光/描边
px(ctx, x, y, s, 1, lite);
px(ctx, x, y, 1, s, lite);
px(ctx, x, y + s - 1, s, 1, dark);
px(ctx, x + s - 1, y, 1, s, dark);
if (lit) { ctx.fillStyle = 'rgba(255,255,255,.09)'; ctx.fillRect(x, y, s, s); }
}
export function drawIce(ctx, x, y) {
const s = TILE;
px(ctx, x, y, s, s, C.iceDark);
px(ctx, x + 1, y + 1, s - 2, s - 2, C.ice);
px(ctx, x + 2, y + 3, 10, 3, '#e6fbff');
px(ctx, x + 18, y + 16, 8, 2, 'rgba(255,255,255,.7)');
px(ctx, x, y, s, 1, '#e6fbff');
px(ctx, x, y + s - 1, s, 1, C.iceDark);
}
export function drawWater(ctx, x, y, t) {
const s = TILE;
px(ctx, x, y, s, s, 'rgba(40,110,215,.55)');
ctx.fillStyle = 'rgba(120,200,255,.35)';
const off = Math.sin(t / 260 + x * 0.09) * 3;
ctx.fillRect(x, y + 4 + off, s, 3);
ctx.fillRect(x, y + 16 - off, s, 2);
}
export function drawOneWay(ctx, x, y) {
const s = TILE;
px(ctx, x, y, s, 6, C.metalDark);
px(ctx, x, y, s, 3, '#9aa6bd');
for (let i = 0; i < 8; i++) px(ctx, x + i * 4, y + 6, 2, 3, C.metalDark);
}
export function drawSpike(ctx, x, y, dir, t) {
const s = TILE;
const g = ctx.createLinearGradient(x, y, x, y + s);
// 底座
if (dir === 0) { // 朝上(在地面上)
px(ctx, x + 1, y + s - 7, s - 2, 7, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + 3 + i * 9, y + s - 6, x + 7.5 + i * 9, y + 5, x + 12 + i * 9, y + s - 6);
px(ctx, x + 1, y + s - 2, s - 2, 2, '#2a2a34');
} else if (dir === 1) { // 朝下(天花板)
px(ctx, x + 1, y, s - 2, 7, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + 3 + i * 9, y + 6, x + 7.5 + i * 9, y + s - 5, x + 12 + i * 9, y + 6);
px(ctx, x + 1, y, s - 2, 2, '#2a2a34');
} else if (dir === 2) { // 朝左(贴右墙)
px(ctx, x + s - 7, y + 1, 7, s - 2, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + s - 6, y + 3 + i * 9, x + 5, y + 7.5 + i * 9, x + s - 6, y + 12 + i * 9);
px(ctx, x + s - 2, y + 1, 2, s - 2, '#2a2a34');
} else { // 朝右(贴左墙)
px(ctx, x, y + 1, 7, s - 2, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + 6, y + 3 + i * 9, x + s - 5, y + 7.5 + i * 9, x + 6, y + 12 + i * 9);
px(ctx, x, y + 1, 2, s - 2, '#2a2a34');
}
}
function tri(ctx, x1, y1, x2, y2, x3, y3) {
ctx.beginPath();
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath();
ctx.fillStyle = C.spike; ctx.fill();
ctx.strokeStyle = '#8d8da0'; ctx.lineWidth = 1; ctx.stroke();
}
/** 全致死方块(美味水果外观) */
export function drawKiller(ctx, x, y, t) {
const s = TILE;
const bob = Math.sin(t / 300 + x) * 1.5;
ctx.save();
ctx.translate(x + s / 2, y + s / 2 + bob);
// 苹果
ctx.fillStyle = C.apple;
ctx.beginPath(); ctx.arc(0, 2, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ff7a90';
ctx.beginPath(); ctx.arc(-4, -2, 4, 0, Math.PI * 2); ctx.fill();
px(ctx, -1, -13, 2, 6, '#6b4423');
ctx.fillStyle = '#3ddc84';
ctx.beginPath(); ctx.ellipse(6, -11, 5, 2.6, -0.5, 0, Math.PI * 2); ctx.fill();
// 恶作剧的眼睛
px(ctx, -5, 0, 2, 2, '#000'); px(ctx, 3, 0, 2, 2, '#000');
ctx.restore();
}
export function drawVanish(ctx, x, y, state, t) {
const s = TILE;
const alpha = state === 0 ? 1 : state === 1 ? (Math.sin(t / 60) > 0 ? 0.55 : 0.9) : 0.14;
ctx.globalAlpha = alpha;
drawBlock(ctx, x, y, 3);
ctx.globalAlpha = 1;
if (state !== 2) {
ctx.strokeStyle = 'rgba(255,200,60,.55)'; ctx.lineWidth = 1;
ctx.strokeRect(x + 2.5, y + 2.5, s - 5, s - 5);
}
}
export function drawJumpRef(ctx, x, y, t) {
const s = TILE;
const b = Math.sin(t / 200 + x) * 2;
ctx.fillStyle = '#3ddc84';
ctx.beginPath();
ctx.moveTo(x + s / 2, y + 8 + b); ctx.lineTo(x + s - 6, y + s - 10 + b); ctx.lineTo(x + 6, y + s - 10 + b);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#0d5c30'; ctx.lineWidth = 1; ctx.stroke();
}
// ---------------- 玩家 ----------------
export function drawKid(ctx, p, t) {
const x = Math.round(p.x), y = Math.round(p.y);
const w = p.w, h = p.h;
const air = !p.grounded;
const sq = p.squash; // 1 正常
ctx.save();
ctx.translate(x + w / 2, y + h);
ctx.scale(1 / sq, sq);
ctx.translate(-w / 2, -h);
const face = p.facing; // 1 右 -1 左
// 身体(红衣)
px(ctx, 2, 7, w - 4, 7, C.kid);
px(ctx, 2, 7, w - 4, 1, shade(C.kid, 40));
// 手臂
if (air) { px(ctx, face > 0 ? w - 3 : 0, 5, 3, 5, C.skin); }
else { px(ctx, face > 0 ? w - 3 : 0, 9, 3, 5, C.skin); }
// 腿
if (air) { px(ctx, 3, 14, 4, 4, C.pant); px(ctx, w - 7, 14, 4, 3, C.pant); }
else { px(ctx, 2, 14, 4, 4, C.pant); px(ctx, w - 6, 14, 4, 4, C.pant); }
// 头
px(ctx, 1, 0, w - 2, 8, C.skin);
px(ctx, 1, 0, w - 2, 3, C.hair); // 头发
px(ctx, face > 0 ? w - 5 : 3, 1, 3, 2, C.hair);
// 眼睛
px(ctx, face > 0 ? w - 6 : 3, 4, 2, 2, '#101018');
px(ctx, face > 0 ? w - 3 : 0, 4, 1, 2, '#101018');
// 嘴(死亡张嘴)
if (p.dead) { px(ctx, face > 0 ? w - 6 : 3, 6, 3, 2, '#5a1a1a'); }
ctx.restore();
// 持枪
if (p.hasGun && !p.dead) {
const gx = face > 0 ? x + w - 1 : x - 7;
const gy = y + 9;
px(ctx, gx, gy, 8, 3, C.gun);
px(ctx, face > 0 ? gx + 6 : gx, gy + 1, 3, 2, '#7a7a8c');
px(ctx, gx + 1, gy + 3, 3, 3, '#6a6a7c');
}
}
// ---------------- 实体 ----------------
export function drawSave(ctx, e, t) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
const on = e.active;
const pulse = 1 + Math.sin(t / 180) * 0.09;
if (on) {
ctx.globalAlpha = 0.28;
ctx.fillStyle = C.save;
ctx.beginPath(); ctx.arc(cx, cy, 15 * pulse, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
} else {
// 未激活:画一圈呼吸光环,别让玩家看不见它
const r = 13 + Math.sin(t / 26) * 2.2;
ctx.globalAlpha = 0.30 + Math.sin(t / 26) * 0.12;
ctx.strokeStyle = '#8a6ab8';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.globalAlpha = 1;
}
const col = on ? C.save : '#8f74b5';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(cx - 3, cy - 1, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + 4, cy + 1, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy + 5, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + 1, cy - 5, 4.2, 0, Math.PI * 2); ctx.fill();
if (on) { ctx.fillStyle = '#e5d0ff'; ctx.beginPath(); ctx.arc(cx - 1, cy - 2, 1.6, 0, Math.PI * 2); ctx.fill(); }
// 梗
px(ctx, cx + 1, cy - 9, 2, 4, '#4a8a3a');
}
export function drawGoal(ctx, e, t) {
const x = e.x, y = e.y, w = e.w, h = e.h;
// 门
px(ctx, x + 3, y + 2, w - 6, h - 2, '#2a1e3a');
px(ctx, x + 5, y + 4, w - 10, h - 6, '#140d20');
// 光柱
const g = ctx.createLinearGradient(x, y + h, x, y);
g.addColorStop(0, 'rgba(255,200,60,.55)');
g.addColorStop(1, 'rgba(255,200,60,0)');
ctx.fillStyle = g;
ctx.fillRect(x + 5, y + 4, w - 10, h - 6);
// 闪烁粒子
for (let i = 0; i < 3; i++) {
const p = ((t / 700) + i / 3) % 1;
px(ctx, x + 7 + ((i * 9) % (w - 12)), y + h - 4 - p * (h - 8), 2, 2, C.goal);
}
// 门框
px(ctx, x + 3, y + 2, w - 6, 2, C.goal);
px(ctx, x + 3, y + 2, 2, h - 2, '#c99a1e');
px(ctx, x + w - 5, y + 2, 2, h - 2, '#c99a1e');
}
export function drawGun(ctx, e, t) {
const x = e.x, y = e.y + Math.sin(t / 250) * 2;
px(ctx, x + 2, y + 6, 20, 7, '#4a4a5c'); // 枪身
px(ctx, x + 2, y + 6, 20, 2, '#8a8aa0');
px(ctx, x + 18, y + 8, 9, 3, C.gun); // 枪管
px(ctx, x + 6, y + 13, 5, 8, '#3a3a4c'); // 握把
px(ctx, x + 8, y + 19, 3, 3, '#2a2a3a');
px(ctx, x + 24, y + 7, 2, 5, '#d0d0e0');
}
export function drawApple(ctx, e, t) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2 + Math.sin(t / 320 + e.x) * 1.5;
ctx.fillStyle = C.apple;
ctx.beginPath(); ctx.arc(cx, cy, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ff8095';
ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, Math.PI * 2); ctx.fill();
px(ctx, cx - 1, cy - 11, 2, 4, '#6b4423');
ctx.fillStyle = '#3ddc84';
ctx.beginPath(); ctx.ellipse(cx + 4, cy - 9, 4, 2, -0.5, 0, Math.PI * 2); ctx.fill();
}
export function drawWarp(ctx, e, t) {
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
for (let i = 0; i < 3; i++) {
const r = 14 - i * 4;
const a = t / (300 + i * 120);
ctx.strokeStyle = `rgba(${170 - i * 30},${90 + i * 40},255,${0.85 - i * 0.2})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r, a, a + Math.PI * 1.4);
ctx.stroke();
}
ctx.fillStyle = 'rgba(120,60,200,.35)';
ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
}
/** 摄像头对象(镜头控制区标记) */
export function drawCamera(ctx, e, t) {
const x = e.x, y = e.y, w = e.w, h = e.h;
ctx.strokeStyle = `rgba(80,220,255,${0.35 + Math.sin(t / 300) * 0.15})`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 1, y + 1, w - 2, h - 2);
ctx.setLineDash([]);
// 镜头图标(放中央上方)
const ix = x + w / 2 - 9, iy = y + 6;
px(ctx, ix, iy + 3, 18, 11, '#1e1e2a');
px(ctx, ix + 2, iy + 5, 14, 7, '#3a3a4c');
ctx.fillStyle = '#7ae0ff';
ctx.beginPath(); ctx.arc(ix + 9, iy + 8.5, 3.5, 0, Math.PI * 2); ctx.fill();
px(ctx, ix + 12, iy, 5, 3, '#1e1e2a');
px(ctx, ix + 13, iy - 1, 3, 2, '#ff4060');
}
export function drawSign(ctx, e, t) {
const x = e.x, y = e.y, w = e.w, h = e.h;
px(ctx, x + w / 2 - 1, y + h - 8, 3, 8, '#6b4423');
px(ctx, x, y + 2, w, h - 10, '#c9a86b');
px(ctx, x + 2, y + 4, w - 4, h - 14, '#e8d5a8');
px(ctx, x + 4, y + 8, w - 8, 2, '#8a7550');
px(ctx, x + 4, y + 13, w - 12, 2, '#8a7550');
}
export function drawTurret(ctx, e, t) {
const x = e.x, y = e.y, s = TILE;
px(ctx, x + 2, y + 6, s - 4, s - 6, '#4a4a5e');
px(ctx, x + 4, y + 8, s - 8, s - 12, '#6a6a80');
const dirx = e.dir === 'left' ? -1 : e.dir === 'right' ? 1 : 0;
const diry = e.dir === 'up' ? -1 : e.dir === 'down' ? 1 : 0;
const cx = x + s / 2, cy = y + s / 2;
px(ctx, cx + dirx * 8 - 3, cy + diry * 8 - 3, 7, 7, '#2a2a38');
const blink = (Math.floor(t / 300) % 3) === 0;
px(ctx, cx + dirx * 12 - 2, cy + diry * 12 - 2, 4, 4, blink ? '#ff4060' : '#8a2030');
}
export function drawMovingPlatform(ctx, e) {
const x = e.x, y = e.y, w = e.w, h = e.h;
px(ctx, x, y, w, h, '#3a3a4c');
px(ctx, x + 1, y + 1, w - 2, h - 2, '#6a6a84');
px(ctx, x + 1, y + 1, w - 2, 2, '#9a9ab4');
for (let i = 0; i < w; i += 8) px(ctx, x + i + 2, y + h - 4, 4, 2, '#3a3a4c');
// 轨道虚线
ctx.strokeStyle = 'rgba(255,255,255,.10)';
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.moveTo(e.ax + e.aw / 2, e.ay + e.ah / 2);
ctx.lineTo(e.bx + e.aw / 2, e.by + e.ah / 2);
ctx.stroke();
ctx.setLineDash([]);
}
/** 掉落刺(坑向:玩家靠近触发) */
export function drawDropSpike(ctx, e, t) {
const s = TILE;
if (!e.falling) {
ctx.globalAlpha = 0.85;
px(ctx, e.x + 4, e.y + 4, s - 8, s - 8, '#4a4a5c');
px(ctx, e.x + 6, e.y + 6, s - 12, s - 12, '#6a6a80');
ctx.globalAlpha = 1;
} else {
drawSpike(ctx, e.x, e.y, 2, t);
}
}
export function drawBullet(ctx, b) {
px(ctx, b.x - 1, b.y - 1, b.w + 2, b.h + 2, 'rgba(255,240,120,.35)');
px(ctx, b.x, b.y, b.w, b.h, '#fff6a0');
px(ctx, b.x, b.y + b.h - 2, b.w, 2, '#ffb040');
}
export function drawEBullet(ctx, b, t) {
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
ctx.fillStyle = b.col || '#ff5c7a';
ctx.beginPath(); ctx.arc(cx, cy, b.w / 2 + 1, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(cx - 1.5, cy - 1.5, b.w / 4, 0, Math.PI * 2); ctx.fill();
if (b.big) {
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.arc(cx, cy, b.w / 2 + 4 + Math.sin(t / 90) * 2, 0, Math.PI * 2); ctx.stroke();
}
}
export { C, px };