367 lines
13 KiB
JavaScript
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 };
|