棋盘是游戏的"脸面"——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();}}组件声明
@Componentexportstruct ChessBoardView{@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();}}})}}组件接口设计
ChessBoardView是一个纯表现组件——只负责显示和事件传递:
输入(@Prop)
@Prop@Watch('onDataChange')boardData:number[]=[];// 棋盘数据@ProplastMoveRow:number=-1;// 最后一手行@ProplastMoveCol:number=-1;// 最后一手列输出(回调)
onCellClick:(row:number,col:number)=>void=()=>{};内部状态
privatecontext:CanvasRenderingContext2D;// Canvas绘制上下文privatecanvasSize:number=320;// 实际绘制尺寸privateisReady:boolean=false;// Canvas是否就绪Canvas初始化
privatecontext:CanvasRenderingContext2D=newCanvasRenderingContext2D(newRenderingContextSettings(true));RenderingContextSettings(true)中的true表示开启抗锯齿,让棋子和线条更加平滑。
Canvas组件的生命周期
组件创建 ↓ Canvas挂载到组件树 ↓ onReady触发 → isReady = true → drawBoard() ↓ onAreaChange触发 → canvasSize更新 → drawBoard() ↓ @Prop boardData变化 → @Watch onDataChange → drawBoard()onReady
.onReady(()=>{this.isReady=true;this.drawBoard();})Canvas组件创建后需要等待onReady回调才能开始绘制。isReady标志位防止在Canvas未就绪时调用绘制方法。
onAreaChange
.onAreaChange((_oldValue:Area,newValue:Area)=>{constw=newValue.width;if(typeofw==='number'){this.canvasSize=w;if(this.isReady){this.drawBoard();}}})当组件尺寸变化时(如屏幕旋转、窗口调整),重新获取尺寸并重绘。typeof w === 'number'检查是因为Area的width可能是number或string类型。
@Watch数据驱动重绘
@Prop@Watch('onDataChange')boardData:number[]=[];onDataChange():void{if(this.isReady){this.drawBoard();}}当父组件传入的boardData发生变化时,@Watch自动触发onDataChange,进而调用drawBoard重绘。
注意:ArkTS中@Prop数组变化需要创建新引用才能触发@Watch。父组件中的做法:
privaterefreshBoardData():void{this.boardData=this.engine.toFlatArray();// 创建新数组}toFlatArray()每次返回新数组,确保引用变化触发@Watch。
drawBoard绘制流程
privatedrawBoard():void{constctx=this.context;constsize=this.canvasSize;if(size<=0)return;constcellSize=size/BOARD_SIZE;consthalfCell=cellSize/2;// 1. 清空画布ctx.clearRect(0,0,size,size);// 2. 绘制背景ctx.fillStyle=BOARD_BG_COLOR;ctx.fillRect(0,0,size,size);// 3. 绘制网格线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();}// 4. 绘制星位// 5. 绘制棋子// 6. 绘制最后一手标记}颜色常量
constBOARD_BG_COLOR:string='#D4A868';// 棋盘木色constLINE_COLOR:string='#5C4033';// 网格线深棕constSTAR_COLOR:string='#3B2F2F';// 星位黑色constLAST_MOVE_COLOR:string='#E63946';// 最后一手红色这些颜色定义在组件文件顶部,没有放入资源文件——因为它们是组件内部实现细节,不需要国际化或主题适配。
组件复用
ChessBoardView被两个页面复用:
// TwoPlayerPageChessBoardView({boardData:this.boardData,lastMoveRow:this.lastMoveRow,lastMoveCol:this.lastMoveCol,onCellClick:(row:number,col:number)=>{this.onCellClick(row,col);}})// AIBattlePage — 完全相同的用法ChessBoardView({boardData:this.boardData,lastMoveRow:this.lastMoveRow,lastMoveCol:this.lastMoveCol,onCellClick:(row:number,col:number)=>{this.onCellClick(row,col);}})组件不关心数据来源(双人对战还是AI对战),只负责渲染和事件传递。
总结
ChessBoardView的设计展示了ArkUI组件的最佳实践:
- @Prop + @Watch:数据驱动重绘
- Canvas生命周期:onReady后才绘制
- onAreaChange:响应式适配屏幕尺寸
- 纯表现组件:只负责显示和回调,不含业务逻辑
- 可复用:被两个页面无缝复用