// 程序化像素绘制(无外部图片资源) 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 };