Skip to content

矩形、九宫格与裁剪 ​

做血条、面板、弹窗、滚动列表常用的三样东西。

纯色矩形:ColorRect ​

ts
import { CanvasLayer, ColorRect, Scene, v } from 'sapling2d'

const BAR_WIDTH = 300

/** 血条:底色 + 填充,都是 ColorRect(原点在左上角,改 scale.x 就从左往右缩)。 */
class HealthBar extends ColorRect {
  readonly fill: ColorRect

  constructor() {
    super({ position: v(40, 60), size: v(BAR_WIDTH, 24), color: 0x302020 })
    this.fill = this.add(new ColorRect({ size: v(BAR_WIDTH, 24), color: 0x40d040 }))
  }

  set ratio(value: number) {
    this.fill.scale = v(Math.max(0, Math.min(1, value)), 1)
    this.fill.color = value > 0.3 ? 0x40d040 : 0xe04040 // 血少了变红
  }
}

export class Battle extends Scene {
  hp = 10
  bar!: HealthBar
  fade!: ColorRect

  override ready() {
    const hud = this.add(new CanvasLayer())
    this.bar = hud.add(new HealthBar())
    // 转场黑幕:盖住整个可见区域,透明度从 1 补间到 0
    const r = this.tree.viewport.visibleRect
    this.fade = hud.add(new ColorRect({ position: r.position, size: r.size, color: 0x000000, zIndex: 100 }))
    this.fade.createTween().to(this.fade, { alpha: 0 }, 0.5).call(() => this.fade.queueFree())
  }

  hit() {
    this.hp--
    this.bar.ratio = this.hp / 10
  }
}
ts
for (let i = 0; i < 8; i++) g.scene.hit()
expect(g.scene.bar.fill.scale.x).toBeCloseTo(0.2)
expect(g.scene.bar.fill.color).toBe(0xe04040)
g.stepSeconds(0.6)
expect(g.scene.fade.isFreed).toBe(true) // 黑幕淡出后删掉
  • 属性:size、color(0xRRGGBB);透明度用 alpha。color 和 size 都能补间。
  • 原点在左上角(和 Sprite2D 默认居中不同):血条改 scale.x,左端固定、从右边缩短。
  • 没有 hitArea 时,点击区域就是这个矩形(无头模式下也是),可以当简单的按钮底板或遮罩。
  • 和贴图一起合批绘制,几十个血条不会增加绘制调用。圆、圆环这类形状用贴图加 modulate。

当按钮或遮罩用时要设 inputPickable: true

不设的话照常画出来,但点不到、也挡不住下面的点击,而且没有任何提示。

九宫格:NineSliceSprite ​

边框、按钮底板这类要拉伸到不同尺寸的图,用九宫格:四个角不缩放,上下边只横向拉伸,左右边只纵向拉伸,中间两个方向都拉伸。

ts
import { atlas, CanvasLayer, Label, NineSliceSprite, Scene, Signal, v } from 'sapling2d'
// ui.json:TexturePacker 等工具打的界面图集(边框、按钮)
import uiData from './ui.json'

const UI = atlas('ui.png', uiData)

/** 一张升级卡片:木框边框拉成卡片大小,四个角不变形;点一下发出 picked。 */
class Card extends NineSliceSprite {
  readonly picked = new Signal()

  constructor(title: string) {
    // 边框贴图 96×96,四边各 28 像素是不能拉伸的花边
    super({ texture: UI.get('panel_wood'), margins: 28, size: v(560, 180), inputPickable: true }) // 没设 hitArea:点击区域就是这个矩形
    this.add(new Label({ text: title, fontSize: 40, align: 'center', verticalAlign: 'center', position: v(280, 90) }))
    this.clicked.connect(() => this.picked.emit(), this)
  }
}

export class Shop extends Scene {
  static override assets = { ui: UI }
  card!: Card
  picks = 0

