diff --git a/CHANGELOG.md b/CHANGELOG.md index 38435dc73..e42922aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog + + All notable changes to this project will be documented in this file. The format is (mostly) based on @@ -88,6 +90,15 @@ So your change should look like: - Fixed broadphase objects cleared on scene switch including those with `stay()` (#1077) - @imaginarny, @mflerackers +### Added + +- Added a `repack: false` option to `loadSpite()` and a repack parameter to + `loadSpriteAtlas()`, for faster loading if you're packing stuff at build-time + (#1063) - @dragoncoder047 +- Added the ability to check which character a point is hovering over in the + `text()` component, to make text-based menus easier to build by simply styling + text (#1064) - @dragoncoder047 + ## [4000.0.0-alpha.27] - 2026-03-19 ### Added diff --git a/examples/textClick.js b/examples/textClick.js new file mode 100644 index 000000000..4bbd4dc37 --- /dev/null +++ b/examples/textClick.js @@ -0,0 +1,96 @@ +/** + * @file Text Click + * @description How to detect where the user is clicking on text. + * @difficulty 1 + * @tags basics, ui + * @minver 4000.0 + * @category input + */ + +kaplay({ background: "black", font: "happy" }); +loadHappy(); + +let menuText = ""; + +function registerSceneNumber(number, happy) { + scene(`scene${number}`, () => { + add([ + pos(center()), + text( + `you went to scene ${number}!\n\n[small]${happy}\n\n\nclick anywhere to go back[/small]`, + { + align: "center", + size: 70, + styles: { + small: { + scale: 0.6, + stretchInPlace: false, + }, + }, + }, + ), + anchor("center"), + ]); + onMousePress(() => popScene()); + }); + + menuText += + `\n[go=scene${number}][goArrow=scene${number}]> [/goArrow]go to scene ${number}[goArrow=scene${number}] <[/goArrow][/go]`; +} + +registerSceneNumber(1, "good job!"); +registerSceneNumber(2, "you came back for more!"); +registerSceneNumber(3, "three's company!"); +registerSceneNumber(4, "you're still coming?"); +registerSceneNumber(5, "oh, wow, you're\nreally into this..."); +registerSceneNumber(6, "like seriously do you\nlike scenes this much?"); + +scene("menu", () => { + let willGo; + const t = add([ + pos(center()), + text(`click text below\nto go to the scene\n\n${menuText}`, { + align: "center", + size: 50, + styles: { + large: { + scale: 3, + stretchInPlace: false, + }, + go(_, __, goScene) { + if (willGo === goScene) { + return { + color: Color.fromHSL((time() * 2) % 1, 1, 0.5), + }; + } + else { + return {}; + } + }, + goArrow(_, __, goScene) { + return { + opacity: willGo === goScene ? 1 : 0, + }; + }, + }, + }), + anchor("center"), + area(), + ]); + onMouseMove(() => { + t.onHoverUpdate(() => { + const index = t.pointToCharacter(t.fromScreen(mousePos())); + willGo = t.formattedText().chars[index]?.styles.find(pair => + pair[0] === "go" + )?.[1]; + }); + t.onHoverEnd(() => { + willGo = undefined; + }); + t.onClick(() => { + if (willGo !== undefined) pushScene(willGo); + }); + return cancel(); + }); +}); +go("menu"); diff --git a/src/ecs/components/draw/text.ts b/src/ecs/components/draw/text.ts index 65c9e2db3..dadd08763 100644 --- a/src/ecs/components/draw/text.ts +++ b/src/ecs/components/draw/text.ts @@ -1,17 +1,21 @@ import type { BitmapFontData } from "../../../assets/bitmapFont"; import { DEF_TEXT_SIZE } from "../../../constants/general"; import { getRenderProps } from "../../../game/utils"; +import { anchorPt } from "../../../gfx/anchor"; import { drawFormattedText, type FormattedText, } from "../../../gfx/draw/drawFormattedText"; +import { drawRect } from "../../../gfx/draw/drawRect"; import type { CharTransform, CharTransformFunc, TextAlign, } from "../../../gfx/draw/drawText"; import { formatText } from "../../../gfx/formatText"; -import { Rect, vec2 } from "../../../math/math"; +import { rgb } from "../../../math/color"; +import { Rect, testRectPoint, vec2 } from "../../../math/math"; +import { Vec2 } from "../../../math/Vec2"; import { _k } from "../../../shared"; import type { Comp, GameObj } from "../../../types"; import { nextRenderAreaVersion } from "../physics/area"; @@ -86,7 +90,7 @@ export interface TextComp extends Comp { */ textTransform: CharTransform | CharTransformFunc; /** - * Stylesheet for styled chunks, in the syntax of "this is a [style]text[/style] word". + * Stylesheet for styled chunks, using BBCode syntax "this is some [style]different formatting[/style] text". * * @since v2000.2 */ @@ -98,11 +102,18 @@ export interface TextComp extends Comp { _renderAreaVersion: number; /** * The text data object after formatting, that contains the - * renering info as well as the parse data of the formatting tags. + * rendering info as well as the parse data of the formatting tags. */ formattedText(): FormattedText; serialize(): SerializedTextComp; + + /** + * Given a point (in local coordinates), returns the index of the + * rendered character that the point is over, or -1 if none are touched. + * You can then access the style information using `obj.formattedText().chars[index].styles`. + */ + pointToCharacter(point: Vec2): number; } /** @@ -184,9 +195,15 @@ export function text(t: string, opt: TextCompOpt = {}): TextComp { obj.height = theFormattedText.height / (obj.scale?.y || 1); } + function anchorPoint() { + return anchorPt(theFormattedText.opt.anchor ?? "topleft").add(1, 1) + .scale(theFormattedText.width, theFormattedText.height).scale(-0.5); + } + let _shape: Rect | undefined; let _width = opt.width ?? 0; let _height = 0; + const tempRectForPointTest = new Rect(vec2(), 0, 0); const obj: TextComp = { id: "text", @@ -238,6 +255,58 @@ export function text(t: string, opt: TextCompOpt = {}): TextComp { drawFormattedText(theFormattedText); }, + drawInspect(this: GameObj) { + const p = anchorPoint(); + for (let fc of theFormattedText.chars) { + drawRect({ + pos: fc.pos.add(p), + width: fc.width * fc.scale.x, + height: fc.height * fc.scale.y, + fill: false, + anchor: "center", + outline: { + color: rgb(255, 255, 0), + width: 2, + }, + }); + } + }, + + pointToCharacter(this: GameObj, point) { + const offset = anchorPoint(); + if (!testRectPoint(this.renderArea(), point.sub(offset))) return -1; + const chars = theFormattedText.chars; + let minDist = Infinity; + let minIndex = -1; + for (let i = 0; i < chars.length; i++) { + const fc = chars[i]; + const w = fc.width * fc.scale.x; + const h = fc.height * fc.scale.y; + Vec2.copy(fc.pos, tempRectForPointTest.pos); + Vec2.add( + tempRectForPointTest.pos, + offset, + tempRectForPointTest.pos, + ); + Vec2.addc( + tempRectForPointTest.pos, + -w / 2, + -h / 2, + tempRectForPointTest.pos, + ); + tempRectForPointTest.width = w; + tempRectForPointTest.height = h; + if (testRectPoint(tempRectForPointTest, point)) { + const dist = Vec2.dist(point, fc.pos); + if (dist < minDist) { + minDist = dist; + minIndex = i; + } + } + } + return minIndex; + }, + update(this: GameObj) { update(this); }, diff --git a/src/ecs/components/transform/offscreen.ts b/src/ecs/components/transform/offscreen.ts index 6af85ba2a..0511b8fac 100644 --- a/src/ecs/components/transform/offscreen.ts +++ b/src/ecs/components/transform/offscreen.ts @@ -1,7 +1,7 @@ import { DEF_OFFSCREEN_DIS } from "../../../constants/general"; import type { KEventController } from "../../../events/events"; import { height, width } from "../../../gfx/stack"; -import { Rect, testRectPoint, vec2 } from "../../../math/math"; +import { Rect, testRectPoint, testRectRect, vec2 } from "../../../math/math"; import { _k } from "../../../shared"; import type { Comp, GameObj } from "../../../types"; import type { RectComp } from "../draw/rect"; @@ -110,7 +110,7 @@ export function offscreen(opt: OffScreenCompOpt = {}): OffScreenComp { selfRect.width = this.width; selfRect.height = this.height; selfRect.pos = this.pos; - return selfRect.collides(screenRect); + return testRectRect(selfRect, screenRect); } const dist = this.offscreenDistance ? this.offscreenDistance diff --git a/src/gfx/draw/drawFormattedText.ts b/src/gfx/draw/drawFormattedText.ts index 5d7f0a4d6..e04f804fa 100644 --- a/src/gfx/draw/drawFormattedText.ts +++ b/src/gfx/draw/drawFormattedText.ts @@ -49,6 +49,7 @@ export interface FormattedChar { stretchInPlace: boolean; shader?: string; uniform?: Uniform; + styles: [string, string][]; } // cSpell: ignore ftext diff --git a/src/gfx/formatText.ts b/src/gfx/formatText.ts index ab445c797..294daea24 100644 --- a/src/gfx/formatText.ts +++ b/src/gfx/formatText.ts @@ -277,11 +277,11 @@ export function formatText(opt: DrawTextOpt): FormattedText { let curX: number = 0; let tw = 0; - const lines: Array<{ + const lines: { + chars: FormattedChar[]; width: number; - chars: { ch: FormattedChar; font: GfxFont }[]; - }> = []; - let curLine: typeof lines[number]["chars"] = []; + }[] = []; + let curLine: FormattedChar[] = []; let cursor = 0; let lastSpace: number | null = null; let lastSpaceWidth: number = 0; @@ -325,6 +325,7 @@ export function formatText(opt: DrawTextOpt): FormattedText { angle: 0, font: defaultFontValue, stretchInPlace: true, + styles: [], }; if (opt.transform) { @@ -337,7 +338,7 @@ export function formatText(opt: DrawTextOpt): FormattedText { } if (charStyleMap[cursor]) { - const styles = charStyleMap[cursor]; + const styles = theFChar.styles = charStyleMap[cursor]; for (const [name, param] of styles) { const style = opt.styles?.[name]; const tr = typeof style === "function" @@ -419,10 +420,7 @@ export function formatText(opt: DrawTextOpt): FormattedText { ); // queue char to be drawn - curLine.push({ - ch: theFChar as FormattedChar, - font: requestedFontData, - }); + curLine.push(theFChar as FormattedChar); if (ch === " ") { lastSpace = curLine.length; @@ -462,7 +460,7 @@ export function formatText(opt: DrawTextOpt): FormattedText { if (i > 0) th += lineSpacing; const ox = (tw - lines[i].width) * alignPt(opt.align ?? "left"); let thisLineHeight = size; - for (const { ch } of lines[i].chars) { + for (const ch of lines[i].chars) { ch.pos = ch.pos.add(ox, th - baselineCenterOffset); formattedChars.push(ch); thisLineHeight = Math.max( @@ -470,6 +468,7 @@ export function formatText(opt: DrawTextOpt): FormattedText { size * (ch.stretchInPlace ? scale : ch.scale).y / scale.y, ); } + if (!thisLineHeight) thisLineHeight = size; th += thisLineHeight; } diff --git a/src/math/math.ts b/src/math/math.ts index 8cabca4d2..05bf90e2e 100644 --- a/src/math/math.ts +++ b/src/math/math.ts @@ -2344,7 +2344,7 @@ export class Rect { } support(direction: Vec2): Vec2 { const pts = this.points(); - let maxPoint = this.points()[0]; + let maxPoint = pts[0]; let maxDistance = Number.NEGATIVE_INFINITY; let vertex; for (let i = 1; i < pts.length; i++) {