Skip to content

Commit 51b600c

Browse files
dwursteisenclaude
andcommitted
Add text Lua library for custom font rendering
Introduce a new `text` Lua lib that allows game developers to configure custom fonts via `_tiny.json` and render text with them. Supports font selection by index/name, multiline text, pixel width measurement, and falls back to the boot font when no custom font is selected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1018cdb commit 51b600c

File tree

10 files changed

+535
-1
lines changed

10 files changed

+535
-1
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.github.minigdx.tiny.engine
2+
3+
import kotlin.math.max
4+
5+
data class CharResolution(
6+
val sourceX: Int,
7+
val sourceY: Int,
8+
val charWidth: Int,
9+
val charHeight: Int,
10+
)
11+
12+
data class FontBank(
13+
val name: String,
14+
val charWidth: Int,
15+
val charHeight: Int,
16+
val x: Int,
17+
val y: Int,
18+
val charMap: Map<Int, Pair<Int, Int>>,
19+
)
20+
21+
data class FontDescriptor(
22+
val name: String,
23+
val spritesheet: String,
24+
val spaceWidth: Int,
25+
val lineHeight: Int,
26+
val banks: List<FontBank>,
27+
) {
28+
fun resolve(codepoint: Int): CharResolution? {
29+
for (bank in banks) {
30+
val coord = bank.charMap[codepoint]
31+
if (coord != null) {
32+
return CharResolution(
33+
sourceX = bank.x + coord.first * bank.charWidth,
34+
sourceY = bank.y + coord.second * bank.charHeight,
35+
charWidth = bank.charWidth,
36+
charHeight = bank.charHeight,
37+
)
38+
}
39+
}
40+
return null
41+
}
42+
43+
companion object {
44+
fun fromConfig(config: GameConfigFont): FontDescriptor {
45+
var maxHeight = 0
46+
val banks = config.banks.map { bankConfig ->
47+
val bank = FontBank(
48+
name = bankConfig.name,
49+
charWidth = bankConfig.width,
50+
charHeight = bankConfig.height,
51+
x = bankConfig.x,
52+
y = bankConfig.y,
53+
charMap = buildCharMap(bankConfig.characters),
54+
)
55+
maxHeight = max(maxHeight, bankConfig.height)
56+
bank
57+
}
58+
val defaultSpaceWidth = config.banks.firstOrNull()?.let { it.width / 2 } ?: 4
59+
return FontDescriptor(
60+
name = config.name,
61+
spritesheet = config.spritesheet,
62+
spaceWidth = config.spaceWidth ?: defaultSpaceWidth,
63+
lineHeight = maxHeight,
64+
banks = banks,
65+
)
66+
}
67+
}
68+
}
69+
70+
/**
71+
* KMP-compatible codepoint iteration with surrogate pair handling.
72+
* Skips variation selectors (U+FE0E, U+FE0F).
73+
*/
74+
inline fun String.forEachCodepoint(action: (codepoint: Int) -> Unit) {
75+
var i = 0
76+
while (i < length) {
77+
val c = this[i]
78+
val codepoint = if (c.isHighSurrogate() && i + 1 < length && this[i + 1].isLowSurrogate()) {
79+
val low = this[i + 1]
80+
i += 2
81+
((c.code - 0xD800) shl 10) + (low.code - 0xDC00) + 0x10000
82+
} else {
83+
i++
84+
c.code
85+
}
86+
if (codepoint != 0xFE0E && codepoint != 0xFE0F) {
87+
action(codepoint)
88+
}
89+
}
90+
}
91+
92+
fun buildCharMap(characters: List<String>): Map<Int, Pair<Int, Int>> {
93+
val map = mutableMapOf<Int, Pair<Int, Int>>()
94+
characters.forEachIndexed { row, line ->
95+
var col = 0
96+
line.forEachCodepoint { codepoint ->
97+
map[codepoint] = col to row
98+
col++
99+
}
100+
}
101+
return map
102+
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/engine/GameConfig.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ sealed class GameConfig {
2525
@Serializable
2626
data class GameConfigSize(val width: Int, val height: Int)
2727

28+
@Serializable
29+
data class GameConfigFontBank(
30+
val name: String,
31+
val width: Int,
32+
val height: Int,
33+
val characters: List<String>,
34+
val x: Int = 0,
35+
val y: Int = 0,
36+
)
37+
38+
@Serializable
39+
data class GameConfigFont(
40+
val name: String,
41+
val spritesheet: String,
42+
val spaceWidth: Int? = null,
43+
val banks: List<GameConfigFontBank>,
44+
)
45+
2846
@SerialName("V1")
2947
@Serializable
3048
data class GameConfigV1(
@@ -45,6 +63,7 @@ data class GameConfigV1(
4563
* When set, this script will be used as the first script to run.
4664
*/
4765
val bootScript: String? = null,
66+
val fonts: List<GameConfigFont> = emptyList(),
4867
) : GameConfig() {
4968
override fun toGameOptions(): GameOptions =
5069
GameOptions(
@@ -59,5 +78,8 @@ data class GameConfigV1(
5978
sound = sound,
6079
hideMouseCursor = hideMouseCursor,
6180
bootScript = bootScript,
81+
fonts = fonts.map { font ->
82+
FontDescriptor.fromConfig(font)
83+
},
6284
)
6385
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/engine/GameOptions.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ data class GameOptions(
2121
val hideMouseCursor: Boolean = false,
2222
val bootScript: String? = null,
2323
val icon: String? = null,
24+
val fonts: List<FontDescriptor> = emptyList(),
2425
) : MouseProject {
2526
init {
2627
require(width > 0) { "The width needs to be a positive number." }

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/engine/GameResourceAccess.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,14 @@ interface GameResourceAccess {
5656
fun findSound(name: String): Sound?
5757

5858
fun findGameScript(name: String): GameScript?
59+
60+
/**
61+
* Access a font spritesheet by its index.
62+
*/
63+
fun findFontSpritesheet(index: Int): SpriteSheet?
64+
65+
/**
66+
* Find a font spritesheet by its name.
67+
*/
68+
fun findFontSpritesheet(name: String): SpriteSheet?
5969
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/engine/GameResourceProcessor.kt

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.github.minigdx.tiny.resources.ResourceType
1212
import com.github.minigdx.tiny.resources.ResourceType.BOOT_GAMESCRIPT
1313
import com.github.minigdx.tiny.resources.ResourceType.BOOT_SPRITESHEET
1414
import com.github.minigdx.tiny.resources.ResourceType.ENGINE_GAMESCRIPT
15+
import com.github.minigdx.tiny.resources.ResourceType.FONT_SPRITESHEET
1516
import com.github.minigdx.tiny.resources.ResourceType.GAME_GAMESCRIPT
1617
import com.github.minigdx.tiny.resources.ResourceType.GAME_LEVEL
1718
import com.github.minigdx.tiny.resources.ResourceType.GAME_SOUND
@@ -39,6 +40,7 @@ class GameResourceProcessor(
3940
private var spriteSheets: Array<SpriteSheet?>
4041
private val levels: Array<GameLevel?>
4142
private val sounds: Array<Sound?>
43+
private val fontSheets: Array<SpriteSheet?>
4244

4345
override var bootSpritesheet: SpriteSheet? = null
4446
private set
@@ -88,6 +90,11 @@ class GameResourceProcessor(
8890
Sound(0, 0, "default-sound", SoundData.DEFAULT_EMPTY)
8991
}
9092

93+
val fontSpritesheets = gameOptions.fonts.mapIndexed { index, font ->
94+
resourceFactory.fontSpritesheet(index, font.spritesheet)
95+
}
96+
this.fontSheets = Array(fontSpritesheets.size) { null }
97+
9198
val bootScriptFlow = if (gameOptions.bootScript != null) {
9299
resourceFactory.customBootScript(gameOptions.bootScript)
93100
} else {
@@ -99,7 +106,7 @@ class GameResourceProcessor(
99106
bootScriptFlow,
100107
resourceFactory.enginescript("_engine.lua"),
101108
resourceFactory.bootSpritesheet("_boot.png"),
102-
) + gameScripts + spriteSheets + gameLevels + listOfNotNull(sounds)
109+
) + gameScripts + spriteSheets + gameLevels + listOfNotNull(sounds) + fontSpritesheets
103110

104111
toBeLoaded.addAll(
105112
setOf(bootScriptName, "_engine.lua", "_boot.png"),
@@ -108,6 +115,7 @@ class GameResourceProcessor(
108115
toBeLoaded.addAll(gameOptions.gameScripts)
109116
toBeLoaded.addAll(gameOptions.spriteSheets)
110117
gameOptions.sound?.let { toBeLoaded.add(it) }
118+
toBeLoaded.addAll(gameOptions.fonts.map { it.spritesheet })
111119

112120
numberOfResources = resources.size
113121
logger.debug("GAME_ENGINE") { "Number of resources to load: $numberOfResources" }
@@ -167,6 +175,7 @@ class GameResourceProcessor(
167175
GAME_SPRITESHEET -> loadGameSpriteSheet(resource)
168176
GAME_LEVEL -> loadGameLevel(resource)
169177
GAME_SOUND -> loadGameSound(resource)
178+
FONT_SPRITESHEET -> loadFontSpriteSheet(resource)
170179
PRIMITIVE_SPRITESHEET -> Unit
171180
}
172181
}
@@ -190,6 +199,13 @@ class GameResourceProcessor(
190199
spritesheetToBind.addAll(gameLevel.tilesset.values)
191200
}
192201

202+
private fun loadFontSpriteSheet(resource: GameResource) {
203+
val spriteSheet = resource as SpriteSheet
204+
spriteSheet.textureUnit = fontSheets[resource.index]?.textureUnit
205+
fontSheets[resource.index] = spriteSheet
206+
spritesheetToBind.add(spriteSheet)
207+
}
208+
193209
private fun loadGameSpriteSheet(resource: GameResource) {
194210
val spriteSheet = resource as SpriteSheet
195211
// Copy the texture unit used by the current spritesheet
@@ -292,6 +308,14 @@ class GameResourceProcessor(
292308
return scripts.find { it?.name == name }
293309
}
294310

311+
override fun findFontSpritesheet(index: Int): SpriteSheet? {
312+
return fontSheets.atIndex(index)
313+
}
314+
315+
override fun findFontSpritesheet(name: String): SpriteSheet? {
316+
return fontSheets.find { it?.name == name }
317+
}
318+
295319
fun status(): Map<ResourceType, Collection<GameResource>> {
296320
return gameResourceCollector.status()
297321
}

0 commit comments

Comments
 (0)