  override ready() {
    const hud = this.add(new CanvasLayer())
    this.card = hud.add(new Card('多重箭'))
    this.card.position = v(95, 500) // 原点在左上角
    this.card.picked.connect(() => this.picks++, this)
  }
}
ts
import { expect, it } from 'vitest'
import { createTestGame } from 'sapling2d/testing'

it('nine slice', async () => {
  const g = await createTestGame({ main: Shop })
  g.tap(375, 590) // 卡片中间
  g.tap(375, 700) // 卡片外面
  expect(g.scene.picks).toBe(1)
  expect(g.dump()).toContain('Card (Card) position=(95, 500) texture=ui.png#panel_wood size=(560, 180) margins=28')
})
  • 属性:texture、margins({ left, top, right, bottom },或一个数字表示四边相同,单位是贴图原始尺寸的像素)、size。
  • 原点在左上角,和 ColorRect 一样;size 能补间。没有 hitArea 时点击区域就是 (0, 0)–size,设 inputPickable: true 就能当按钮。
  • 边框贴图做成正方形、四边是花边、中间可以拉伸的样子,margins 就是花边的宽度。size 小于两边边距之和时,四个角按比例缩小。
  • selfModulate 给边框染色(按钮按下时变暗)。图集里裁掉透明边的帧也能用。

裁剪:clipRect ​

Node2D.clipRect 只画矩形里的部分:

ts
import { ColorRect, Label, Node2D, rect, Scene, v } from 'sapling2d'

/** 商店列表:一个 300×400 的窗口只显示窗口里的卡片,滚出窗口的部分既看不见也点不到。 */
export class Shop extends Scene {
  readonly window = new Node2D({ position: v(225, 400), clipRect: rect(0, 0, 300, 400) })
  readonly content = new Node2D() // 滚动时移动它
  picked = ''

  override ready() {
    this.add(this.window)
    this.window.add(new ColorRect({ size: v(300, 400), color: 0x203040 })) // 窗口底色,和裁剪区一样大
    this.window.add(this.content)
    for (let i = 0; i < 8; i++) {
      const card = this.content.add(new ColorRect({ name: `Card${i}`, position: v(20, 20 + i * 110), size: v(260, 90), color: 0x4060a0, inputPickable: true }))
      card.add(new Label({ text: `道具 ${i + 1}`, position: v(16, 28) }))
      card.clicked.connect(() => (this.picked = card.name))
    }
  }

  /** 第 i 张卡片滚到窗口顶部(最简单的滚动:直接改内容的 y)。 */
  scrollTo(i: number) {
    this.content.y = -i * 110
  }
}
ts
g.tap(375, 465) // 窗口顶部的第一张卡片
expect(g.scene.picked).toBe('Card0')
g.scene.scrollTo(4)
g.tap(375, 465) // 同一个位置现在是第五张
expect(g.scene.picked).toBe('Card4')
g.tap(375, 820) // 第八张卡片的下半部分在窗口外:看不见,也点不到
expect(g.scene.picked).toBe('Card4')
expect(g.dump()).toContain('clipRect=(0, 0, 300, 400)')
  • clipRect 是局部坐标的 Rect2,默认 null(不裁剪)。节点自己的内容和整个子树都被裁,嵌套时取交集;随节点的变换走(旋转、缩放、相机都照常)。
  • Rect2 不可变,改范围就赋一个新的。
  • 拾取也遵守裁剪:被裁掉的部分点不到。被裁掉的位置算空白,会触发 pointerPress() 动作。正在拖着的节点滚出裁剪区不会被打断。
  • 子树里的 CanvasLayer 自成一层,不受祖先裁剪。弹窗里要一起裁掉的东西不要放进 CanvasLayer。
  • 用途:滚动列表的窗口、按比例填充的进度条(改填充节点的 clipRect,九宫格不变形)、只露出一部分的图。圆形或贴图形状的遮罩暂不支持。
  • 性能:真机实测 10 个裁剪容器约 0.45 ms。

MIT 许可证