📢 新文章推送 · 每周更新优质内容 · 订阅更新 →
向下滚动
游戏娱乐

像素画在线绘制工具功能实现

AI 智能总结

概述

Lumin 博客在 探索 菜单下新增了 像素画 子页面,提供在线像素画绘制功能。支持画笔、橡皮擦、填充、取色器四种工具,5 种画布尺寸,18 色预设调色板,撤销/重做历史记录,网格线开关,PNG 导出,触摸屏适配和暗色模式。本文详解完整的实现方案。

架构设计

1hugo.toml                              → 菜单注册(探索 → 像素画,weight=19)
2content/pixelart/_index.md             → 页面元数据(type=pixelart)
3layouts/pixelart/single.html           → 单页模板(内联 CSS + JS)
4layouts/_default/baseof.html           → noSidebar 列表添加 pixelart
5assets/css/main.css                    → :has(#pixelart-page) 全宽布局选择器

采用 Hugo 的 Content Type 模式:通过 type: pixelart 指定内容类型,Hugo 自动匹配 layouts/pixelart/single.html 模板渲染,无需后端 API,所有绘制逻辑在前端完成。

内容文件

content/pixelart/_index.md 定义页面元数据:

1---
2title: "像素画"
3slug: pixelart
4date: 2026-06-03T00:00:00+08:00
5draft: false
6type: pixelart
7description: "用像素创造你的艺术 — 在线像素画绘制工具"
8url: /pixelart/
9---

关键字段说明:

  • type: pixelart — 指定内容类型,匹配 layouts/pixelart/ 目录下的模板
  • url: /pixelart/ — 固定 URL 路径

全宽布局集成

baseof.html 无侧边栏

layouts/_default/baseof.html$noSidebar 变量中添加 pixelart

1{{ $noSidebar := or (eq .Type "moments") (eq .Type "gallery") ... (eq .Type "pixelart") (eq .Type "error") }}

$noSidebar 为 true 时,页面走全宽布局分支,不渲染侧边栏和目录。

main.css 全宽选择器

assets/css/main.css 中添加 :has() 选择器,控制全宽页面的 padding-top:

1.main-wrapper:has(#pixelart-page),
2.main-wrapper:has(#tools-page),
3.main-wrapper:has(#explore-page),
4/* ... 其他全宽页面 */
5{
6  padding: calc(var(--header-height) + 80px) 0 0 !important;
7}

模板中通过 id="pixelart-page" 触发此选择器。

首页排除

layouts/index.html 中排除 pixelart 类型,避免出现在首页文章列表:

1{{ $allArticles := where (where site.RegularPages "Params.excludeFromList" "!=" true) "Type" "not in" (slice "moments" "amap" "pomodoro" "pixelart") }}

页面模板设计

模板 layouts/pixelart/single.html 采用内联 CSS + JS 的方式,与番茄钟、出行轨迹等页面风格统一。

绿色渐变 Banner,高度固定 160px,与探索子菜单下其他页面风格一致:

 1.pixelart-page-header{
 2  text-align:center;
 3  height:160px;
 4  max-height:160px;
 5  overflow:hidden;
 6  display:flex;
 7  flex-direction:column;
 8  align-items:center;
 9  justify-content:center;
10  padding:28px 24px;
11  background:linear-gradient(135deg,#ecfdf5 0%,#d1fae5 50%,#a7f3d0 100%);
12  border-radius:20px;
13  margin-bottom:22px;
14  box-shadow:0 4px 20px rgba(0,0,0,.08);
15  position:relative;
16  border:1px solid rgba(16,185,129,.12)
17}

关键点:

  • 使用 height:160px + max-height:160px + overflow:hidden 固定高度,避免内容撑开
  • ::before 伪元素添加径向渐变光晕装饰
  • 图标添加 bounce 动画,增加视觉活力

工具栏

工具栏采用 flex-wrap 布局,分组排列工具、颜色和画布选项:

 1<div class="pixelart-toolbar">
 2  <div class="pixelart-toolbar-group">
 3    <span class="pixelart-toolbar-label">工具</span>
 4    <button class="pixelart-tool-btn active" data-tool="pen">...</button>
 5    <button class="pixelart-tool-btn" data-tool="eraser">...</button>
 6    <button class="pixelart-tool-btn" data-tool="fill">...</button>
 7    <button class="pixelart-tool-btn" data-tool="picker">...</button>
 8  </div>
 9  <div class="pixelart-toolbar-divider"></div>
10  <div class="pixelart-toolbar-group">
11    <span class="pixelart-toolbar-label">颜色</span>
12    <input type="color" id="pixelart-color" value="#1e293b">
13    <div class="pixelart-color-presets" id="pixelart-presets"></div>
14  </div>
15  <div class="pixelart-toolbar-divider"></div>
16  <div class="pixelart-toolbar-group">
17    <span class="pixelart-toolbar-label">画布</span>
18    <select id="pixelart-grid-size">
19      <option value="16">16 × 16</option>
20      <option value="32" selected>32 × 32</option>
21      <option value="64">64 × 64</option>
22    </select>
23  </div>
24</div>

工具按钮使用 data-tool 属性标识类型,点击时切换 active 类:

1.pixelart-tool-btn.active{
2  background:linear-gradient(135deg,#10b981,#059669);
3  color:#fff;
4  border-color:transparent;
5  box-shadow:0 4px 16px rgba(16,185,129,.35)
6}

Canvas 绘制核心

数据模型

使用二维数组存储像素颜色,null 表示透明:

1var gridSize = 32;   // 画布尺寸(32×32)
2var pixelSize = 16;  // 每个像素的渲染大小(px)
3var pixels = [];      // 二维数组,pixels[y][x] = '#ef4444' | null

初始化时创建空画布:

 1function resetPixels(){
 2  pixels = [];
 3  for(var y = 0; y < gridSize; y++){
 4    pixels[y] = [];
 5    for(var x = 0; x < gridSize; x++){
 6      pixels[y][x] = null;
 7    }
 8  }
 9  undoStack = [];
10  redoStack = [];
11}

自适应画布大小

根据容器宽度动态计算每个像素的渲染大小,确保画布不溢出:

1function resizeCanvas(){
2  var wrapper = canvas.parentElement;
3  var maxW = wrapper.clientWidth - 48;
4  pixelSize = Math.max(4, Math.floor(maxW / gridSize));
5  pixelSize = Math.min(pixelSize, 20);  // 上限 20px
6  canvas.width = gridSize * pixelSize;
7  canvas.height = gridSize * pixelSize;
8}

渲染流程

每次操作后调用 render() 重绘整个画布:

 1function render(){
 2  ctx.clearRect(0, 0, canvas.width, canvas.height);
 3
 4  // 1. 棋盘格背景(表示透明区域)
 5  for(var y = 0; y < gridSize; y++){
 6    for(var x = 0; x < gridSize; x++){
 7      if((x + y) % 2 === 0){
 8        ctx.fillStyle = '#f8f9fa';
 9      } else {
10        ctx.fillStyle = '#e9ecef';
11      }
12      ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
13
14      // 2. 像素颜色覆盖
15      if(pixels[y][x]){
16        ctx.fillStyle = pixels[y][x];
17        ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
18      }
19    }
20  }
21
22  // 3. 网格线(仅当像素≥6px时显示)
23  if(showGrid && pixelSize >= 6){
24    ctx.strokeStyle = 'rgba(0,0,0,0.08)';
25    ctx.lineWidth = 0.5;
26    for(var i = 0; i <= gridSize; i++){
27      ctx.beginPath();
28      ctx.moveTo(i * pixelSize, 0);
29      ctx.lineTo(i * pixelSize, gridSize * pixelSize);
30      ctx.stroke();
31      ctx.beginPath();
32      ctx.moveTo(0, i * pixelSize);
33      ctx.lineTo(gridSize * pixelSize, i * pixelSize);
34      ctx.stroke();
35    }
36  }
37}

棋盘格背景是像素画编辑器的标准设计,让用户直观区分透明区域和白色填充。

像素坐标转换

鼠标/触摸事件需要转换为画布上的像素坐标:

 1function getPixelCoord(e){
 2  var rect = canvas.getBoundingClientRect();
 3  var scaleX = canvas.width / rect.width;
 4  var scaleY = canvas.height / rect.height;
 5  var x, y;
 6  if(e.touches){
 7    x = (e.touches[0].clientX - rect.left) * scaleX;
 8    y = (e.touches[0].clientY - rect.top) * scaleY;
 9  } else {
10    x = (e.clientX - rect.left) * scaleX;
11    y = (e.clientY - rect.top) * scaleY;
12  }
13  return {
14    x: Math.floor(x / pixelSize),
15    y: Math.floor(y / pixelSize)
16  };
17}

关键点:scaleX/scaleY 处理 CSS 缩放与 Canvas 实际尺寸不一致的情况。

四种绘制工具

画笔与橡皮擦

最基础的工具,画笔设置颜色,橡皮擦清除为 null

1function drawPixel(x, y){
2  if(x < 0 || x >= gridSize || y < 0 || y >= gridSize) return;
3  if(currentTool === 'pen'){
4    pixels[y][x] = currentColor;
5  } else if(currentTool === 'eraser'){
6    pixels[y][x] = null;
7  }
8  render();
9}

拖拽绘制通过 mousedownmousemovemouseup 事件链实现,isDrawing 标志位控制:

 1canvas.addEventListener('mousedown', function(e){
 2  if(currentTool === 'fill' || currentTool === 'picker') { /* ... */ }
 3  else {
 4    saveState();
 5    isDrawing = true;
 6    drawPixel(coord.x, coord.y);
 7  }
 8});
 9canvas.addEventListener('mousemove', function(e){
10  if(!isDrawing) return;
11  drawPixel(coord.x, coord.y);
12});
13document.addEventListener('mouseup', function(){ isDrawing = false; });

填充工具(Flood Fill)

使用栈式深度优先搜索实现泛洪填充算法:

 1function floodFill(startX, startY, fillColor){
 2  if(startX < 0 || startX >= gridSize || startY < 0 || startY >= gridSize) return;
 3  var targetColor = pixels[startY][startX];
 4  if(targetColor === fillColor) return;  // 避免无限循环
 5
 6  var stack = [[startX, startY]];
 7  var visited = {};
 8
 9  while(stack.length > 0){
10    var pos = stack.pop();
11    var px = pos[0], py = pos[1];
12    if(px < 0 || px >= gridSize || py < 0 || py >= gridSize) continue;
13    var key = px + ',' + py;
14    if(visited[key]) continue;
15    if(pixels[py][px] !== targetColor) continue;
16
17    visited[key] = true;
18    pixels[py][px] = fillColor;
19    stack.push([px+1,py],[px-1,py],[px,py+1],[px,py-1]);
20  }
21  render();
22}

关键点:

  • targetColor === fillColor 判断避免同色填充导致的无限循环
  • visited 哈希表防止重复访问
  • 使用栈而非递归,避免大画布时栈溢出

取色器

点击画布上的像素获取其颜色,同步到颜色选择器和预设色板:

1function pickColor(x, y){
2  if(x < 0 || x >= gridSize || y < 0 || y >= gridSize) return;
3  var c = pixels[y][x];
4  if(c){
5    currentColor = c;
6    colorInput.value = c;
7    updateSwatchActive();
8  }
9}

撤销/重做系统

状态快照

每次操作前保存完整画布状态快照:

 1var undoStack = [];
 2var redoStack = [];
 3var maxHistory = 50;
 4
 5function saveState(){
 6  var state = pixels.map(function(row){ return row.slice(); });
 7  undoStack.push(state);
 8  if(undoStack.length > maxHistory) undoStack.shift();  // 限制历史长度
 9  redoStack = [];  // 新操作清空重做栈
10}

使用 Array.slice() 进行浅拷贝,因为每个像素是字符串(不可变),浅拷贝足够。

撤销与重做

 1function undo(){
 2  if(undoStack.length === 0) return;
 3  var state = pixels.map(function(row){ return row.slice(); });
 4  redoStack.push(state);    // 当前状态压入重做栈
 5  pixels = undoStack.pop(); // 恢复上一状态
 6  render();
 7}
 8
 9function redo(){
10  if(redoStack.length === 0) return;
11  var state = pixels.map(function(row){ return row.slice(); });
12  undoStack.push(state);    // 当前状态压入撤销栈
13  pixels = redoStack.pop(); // 恢复下一状态
14  render();
15}

支持键盘快捷键 Ctrl+Z 撤销、Ctrl+Y 重做。

颜色系统

预设调色板

18 色预设覆盖常用颜色,通过 JS 动态生成色块:

 1var PRESETS = [
 2  '#1e293b','#ef4444','#f97316','#eab308','#22c55e','#10b981',
 3  '#06b6d4','#3b82f6','#6366f1','#8b5cf6','#ec4899','#f43f5e',
 4  '#ffffff','#94a3b8','#64748b','#334155','#000000','#78350f'
 5];
 6
 7function buildPresets(){
 8  presetsDiv.innerHTML = '';
 9  PRESETS.forEach(function(c){
10    var el = document.createElement('div');
11    el.className = 'pixelart-color-swatch';
12    el.style.background = c;
13    if(c === '#ffffff') el.style.border = '2px solid #d1d5db';  // 白色加边框
14    el.dataset.color = c;
15    if(c === currentColor) el.classList.add('active');
16    presetsDiv.appendChild(el);
17  });
18}

自定义颜色

<input type="color"> 提供系统原生颜色选择器,与预设色板联动:

1colorInput.addEventListener('input', function(){
2  currentColor = this.value;
3  updateSwatchActive();  // 取消预设色板的 active 状态
4});

PNG 导出

导出时创建临时 Canvas,以 16 倍放大渲染,保持像素锐利边缘:

 1document.getElementById('pixelart-export').addEventListener('click', function(){
 2  var exportCanvas = document.createElement('canvas');
 3  var scale = 16;  // 每像素 16×16 px
 4  exportCanvas.width = gridSize * scale;
 5  exportCanvas.height = gridSize * scale;
 6  var ectx = exportCanvas.getContext('2d');
 7
 8  // 透明背景,仅绘制有颜色的像素
 9  for(var y = 0; y < gridSize; y++){
10    for(var x = 0; x < gridSize; x++){
11      if(pixels[y][x]){
12        ectx.fillStyle = pixels[y][x];
13        ectx.fillRect(x * scale, y * scale, scale, scale);
14      }
15    }
16  }
17
18  var link = document.createElement('a');
19  link.download = 'pixelart-' + gridSize + 'x' + gridSize + '-' + Date.now() + '.png';
20  link.href = exportCanvas.toDataURL('image/png');
21  link.click();
22});

关键点:

  • 16 倍放大确保 32×32 画布导出 512×512 像素图片
  • 透明背景(不绘制棋盘格),方便后续合成
  • 文件名包含画布尺寸和时间戳

触摸屏适配

Canvas 默认触摸行为是滚动页面,需要阻止并转换为绘制事件:

 1canvas.addEventListener('touchstart', function(e){
 2  e.preventDefault();  // 阻止滚动
 3  var coord = getPixelCoord(e);
 4  // ... 与 mousedown 相同的逻辑
 5}, {passive: false});
 6
 7canvas.addEventListener('touchmove', function(e){
 8  e.preventDefault();  // 阻止滚动
 9  if(!isDrawing) return;
10  var coord = getPixelCoord(e);
11  drawPixel(coord.x, coord.y);
12}, {passive: false});
13
14canvas.addEventListener('touchend', function(){ isDrawing = false; });

同时设置 touch-action: none CSS 属性,从根源禁止浏览器触摸手势:

1.pixelart-canvas{
2  touch-action: none;
3}

暗色模式

所有组件使用 CSS 变量 + [data-theme="dark"] 选择器适配暗色模式:

 1[data-theme="dark"] .pixelart-page-header{
 2  background:linear-gradient(135deg,#022c22 0%,#064e3b 50%,#022c22 100%);
 3  border-color:rgba(16,185,129,.2)
 4}
 5[data-theme="dark"] .pixelart-canvas{
 6  border-color:var(--border-color,#334155);
 7  background:#1e293b
 8}
 9[data-theme="dark"] .pixelart-tool-btn{
10  background:rgba(30,41,59,.8);
11  color:#e2e8f0;
12  border-color:rgba(148,163,184,.15)
13}

暗色模式下棋盘格背景由 JS 检测主题动态调整,或使用 CSS 变量统一控制。

响应式设计

三个断点适配不同屏幕:

断点容器 paddingBanner 高度工具栏
>900px40px160px完整展示
600-900px16px160px紧凑间距
<600px12px140px最小间距

画布大小通过 resizeCanvas() 自动适配容器宽度,窗口 resize 时重新计算。

评论区集成

模板底部集成 Twikoo 评论系统,与主题其他页面风格统一:

1{{ if and site.Params.comments.enable (ne .Params.comments false) }}
2<div class="comments-section" style="margin-top:24px;padding:24px 28px;...">
3  <h3 class="comments-title">
4    <i class="fas fa-comment-dots" style="color:#10b981"></i> 留言评论
5  </h3>
6  {{ partial "comments.html" . }}
7</div>
8{{ end }}

评论图标颜色使用页面主题色 #10b981(绿色),与 Banner 渐变呼应。

菜单注册

hugo.toml 中注册菜单项,作为「探索」的子菜单:

1[[menu.main]]
2  identifier = "explore-pixelart"
3  name = "像素画"
4  url = "/pixelart/"
5  weight = 19
6  parent = "explore"
7  [menu.main.params]
8    icon = "fa-solid fa-th"

图标使用 fa-th(网格图标),直观表达像素画的特征。

技术要点总结

功能实现方式
像素数据二维数组 pixels[y][x],null 为透明
画布渲染Canvas 2D API,棋盘格 + 像素 + 网格线三层
画笔/橡皮擦mousedown+mousemove 事件链,isDrawing 标志位
填充栈式 DFS 泛洪算法,visited 哈希防重
取色器读取像素颜色,同步到 input[type=color]
撤销/重做双栈结构,最多 50 步历史
导出 PNG临时 Canvas 16× 放大渲染,透明背景
触摸屏preventDefault + touch-action:none
暗色模式CSS 变量 + [data-theme=“dark”] 选择器
全宽布局:has(#pixelart-page) + $noSidebar 变量
6 / 9
版权声明

本文作者 Lumin

本文链接 https://www.zhengquan.xyz/game/pixelart-implementation/

许可协议 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

请作者喝杯咖啡 ☕

  • 微信打赏
    微信支付
  • 支付宝打赏
    支付宝
点击按钮查看打赏二维码
🎁 推荐工具
试试这些实用在线工具,提升工作效率
前往工具集 →

留言评论

期待你的想法

评论加载中