Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { createFaceState, type FaceState, toPiuColorNumber, toPiuColorString } from 'face-state'
import {
Container,
Content,
type Container as PiuContainer,
type Content as PiuContent,
type Skin as PiuSkin,
type Style as PiuStyle,
type Text as PiuText,
type Texture as PiuTexture,
Skin,
Style,
Text,
Texture,
} from 'piu/MC'
let bubbleTexture: PiuTexture | null = null
let bubbleSkinCache: Map<string, PiuSkin> | null = null
let textStyleCache: Map<string, PiuStyle> | null = null
const defaultOptions = {
left: 16,
right: 16,
bottom: 12,
minHeight: 44,
paddingX: 18,
paddingY: 10,
text: 'Hello from Stack-chan',
font: 'k8x12-12',
}
type SpeechBalloonTail = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
type BalloonOptions = {
name?: string
left?: number
right?: number
top?: number
bottom?: number
width?: number
height?: number
minHeight?: number
padding?: number
paddingX?: number
paddingY?: number
text?: string
font?: string
tail?: SpeechBalloonTail
// Legacy options kept for compatibility.
space?: number
radius?: number
speed?: number
}
type WithSkin = PiuContent & { skin?: unknown }
type ResizableContainer = PiuContainer & {
coordinates?: {
left?: number
right?: number
top?: number
bottom?: number
width?: number
height?: number
}
}
type BalloonContainerOptions = {
name?: string
left?: number
right?: number
top?: number
bottom?: number
width?: number
height?: number
clip: boolean
Behavior: typeof Behavior
}
function resolveDimension(value: number | undefined, fallback: number): number {
if (value === undefined) return fallback
return value
}
function getTextStyle(font: string, color: number | string): PiuStyle {
if (!textStyleCache) textStyleCache = new Map()
const styleColor = typeof color === 'number' ? toPiuColorString(color) : color
const key = `${font}:${styleColor}`
const cached = textStyleCache.get(key)
if (cached) return cached
const style = new Style({ font, color: styleColor, horizontal: 'left' })
textStyleCache.set(key, style)
return style
}
type BubbleTextureRegion = {
x: number
y: number
height: number
top: number
}
const tailTextureRegions: Record<SpeechBalloonTail, BubbleTextureRegion> = {
'bottom-left': { x: 0, y: 0, height: 25, top: 12 },
'bottom-right': { x: 204, y: 0, height: 25, top: 12 },
'top-left': { x: 0, y: 25, height: 28, top: 15 },
'top-right': { x: 204, y: 25, height: 28, top: 15 },
}
function resolveTail(options: BalloonOptions): SpeechBalloonTail {
if (options.tail !== undefined) return options.tail
return options.top !== undefined ? 'bottom-left' : 'top-left'
}
function getBubbleSkin(color: number, tail: SpeechBalloonTail): PiuSkin {
if (!bubbleSkinCache) bubbleSkinCache = new Map()
const key = `${color}:${tail}`
const cached = bubbleSkinCache.get(key)
if (cached) return cached
if (!bubbleTexture) bubbleTexture = new Texture('bubble.png')
const skinColor = toPiuColorString(color)
const textureRegion = tailTextureRegions[tail]
const skin = new Skin({
texture: bubbleTexture,
color: [skinColor],
x: textureRegion.x,
y: textureRegion.y,
width: 204,
height: textureRegion.height,
left: 24,
right: 24,
top: textureRegion.top,
bottom: 12,
})
bubbleSkinCache.set(key, skin)
return skin
}
export const SpeechBalloon = Container.template((opts: BalloonOptions = {}) => {
const o = { ...defaultOptions, ...opts }
const paddingX = resolveDimension(opts.paddingX ?? opts.padding, defaultOptions.paddingX)
const paddingY = resolveDimension(opts.paddingY ?? opts.padding, defaultOptions.paddingY)
const minHeight = resolveDimension(opts.minHeight, defaultOptions.minHeight)
const fixedHeight = opts.height
const tail = resolveTail(opts)
const style = getTextStyle(o.font, '#000')
const lineHeight = Math.max(1, style.measure('Mg').height ?? 0)
let background: WithSkin | null = null
let bodyText: PiuText | null = null
let currentText = o.text ?? ''
let currentPrimary: number | null = null
let currentSecondary: number | null = null
let currentFace: FaceState = createFaceState()
let layoutWidth = 0
const left = opts.left ?? defaultOptions.left
const right = opts.right ?? defaultOptions.right
const top = opts.top
const bottom = opts.bottom ?? defaultOptions.bottom
const width = opts.width
const resolveWidth = (self: PiuContainer) => {
const w = self.width
if (w > 0) return w
if (opts.width !== undefined) return opts.width
return 240
}
const countWrappedLines = (text: string, maxWidth: number) => {
if (!text || text.length === 0) return 1
const widthLimit = Math.max(1, maxWidth)
let lines = 1
let line = ''
for (const ch of text) {
if (ch === '\n') {
lines += 1
line = ''
continue
}
const candidate = line + ch
if ((style.measure(candidate).width ?? 0) <= widthLimit) {
line = candidate
continue
}
lines += 1
line = ch
}
return lines
}
const resolveHeight = (self: PiuContainer, text: string) => {
if (fixedHeight !== undefined) return fixedHeight
const availableWidth = resolveWidth(self) - paddingX * 2
const lines = countWrappedLines(text ?? '', availableWidth)
return Math.max(minHeight, paddingY * 2 + lineHeight * lines)
}
const applyHeight = (self: ResizableContainer, height: number) => {
const currentHeight = self.coordinates?.height ?? self.height
if (currentHeight === height) return
self.coordinates = { ...(self.coordinates ?? {}), height }
}
const containerOptions: BalloonContainerOptions = {
name: opts.name ?? 'SpeechBalloon',
clip: true,
Behavior: class extends Behavior {
ensureParts(self: PiuContainer) {
const w = resolveWidth(self)
if (background && bodyText && layoutWidth === w) return
if (background || bodyText) {
self.empty()
background = null
bodyText = null
}
currentPrimary = null
currentSecondary = null
layoutWidth = w
background = new Content(null, { left: 0, right: 0, top: 0, bottom: 0 }) as WithSkin
bodyText = new Text(null, {
left: paddingX,
right: paddingX,
top: paddingY,
string: '',
style,
})
self.add(background)
self.add(bodyText)
this.updatePalette(currentFace)
this.updateText(self, currentText)
}
updatePalette(face: FaceState) {
currentFace = face
if (!background || !bodyText) return
const primary = toPiuColorNumber(face.theme.primary)
const secondary = toPiuColorNumber(face.theme.secondary)
if (primary === currentPrimary && secondary === currentSecondary) return
currentPrimary = primary
currentSecondary = secondary
const bubbleColor = primary
const textColor = secondary === bubbleColor ? 0x000000 : secondary
background.skin = getBubbleSkin(bubbleColor, tail)
bodyText.style = getTextStyle(o.font, textColor)
}
updateText(self: PiuContainer, text: string) {
if (!bodyText) return
bodyText.string = text ?? ''
if (fixedHeight === undefined) {
const nextHeight = resolveHeight(self, text ?? '')
applyHeight(self as ResizableContainer, nextHeight)
}
}
setText(self: PiuContainer, text: string) {
currentText = text ?? ''
this.ensureParts(self)
this.updateText(self, currentText)
}
clear(self: PiuContainer) {
this.setText(self, '')
}
onDisplaying(content: PiuContainer) {
this.ensureParts(content)
this.updatePalette(currentFace)
}
onFaceState(content: PiuContainer, face: FaceState) {
this.ensureParts(content)
this.updatePalette(face)
}
},
}
if (width !== undefined) {
containerOptions.width = width
if (opts.left !== undefined) {
containerOptions.left = left
} else if (opts.right !== undefined) {
containerOptions.right = right
} else {
containerOptions.left = left
}
} else {
containerOptions.left = left
containerOptions.right = right
}
if (fixedHeight !== undefined) {
containerOptions.height = fixedHeight
}
if (top !== undefined) {
containerOptions.top = top
} else {
containerOptions.bottom = bottom
}
return containerOptions
})
export type { BalloonOptions as SpeechBalloonOptions, SpeechBalloonTail }
4bc857bacThis file is pinned to the selected release. Download the original to open it in your design tool.