kaplay-plugin-text
Version:
KAPLAY plugin for styled text
1 lines • 12.7 kB
Source Map (JSON)
{"version":3,"file":"plugin.cjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["import type {\n Anchor,\n Color,\n Comp,\n KAPLAYCtx,\n Outline,\n Rect,\n SpriteData,\n TextAlign,\n Uniform,\n} from 'kaplay';\n\n/** Drop shadow style for styled text. */\nexport interface StyledTextShadow {\n color: Color;\n offsetX: number;\n offsetY: number;\n blur: number;\n}\n\n/** Gradient style for styled text. */\nexport interface StyledTextGradient {\n from: Color;\n to: Color;\n direction: 'horizontal' | 'vertical';\n}\n\n/** Options for the {@link styledText} component. */\nexport interface StyledTextCompOpt {\n size?: number;\n font?: string;\n fill?: Color;\n outline?: Outline;\n shadow?: StyledTextShadow;\n gradient?: StyledTextGradient;\n align?: TextAlign;\n width?: number;\n lineSpacing?: number;\n}\n\n/** The {@link styledText} component. */\nexport interface StyledTextComp extends Comp {\n id: 'styledText';\n text: string;\n width: number;\n height: number;\n renderArea(): Rect;\n setStyle(opt: Partial<StyledTextCompOpt>): void;\n}\n\ndeclare module 'kaplay' {\n interface KAPLAYCtx {\n styledText: (txt?: string, opt?: StyledTextCompOpt) => StyledTextComp;\n }\n}\n\n/** Convert a KAPLAY {@link Color} to a CSS string. */\nfunction colorToCss(color: Color): string {\n return `rgb(${String(color.r)}, ${String(color.g)}, ${String(color.b)})`;\n}\n\n/** Resolve the font from options, KAPLAY init, or default. */\nfunction resolveFont(k: KAPLAYCtx, font?: string): string {\n if (font) return font;\n const initFont = (k as { _k?: { globalOpt?: { font?: string } } })._k\n ?.globalOpt?.font;\n if (initFont) return initFont;\n return 'monospace';\n}\n\n/** Styled text plugin for KAPLAY. */\nexport function styledTextPlugin(k: KAPLAYCtx) {\n const styledText = (\n txt = '',\n opt: StyledTextCompOpt = {},\n ): StyledTextComp => {\n let currentText = txt;\n let currentOpt: StyledTextCompOpt = { ...opt };\n let canvas: HTMLCanvasElement | null = null;\n let ctx: CanvasRenderingContext2D | null = null;\n let spriteData: SpriteData | null = null;\n let textWidth = 0;\n let textHeight = 0;\n let lastCanvasWidth = 0;\n let lastCanvasHeight = 0;\n let currentPadding = 0;\n\n const getFont = (): string => {\n return resolveFont(k, currentOpt.font);\n };\n\n const render = (): void => {\n if (!canvas || !ctx) return;\n\n const font = getFont();\n const fontSize = currentOpt.size ?? 48;\n const textAlign = currentOpt.align ?? 'left';\n const ls = currentOpt.lineSpacing ?? 0;\n const outlineWidth = currentOpt.outline?.width ?? 0;\n const shadowOffsetX = currentOpt.shadow?.offsetX ?? 0;\n const shadowOffsetY = currentOpt.shadow?.offsetY ?? 0;\n const shadowBlur = currentOpt.shadow?.blur ?? 0;\n const padding =\n Math.max(outlineWidth, shadowBlur) +\n Math.abs(shadowOffsetX) +\n Math.abs(shadowOffsetY) +\n 2;\n currentPadding = padding;\n\n ctx.font = `${String(fontSize)}px ${font}`;\n ctx.textAlign = textAlign;\n ctx.textBaseline = 'top';\n\n // Handle wrapping\n const wrapW = currentOpt.width;\n const lines = wrapText(ctx, currentText, wrapW);\n const lineHeight = fontSize + ls;\n\n // Measure widest line\n let maxLineWidth = 0;\n for (const line of lines) {\n const metrics = ctx.measureText(line);\n maxLineWidth = Math.max(maxLineWidth, metrics.width);\n }\n\n const canvasWidth = Math.ceil(maxLineWidth + padding * 2);\n const canvasHeight = Math.ceil(lines.length * lineHeight + padding * 2);\n\n // Resize canvas if dimensions changed\n if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {\n canvas.width = canvasWidth;\n canvas.height = canvasHeight;\n // Re-set context properties after resize (they reset)\n ctx.font = `${String(fontSize)}px ${font}`;\n ctx.textAlign = textAlign;\n ctx.textBaseline = 'top';\n }\n\n // Clear canvas\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n // Draw each line\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n const x = padding;\n const y = padding + i * lineHeight;\n\n // Apply shadow\n if (currentOpt.shadow) {\n ctx.shadowColor = colorToCss(currentOpt.shadow.color);\n ctx.shadowOffsetX = currentOpt.shadow.offsetX;\n ctx.shadowOffsetY = currentOpt.shadow.offsetY;\n ctx.shadowBlur = currentOpt.shadow.blur;\n }\n\n // Draw outline (stroke)\n if (currentOpt.outline) {\n ctx.strokeStyle = currentOpt.outline.color\n ? colorToCss(currentOpt.outline.color)\n : 'black';\n ctx.lineWidth = currentOpt.outline.width ?? 1;\n ctx.lineJoin = 'round';\n ctx.strokeText(line, x, y);\n\n // Reset shadow before fill to avoid double shadow\n ctx.shadowColor = 'transparent';\n ctx.shadowOffsetX = 0;\n ctx.shadowOffsetY = 0;\n ctx.shadowBlur = 0;\n }\n\n // Apply fill or gradient\n if (currentOpt.gradient) {\n const grad =\n currentOpt.gradient.direction === 'vertical'\n ? ctx.createLinearGradient(0, padding, 0, canvasHeight - padding)\n : ctx.createLinearGradient(padding, 0, canvasWidth - padding, 0);\n grad.addColorStop(0, colorToCss(currentOpt.gradient.from));\n grad.addColorStop(1, colorToCss(currentOpt.gradient.to));\n ctx.fillStyle = grad;\n } else if (currentOpt.fill) {\n ctx.fillStyle = colorToCss(currentOpt.fill);\n } else {\n ctx.fillStyle = 'white';\n }\n\n ctx.fillText(line, x, y);\n }\n\n textWidth = canvasWidth;\n textHeight = canvasHeight;\n\n // Update or create sprite data\n if (\n spriteData &&\n lastCanvasWidth === canvasWidth &&\n lastCanvasHeight === canvasHeight\n ) {\n const frame = spriteData.frames[0];\n const texX = Math.round(frame.x * spriteData.tex.width);\n const texY = Math.round(frame.y * spriteData.tex.height);\n spriteData.tex.update(canvas, texX, texY);\n } else {\n if (spriteData) {\n spriteData.tex.free();\n }\n spriteData = k.SpriteData.fromImage(canvas, { singular: true });\n lastCanvasWidth = canvasWidth;\n lastCanvasHeight = canvasHeight;\n }\n };\n\n const wrapText = (\n c: CanvasRenderingContext2D,\n text: string,\n maxWidth?: number,\n ): string[] => {\n if (!maxWidth) return text.split('\\n');\n const allLines: string[] = [];\n for (const paragraph of text.split('\\n')) {\n const words = paragraph.split(' ');\n let currentLine = '';\n for (const word of words) {\n const testLine = currentLine ? `${currentLine} ${word}` : word;\n const metrics = c.measureText(testLine);\n if (metrics.width > maxWidth && currentLine) {\n allLines.push(currentLine);\n currentLine = word;\n } else {\n currentLine = testLine;\n }\n }\n allLines.push(currentLine);\n }\n return allLines;\n };\n\n const comp: StyledTextComp = {\n id: 'styledText',\n get text() {\n return currentText;\n },\n set text(value: string) {\n currentText = value;\n render();\n },\n get width() {\n return textWidth;\n },\n get height() {\n return textHeight;\n },\n renderArea(): Rect {\n return new k.Rect(k.vec2(0), textWidth, textHeight);\n },\n setStyle(opt: Partial<StyledTextCompOpt>): void {\n currentOpt = { ...currentOpt, ...opt };\n render();\n },\n add() {\n canvas = document.createElement('canvas');\n ctx = canvas.getContext('2d');\n if (ctx) {\n render();\n }\n },\n draw() {\n if (spriteData) {\n const obj = this as unknown as {\n anchor?: Anchor;\n color?: Color;\n opacity?: number;\n outline?: Outline;\n shader?: string;\n uniform?: Uniform;\n flipX?: boolean;\n flipY?: boolean;\n };\n k.drawSprite({\n sprite: spriteData,\n pos: k.vec2(-currentPadding, -currentPadding),\n anchor: obj.anchor,\n color: obj.color,\n opacity: obj.opacity,\n outline: obj.outline,\n shader: obj.shader,\n uniform: obj.uniform,\n flipX: obj.flipX,\n flipY: obj.flipY,\n });\n }\n },\n destroy() {\n if (spriteData) {\n spriteData.tex.free();\n spriteData = null;\n }\n canvas = null;\n ctx = null;\n },\n };\n\n return comp;\n };\n\n return { styledText };\n}\n"],"mappings":"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"}