网页游戏中的文本渲染
文本无处不在——分数、对话、菜单、伤害数字。这篇教程涵盖不同方案以及何时用哪种。
1)Canvas 文本基础
最简单的方式:
js
ctx.font = '24px Arial'
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Score: 1000', canvas.width / 2, 50)2)带描边的文本
js
function drawTextWithOutline(ctx, text, x, y, fillColor, outlineColor, outlineWidth = 2) {
ctx.font = '24px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.strokeStyle = outlineColor
ctx.lineWidth = outlineWidth
ctx.strokeText(text, x, y)
ctx.fillStyle = fillColor
ctx.fillText(text, x, y)
}
drawTextWithOutline(ctx, 'GAME OVER', 400, 300, '#fff', '#000', 3)3)带阴影的文本
js
function drawTextWithShadow(ctx, text, x, y, color, shadowOffset = 2) {
ctx.font = '32px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
// 阴影
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillText(text, x + shadowOffset, y + shadowOffset)
// 主文本
ctx.fillStyle = color
ctx.fillText(text, x, y)
}4)位图字体渲染
适合像素风游戏或希望跨平台外观一致的场景:
js
class BitmapFont {
constructor(image, charWidth, charHeight, chars) {
this.image = image
this.charWidth = charWidth
this.charHeight = charHeight
this.chars = chars
}
draw(ctx, text, x, y, scale = 1) {
const w = this.charWidth * scale
const h = this.charHeight * scale
for (let i = 0; i < text.length; i++) {
const charIndex = this.chars.indexOf(text[i].toUpperCase())
if (charIndex === -1) continue
const sx = (charIndex % 16) * this.charWidth
const sy = Math.floor(charIndex / 16) * this.charHeight
ctx.drawImage(
this.image,
sx, sy, this.charWidth, this.charHeight,
Math.round(x + i * w), Math.round(y), w, h
)
}
}
measureText(text, scale = 1) {
return text.length * this.charWidth * scale
}
}
// 用法
const font = new BitmapFont(fontImage, 8, 8,
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?-+:; '
)
font.draw(ctx, 'SCORE: 1000', 10, 10, 2)5)预渲染文本以提升性能
对静态文本,渲染一次反复使用:
js
function createTextImage(text, font, color) {
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')
tempCtx.font = font
const metrics = tempCtx.measureText(text)
tempCanvas.width = Math.ceil(metrics.width)
tempCanvas.height = parseInt(font) * 1.5
tempCtx.font = font
tempCtx.fillStyle = color
tempCtx.textBaseline = 'top'
tempCtx.fillText(text, 0, 0)
return tempCanvas
}
// 只创建一次
const titleImage = createTextImage('MY GAME', '48px Impact', '#fff')
// 多次绘制(快)
ctx.drawImage(titleImage, x, y)6)打字机效果
js
class TypewriterText {
constructor(text, x, y, charsPerSecond = 20) {
this.fullText = text
this.x = x
this.y = y
this.speed = 1 / charsPerSecond
this.timer = 0
this.charIndex = 0
}
update(dt) {
this.timer += dt
while (this.timer >= this.speed && this.charIndex < this.fullText.length) {
this.charIndex++
this.timer -= this.speed
}
}
draw(ctx) {
ctx.font = '18px monospace'
ctx.fillStyle = '#fff'
ctx.fillText(this.fullText.slice(0, this.charIndex), this.x, this.y)
}
isComplete() {
return this.charIndex >= this.fullText.length
}
skip() {
this.charIndex = this.fullText.length
}
}7)自动换行文本
js
function wrapText(ctx, text, maxWidth) {
const words = text.split(' ')
const lines = []
let currentLine = ''
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word
const metrics = ctx.measureText(testLine)
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = word
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
const lines = wrapText(ctx, text, maxWidth)
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], x, y + i * lineHeight)
}
}8)对话框系统
js
class DialogueBox {
constructor(x, y, width, height) {
this.x = x
this.y = y
this.width = width
this.height = height
this.padding = 16
this.lineHeight = 24
this.text = ''
this.displayedChars = 0
this.charsPerSecond = 30
this.timer = 0
}
show(text) {
this.text = text
this.displayedChars = 0
this.timer = 0
}
update(dt) {
if (this.displayedChars < this.text.length) {
this.timer += dt
const charsToAdd = Math.floor(this.timer * this.charsPerSecond)
this.displayedChars = Math.min(this.displayedChars + charsToAdd, this.text.length)
this.timer = this.timer % (1 / this.charsPerSecond)
}
}
skip() {
this.displayedChars = this.text.length
}
isComplete() {
return this.displayedChars >= this.text.length
}
draw(ctx) {
// 背景框
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
ctx.fillRect(this.x, this.y, this.width, this.height)
// 边框
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.strokeRect(this.x, this.y, this.width, this.height)
// 文本
ctx.font = '18px sans-serif'
ctx.fillStyle = '#fff'
ctx.textBaseline = 'top'
const displayText = this.text.slice(0, this.displayedChars)
const maxWidth = this.width - this.padding * 2
const lines = wrapText(ctx, displayText, maxWidth)
for (let i = 0; i < lines.length; i++) {
ctx.fillText(
lines[i],
this.x + this.padding,
this.y + this.padding + i * this.lineHeight
)
}
// 继续指示
if (this.isComplete()) {
ctx.fillText('▼', this.x + this.width - 24, this.y + this.height - 24)
}
}
}9)浮动伤害数字
js
class FloatingText {
constructor(text, x, y, color = '#fff') {
this.text = text
this.x = x
this.y = y
this.color = color
this.vy = -60
this.lifetime = 1
this.age = 0
}
update(dt) {
this.y += this.vy * dt
this.vy *= 0.95
this.age += dt
}
draw(ctx) {
const alpha = 1 - (this.age / this.lifetime)
ctx.font = 'bold 20px sans-serif'
ctx.textAlign = 'center'
ctx.fillStyle = this.color
ctx.globalAlpha = alpha
ctx.fillText(this.text, Math.round(this.x), Math.round(this.y))
ctx.globalAlpha = 1
}
isExpired() {
return this.age >= this.lifetime
}
}
// 管理器
const floatingTexts = []
function spawnDamageNumber(damage, x, y) {
const color = damage > 50 ? '#ff4444' : '#ffff44'
floatingTexts.push(new FloatingText(damage.toString(), x, y, color))
}
function updateFloatingTexts(dt) {
for (let i = floatingTexts.length - 1; i >= 0; i--) {
floatingTexts[i].update(dt)
if (floatingTexts[i].isExpired()) {
floatingTexts.splice(i, 1)
}
}
}10)WebGL 文本(SDF 字体)
对 WebGL 游戏,Signed Distance Field 字体能干净地缩放:
js
// SDF 片元 shader
const sdfFragmentShader = `
precision mediump float;
uniform sampler2D u_texture;
uniform vec4 u_color;
varying vec2 v_texCoord;
void main() {
float distance = texture2D(u_texture, v_texCoord).a;
float alpha = smoothstep(0.4, 0.6, distance);
gl_FragColor = vec4(u_color.rgb, u_color.a * alpha);
}
`
// 像 msdf-bmfont-xml 这样的库可以生成 SDF 字体图集对大多数 2D Canvas 游戏,位图字体或预渲染 Canvas 文本就够用了。
相关阅读
- Canvas 2D 游戏循环
- 像素艺术渲染
- WebGL 基础
- 响应式游戏画布 —— 按 canvas 分辨率和 DPI 缩放文本
- 去哪里找免费游戏素材 —— 字体和 UI 资源来源