/* ========================================================= sprites.js — вся пиксельная графика. - Статичные объекты (препятствия, сердечко) описаны строковыми картами и один раз растеризуются через Pixel.make. - Персонаж рисуется процедурно в offscreen-буфер, затем к нему добавляется сплошной тёмный контур (силуэт со сдвигом) — это даёт чёткую, «без дырок», хорошо читаемую фигурку. ========================================================= */ 'use strict'; /* ---------------- Палитры ---------------- */ const PAL = { // Персонаж (яркие, контрастные цвета) char: { skin: '#ffcfa0', skinSh: '#e8a877', hair: '#ffe9a8', hairSh: '#e6c86e', // светлое каре shirt: '#2fe0cf', shirtSh:'#12b3a4', // бирюзовая футболка pants: '#3a4a66', pantsSh:'#28344a', // тёмные брюки shoe: '#ffffff', shoeSh: '#c9d2d8', sole:'#2a323c', // кеды outline:'#1a1410', eye:'#22160e', mouth:'#c85a48', cheek:'#ffb59a', }, // Городские объекты / препятствия city: { '#': '#161a1f', 'M': '#9aa9b4', 'm': '#5c6d78', 'S': '#c9d2d6', 'Y': '#ffcf3f', 'y': '#c99a1a', 'R': '#e2574c', 'r': '#a8382f', 'K': '#0a0d12', 'W': '#54b8e2', 'w': '#2f86b8', 'B': '#c2b39c', 'b': '#8a7d6e', 'G': '#5aa15a', 'g': '#3c7a3c', 'T': '#c98a4a', 't': '#9a6630', 'O': '#ef8a3c', 'o': '#c96a24', 'D': '#7a5a3a', 'd': '#553d28', 'P': '#3aa6a0', 'p': '#2a7a76', 'L': '#e8e2d0', 'x': '#3a3f45', }, // Сердечко поддержки — фирменная бирюза Тиффани heart: { '#': '#07514e', // тёмная обводка 'T': '#0abab5', // Tiffany — основной цвет 'H': '#7ff2e6', // светлая грань сверху слева 'S': '#0b8f88', // тень справа снизу 'W': '#dffffa', // блик }, }; /* ---------------- Строковые карты препятствий ---------------- */ /* '.' — прозрачно. Ряды могут иметь разную длину (Pixel.make выравнивает). */ const OBSTACLE_MAPS = { // Открытый люк: тёмная дыра со сдвинутой крышкой hatch: [ '.....mmmm........', '....mMMMMm.......', '..############...', '.#KKKKKKKKKKKK#..', '#KKKKKKKKKKKKKK#.', '#KKKKKKKKKKKKKK#.', '.#KKKKKKKKKKKK#..', '..############...', ], // Переполненная мусорка trash: [ '....T..t.T......', '..T.TtT.TtT.T...', '.TtTTtTTtTTtTt..', '..############..', '..#SmSmSmSmSm#..', '..#mSmSmSmSmS#..', '..#SmSmSmSmSm#..', '..#mSmSmSmSmS#..', '..#SmSmSmSmSm#..', '..#mSmSmSmSmS#..', '..############..', ], // Огромная лужа puddle: [ '...WW......WW.......WW....', '.WWwwWWWWWWwwWWWWWWWwwWWW.', 'WwwwwwwwwwwwwwwwwwwwwwwwwW', 'WwwWWwwwwWWwwwwwwWWwwwwwwW', '.WwwwwwwwwwwwwwwwwwwwwwW..', '..WWwwwwwwwwwwwwwwwwwWW...', ], // Сломанный бордюр curb: [ '...............', '....##.........', '..BBbBB##.......', '.BBBBBBBbBB##...', 'BBBBBBBBBBBBBBB.', 'bBBbBBbBBbBBbBb.', 'bbbbbbbbbbbbbbb.', 'dddddddddddddd..', ], // Дорожные работы: щит с полосами + конус roadwork: [ '.......O........', '......OOO.......', '.....OOoOO......', '.....#####......', '#YRYRYRYRYRYR#..', '#RYRYRYRYRYRY#..', '#YRYRYRYRYRYR#..', '#############...', '..m.......m....', '..m.......m....', '..m.......m....', ], // Самокат (жёлтый, вид сбоку): дека, стойка, руль, два колеса scooter: [ '..........YYYYY..', '............Y....', '............Y....', '............Y....', '............Y....', '............Y....', '............Y....', '..YYYYYYYYYYYY...', '..yYYYYYYYYYYy...', '.KKK.......KKK..', 'KmmK.......KmmK.', 'KmmK.......KmmK.', '.KKK.......KKK..', ], // Раскопанная плитка tiles: [ '..L#..L#....#L....', '.L#.#L.#..#L.#....', 'DDdDDdDDdDDdDDdDD.', 'dDDdDDdDDdDDdDDdD.', 'DdDDKKKKKKDDdDDdD.', 'dDDDKKKKKKDDDdDDd.', 'DdDDdDDdDDdDDdDDd.', ], // Упавшее дорожное ограждение fence: [ 'M..M..M..M..M..M.', 'M..M..M..M..M..M.', 'MMMMMMMMMMMMMMMM.', 'M..M..M..M..M..M.', 'M..M..M..M..M..M.', 'MMMMMMMMMMMMMMMM.', 'm..m..m..m..m..m.', '################.', ], }; /* Сердечко поддержки: светлая грань сверху слева, тень справа снизу, блик 2x2 */ const HEART_MAP = [ '..####..####..', '.#HHTT##TTTT#.', '#HWWHTTTTTTTT#', '#HWWHTTTTTTTT#', '#HHTTTTTTTTTS#', '#HTTTTTTTTTSS#', '.#TTTTTTTTSS#.', '.#TTTTTTTSSS#.', '..#TTTTTSSS#..', '...#TTTSSS#...', '....#TSSS#....', '.....#SS#.....', '......##......', ]; /* ---------------- Реестр готовых спрайтов ---------------- */ const SPR = { obstacles: {}, // id -> {canvas, w, h} heart: null, }; /** Растеризовать все статичные спрайты (вызывается один раз при старте). */ function buildSprites() { const scale = 4; for (const [id, rows] of Object.entries(OBSTACLE_MAPS)) { const cv = Pixel.make('obs_' + id, rows, PAL.city, scale); SPR.obstacles[id] = { canvas: cv, w: cv.width, h: cv.height }; } const heartCv = Pixel.make('heart', HEART_MAP, PAL.heart, CONFIG.COINS.SCALE); SPR.heart = { canvas: heartCv, w: heartCv.width, h: heartCv.height }; } /* ========================================================= CharacterRenderer — три анимационных листа по 8 кадров: бег, прыжок и подкат. Каждый лист хранит кадры в сетке 4x2. ========================================================= */ const CharacterRenderer = { animations: Object.freeze({ // Целочисленный размер 114x152 исключает дробное ресемплирование пикселей. run: { id: 'hero-run-animation', frameW: 384, frameH: 512, drawW: 114, drawH: 152, footOffsetPx: 21 }, jump: { id: 'hero-jump-animation', frameW: 112, frameH: 152, scale: 0.90 }, slide: { id: 'hero-slide-animation', frameW: 208, frameH: 128, scale: 0.88 }, }), _frameCache: new Map(), /** Изолируем один кадр от соседей на большом листе. */ _getFrameCanvas(img, anim, frame) { const key = `${img.src}|${anim.frameW}x${anim.frameH}|${frame}`; let canvas = this._frameCache.get(key); if (canvas) return canvas; canvas = document.createElement('canvas'); canvas.width = anim.frameW; canvas.height = anim.frameH; const frameCtx = canvas.getContext('2d'); frameCtx.imageSmoothingEnabled = false; frameCtx.clearRect(0, 0, canvas.width, canvas.height); frameCtx.drawImage( img, (frame % 4) * anim.frameW, Math.floor(frame / 4) * anim.frameH, anim.frameW, anim.frameH, 0, 0, anim.frameW, anim.frameH, ); this._frameCache.set(key, canvas); return canvas; }, /** * @param {CanvasRenderingContext2D} ctx * @param {number} cx центр по X (экран, вирт. px) * @param {number} footY уровень стоп * @param {Object} st {state, frame, rotation, flip, animT} */ draw(ctx, cx, footY, st) { const kind = st.state === 'slide' ? 'slide' : (st.state === 'jump' || st.state === 'fall' ? 'jump' : 'run'); const anim = this.animations[kind]; const img = document.getElementById(anim.id); if (!img || !img.complete || !img.naturalWidth) return; const isGrounded = st.state === 'run' || st.state === 'slide' || st.state === 'victory'; if (isGrounded) { ctx.save(); ctx.globalAlpha *= st.state === 'slide' ? 0.22 : 0.17; ctx.fillStyle = '#07161a'; ctx.beginPath(); ctx.ellipse(cx, footY + 3, st.state === 'slide' ? 52 : 31, st.state === 'slide' ? 7 : 6, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } ctx.save(); ctx.imageSmoothingEnabled = false; const frame = Utils.clamp(Math.floor(st.frame || 0), 0, 7); const frameCanvas = this._getFrameCanvas(img, anim, frame); const drawW = anim.drawW || Math.round(anim.frameW * anim.scale); const drawH = anim.drawH || Math.round(anim.frameH * anim.scale); const victoryBob = st.state === 'victory' ? -Math.abs(Math.sin((st.animT || 0) * Math.PI * 3)) * 8 : 0; // Вращение происходит вокруг корпуса, поэтому двойной прыжок остаётся компактным. const footOffset = anim.footOffsetPx || Math.round((anim.footOffset || 0) * anim.scale); ctx.translate(Math.round(cx), Math.round(footY - drawH / 2 + victoryBob + footOffset)); if (st.rotation) ctx.rotate(st.rotation); if (st.flip) ctx.scale(-1, 1); ctx.drawImage( frameCanvas, -drawW / 2, -drawH / 2, drawW, drawH, ); ctx.restore(); }, };