Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Changelog

<!-- markdownlint-disable no-duplicate-heading blanks-around-fences single-h1 -->

All notable changes to this project will be documented in this file.

The format is (mostly) based on
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions examples/textClick.js
Original file line number Diff line number Diff line change
@@ -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");
75 changes: 72 additions & 3 deletions src/ecs/components/draw/text.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
*/
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -238,6 +255,58 @@ export function text(t: string, opt: TextCompOpt = {}): TextComp {
drawFormattedText(theFormattedText);
},

drawInspect(this: GameObj<TextComp>) {
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<TextComp>, 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<TextComp>) {
update(this);
},
Expand Down
4 changes: 2 additions & 2 deletions src/ecs/components/transform/offscreen.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/gfx/draw/drawFormattedText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface FormattedChar {
stretchInPlace: boolean;
shader?: string;
uniform?: Uniform;
styles: [string, string][];
}

// cSpell: ignore ftext
Expand Down
19 changes: 9 additions & 10 deletions src/gfx/formatText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -325,6 +325,7 @@ export function formatText(opt: DrawTextOpt): FormattedText {
angle: 0,
font: defaultFontValue,
stretchInPlace: true,
styles: [],
};

if (opt.transform) {
Expand All @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -462,14 +460,15 @@ 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(
thisLineHeight,
size * (ch.stretchInPlace ? scale : ch.scale).y / scale.y,
);
}
if (!thisLineHeight) thisLineHeight = size;
th += thisLineHeight;
}

Expand Down
2 changes: 1 addition & 1 deletion src/math/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down