HarmonyOS 小游戏《对战五子棋》开发第20篇 - 棋子设计

让平面棋子"立体起来"——Canvas径向渐变的实战应用

设计截图:

代码如下:

/** * ChessBoardView.ets - 可复用的五子棋棋盘组件 * 使用Canvas绘制棋盘、网格、棋子 */import{BOARD_SIZE,EMPTY,BLACK,WHITE}from'./GameConstants';constBOARD_BG_COLOR:string='#D4A868';constLINE_COLOR:string='#5C4033';constSTAR_COLOR:string='#3B2F2F';constLAST_MOVE_COLOR:string='#E63946';@Componentexportstruct ChessBoardView{/** 棋盘数据(扁平数组,row*15+col) */@Prop@Watch('onDataChange')boardData:number[]=[];/** 最后一手行 */@ProplastMoveRow:number=-1;/** 最后一手列 */@ProplastMoveCol:number=-1;/** 点击回调 */onCellClick:(row:number,col:number)=>void=()=>{};privatecontext:CanvasRenderingContext2D=newCanvasRenderingContext2D(newRenderingContextSettings(true));privatecanvasSize:number=320;privateisReady:boolean=false;build(){Canvas(this.context).width('100%').aspectRatio(1).onReady(()=>{this.isReady=true;this.drawBoard();}).onClick((event:ClickEvent)=>{this.handleClick(event);}).onAreaChange((_oldValue:Area,newValue:Area)=>{constw=newValue.width;if(typeofw==='number'){this.canvasSize=w;if(this.isReady){this.drawBoard();}}})}/** 数据变化时重绘 */onDataChange():void{if(this.isReady){this.drawBoard();}}/** 处理点击事件 */privatehandleClick(event:ClickEvent):void{constcellSize=this.canvasSize/BOARD_SIZE;constx=event.x;consty=event.y;constcol=Math.round((x-cellSize/2)/cellSize);constrow=Math.round((y-cellSize/2)/cellSize);if(row>=0&&row<BOARD_SIZE&&col>=0&&col<BOARD_SIZE){this.onCellClick(row,col);}}/** 绘制棋盘 */privatedrawBoard():void{constctx=this.context;constsize=this.canvasSize;if(size<=0)return;constcellSize=size/BOARD_SIZE;consthalfCell=cellSize/2;// 清空画布ctx.clearRect(0,0,size,size);// 绘制背景ctx.fillStyle=BOARD_BG_COLOR;ctx.fillRect(0,0,size,size);// 绘制网格线ctx.strokeStyle=LINE_COLOR;ctx.lineWidth=1;for(leti=0;i<BOARD_SIZE;i++){constpos=halfCell+i*cellSize;// 水平线ctx.beginPath();ctx.moveTo(halfCell,pos);ctx.lineTo(size-halfCell,pos);ctx.stroke();// 垂直线ctx.beginPath();ctx.moveTo(pos,halfCell);ctx.lineTo(pos,size-halfCell);ctx.stroke();}// 绘制星位conststars:number[][]=[[3,3],[3,11],[7,7],[11,3],[11,11]];ctx.fillStyle=STAR_COLOR;for(conststarofstars){constsx=halfCell+star[1]*cellSize;constsy=halfCell+star[0]*cellSize;ctx.beginPath();ctx.arc(sx,sy,3,0,Math.PI*2);ctx.fill();}// 绘制棋子for(leti=0;i<BOARD_SIZE;i++){for(letj=0;j<BOARD_SIZE;j++){constidx=i*BOARD_SIZE+j;if(idx<this.boardData.length){constpiece=this.boardData[idx];if(piece!==EMPTY){constpx=halfCell+j*cellSize;constpy=halfCell+i*cellSize;constradius=cellSize*0.42;this.drawPiece(ctx,px,py,radius,piece);}}}}// 绘制最后一手标记if(this.lastMoveRow>=0&&this.lastMoveCol>=0){constpx=halfCell+this.lastMoveCol*cellSize;constpy=halfCell+this.lastMoveRow*cellSize;ctx.strokeStyle=LAST_MOVE_COLOR;ctx.lineWidth=2;ctx.beginPath();ctx.arc(px,py,cellSize*0.16,0,Math.PI*2);ctx.stroke();}}/** 绘制单个棋子(带渐变效果) */privatedrawPiece(ctx:CanvasRenderingContext2D,x:number,y:number,radius:number,player:number):void{if(player===BLACK){constgradient=ctx.createRadialGradient(x-radius*0.3,y-radius*0.3,radius*0.1,x,y,radius);gradient.addColorStop(0,'#666666');gradient.addColorStop(0.5,'#2A2A2A');gradient.addColorStop(1,'#111111');ctx.fillStyle=gradient;}else{constgradient=ctx.createRadialGradient(x-radius*0.3,y-radius*0.3,radius*0.1,x,y,radius);gradient.addColorStop(0,'#FFFFFF');gradient.addColorStop(0.7,'#F0F0F0');gradient.addColorStop(1,'#C8C8C8');ctx.fillStyle=gradient;}ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.fill();// 棋子边框ctx.strokeStyle=player===BLACK?'#000000':'#999999';ctx.lineWidth=0.5;ctx.stroke();}}

drawPiece方法

privatedrawPiece(ctx:CanvasRenderingContext2D,x:number,y:number,radius:number,player:number):void{if(player===BLACK){constgradient=ctx.createRadialGradient(x-radius*0.3,y-radius*0.3,radius*0.1,// 内圆:偏左上的亮点x,y,radius// 外圆:棋子边缘);gradient.addColorStop(0,'#666666');// 中心:浅灰(高光)gradient.addColorStop(0.5,'#2A2A2A');// 中间:深灰gradient.addColorStop(1,'#111111');// 边缘:近黑ctx.fillStyle=gradient;}else{constgradient=ctx.createRadialGradient(x-radius*0.3,y-radius*0.3,radius*0.1,x,y,radius);gradient.addColorStop(0,'#FFFFFF');// 中心:纯白(高光)gradient.addColorStop(0.7,'#F0F0F0');// 中间:浅灰gradient.addColorStop(1,'#C8C8C8');// 边缘:灰ctx.fillStyle=gradient;}ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.fill();// 棋子边框ctx.strokeStyle=player===BLACK?'#000000':'#999999';ctx.lineWidth=0.5;ctx.stroke();}

createRadialGradient参数解析

ctx.createRadialGradient(x0,y0,r0,x1,y1,r1)
参数含义本项目的值
x0, y0内圆中心(高光位置)x - radius0.3, y - radius0.3
r0内圆半径radius * 0.1
x1, y1外圆中心(棋子中心)x, y
r1外圆半径radius

高光位置偏移:内圆中心偏左上(x - r*0.3, y - r*0.3),模拟从左上方照射的光源。这是经典的球体光照模型。

渐变效果图解

黑棋渐变: 白棋渐变: ◐ ← 高光(#666) ◑ ← 高光(#FFF) ◑◑ ◐◑ ◑◉◑ ← 中间(#2A2A) ◑◉◑ ← 中间(#F0F0) ◑◑ ◐◑ ◑ ← 边缘(#111) ◐ ← 边缘(#C8C8)

addColorStop:颜色停靠点

// 黑棋gradient.addColorStop(0,'#666666');// 0%:高光区gradient.addColorStop(0.5,'#2A2A2A');// 50%:过渡区gradient.addColorStop(1,'#111111');// 100%:边缘区// 白棋gradient.addColorStop(0,'#FFFFFF');// 0%:高光区gradient.addColorStop(0.7,'#F0F0F0');// 70%:过渡区gradient.addColorStop(1,'#C8C8C8');// 100%:边缘区

黑棋vs白棋的渐变差异

  • 黑棋:高光在50%处就过渡到深色——因为黑色物体反射少
  • 白棋:高光持续到70%——因为白色物体反射多

这种细微差异让黑白棋子有不同质感的视觉表现。

棋子边框

ctx.strokeStyle=player===BLACK?'#000000':'#999999';ctx.lineWidth=0.5;ctx.stroke();
  • 黑棋边框:纯黑#000000
  • 白棋边框:浅灰#999999
  • 线宽0.5像素——细线不会喧宾夺主

边框的作用是让棋子在棋盘背景上更加分明,特别是白棋在浅色背景上容易"消失"。

棋子半径计算

constradius=cellSize*0.42;

棋子半径是格子尺寸的42%——留出16%的间隙,让棋子之间不会紧贴。

cellSize ├────────┤ ◯ ◯ ← 棋子直径 = cellSize * 0.84,间隙 = cellSize * 0.16

最后一手标记

if(this.lastMoveRow>=0&&this.lastMoveCol>=0){constpx=halfCell+this.lastMoveCol*cellSize;constpy=halfCell+this.lastMoveRow*cellSize;ctx.strokeStyle=LAST_MOVE_COLOR;// 红色 #E63946ctx.lineWidth=2;ctx.beginPath();ctx.arc(px,py,cellSize*0.16,0,Math.PI*2);ctx.stroke();}

设计思路

  • 红色小圆圈标记最后一手
  • 半径cellSize * 0.16——比棋子小,不会遮挡
  • stroke()而非fill()——空心圈不遮挡棋子

这个标记对用户体验很重要——玩家一眼就能看到对方刚下的位置。

总结

棋子绘制展示了Canvas 2D的进阶技巧:

  1. createRadialGradient:径向渐变实现球体光照效果
  2. 高光偏移:内圆偏左上模拟光源
  3. 颜色停靠点:不同位置设置不同颜色实现质感
  4. 细边框:增加棋子与背景的对比度
  5. 最后一手标记:红色空心圈提供视觉反馈

这些技巧让简单的黑白棋子拥有了立体感和质感,大大提升了游戏的视觉品质。