Appearance
2. HUD & message box
In this tutorial you build two pieces of UI that most games need:
- A HUD (
hudweapon) in the top-right corner: a health bar that eases smoothly when your health changes, and a gold counter. The numbers belong to the server and are stored inclientrflags, so the HUD can display them but never change them. Pressing H starts a pretend fight, so you can watch the values change. - A message box (
messageboxweapon): a window that word-wraps long text, splits it into pages, and lets the player page through with buttons, the mouse wheel or the keyboard. Any script can open it, whether serverside or clientside. New players see a welcome message once.
What you'll learn
- Server-owned player state in
clientrflags, and why the HUD reads them instead of keeping its own numbers. - Drawing screen-space UI with
findimg: rectangles, polygons and text, plus redrawing only what changed. - Building a window from GUI controls (
GuiWindowCtrl,GuiTextCtrl,GuiButtonCtrl) and wiring it up with.on()events. - Pagination with
measuretext. - Keyboard input with
onKeyPressed, and how it differs from focus-routed.on('keydown'). - Letting other scripts drive a weapon through
triggerClientandfindweapon.
Files: weapons/hud.ts, weapons/hud.client.ts and weapons/messagebox.client.ts in docs/examples/hud-and-messages/.
Two ways to draw UI
The client offers two drawing systems, and this tutorial uses one of each:
Script images (findimg) | GUI controls (new Gui…Ctrl) | |
|---|---|---|
| Good for | HUDs, overlays, world effects | windows, buttons, text fields, lists |
| Input | none (poll mousex() yourself) | .on('click'), .on('mousewheel'), … |
| Position | world tiles or screen pixels | screen pixels, relative to the parent |
| Draws | below all GUI | above images |
A HUD is display-only and should never steal clicks, so it uses images. The message box needs buttons, so it uses controls. The guide pages Images, text & particles and GUI controls cover each system in depth.
Part 1: the HUD
Step 1: the server owns the numbers
The HUD's serverside half makes sure every account has health and gold:
ts
const START_MAX_HP = 10
const START_GOLD = 25
// Seed the stats the first time an account logs in. clientr flags persist
// in the account file, so returning players keep their values.
export function onPlayerJoined(player: Player) {
if (typeof player.clientr.maxhp !== 'number') player.clientr.maxhp = START_MAX_HP
if (typeof player.clientr.hp !== 'number') player.clientr.hp = player.clientr.maxhp
if (typeof player.clientr.gold !== 'number') player.clientr.gold = START_GOLD
}player.clientr is the right bag for this kind of state:
- only the server can write it, so a modified client can't give itself gold;
- the player's client can read it (as
clientr.gold), and every server write is synced to it immediately; - it's saved in the account file, so it survives logouts and restarts.
onPlayerJoined runs on every serverside script that exports it, including weapon halves, so the hud weapon can seed its own flags without touching login.ts. See Flags.
Step 2: lay out the images
Each piece of the HUD is one script image, identified by a number that is private to this weapon:
ts
const IMG_PANEL = 1
const IMG_BAR_BACK = 2
const IMG_BAR_FILL = 3
const IMG_HP_TEXT = 4
const IMG_COIN = 5
const IMG_GOLD_TEXT = 6
const PANEL_W = 180
const PANEL_H = 58
const MARGIN = 12
const BAR_W = PANEL_W - 20
// Places every HUD image relative to the top-right corner. Screen-mode
// images use pixel coordinates from the window's top-left.
function layout() {
const left = ScreenWidth - PANEL_W - MARGIN
const top = MARGIN
const panel = findimg(IMG_PANEL)
panel.screen = true
panel.layer = 0
panel.image = '' // '' = a solid rectangle in the tint color
panel.tint = '20,20,30'
panel.alpha = 0.7
panel.x = left
panel.y = top
panel.width = PANEL_W
panel.height = PANEL_H
const back = findimg(IMG_BAR_BACK)
back.screen = true
back.layer = 1
back.tint = '70,20,20'
back.x = left + 10
back.y = top + 10
back.width = BAR_W
back.height = 14
const fill = findimg(IMG_BAR_FILL)
fill.screen = true
fill.layer = 2
fill.tint = '220,50,60'
fill.x = left + 10
fill.y = top + 10
fill.height = 14
const hpText = findimg(IMG_HP_TEXT)
hpText.screen = true
hpText.layer = 3
hpText.style = 'bc' // bold, centered on x
hpText.fontsize = 12
hpText.textshadow = true
hpText.x = left + 10 + BAR_W / 2
hpText.y = top + 9
// A little diamond-shaped "coin": polygon vertices are screen pixels here.
const coin = findimg(IMG_COIN)
coin.screen = true
coin.layer = 1
coin.tint = '240,200,60'
const cx = left + 18
const cy = top + 41
coin.polygon = [cx, cy - 7, cx + 6, cy, cx, cy + 7, cx - 6, cy]
const goldText = findimg(IMG_GOLD_TEXT)
goldText.screen = true
goldText.layer = 1
goldText.style = 'b'
goldText.fontsize = 14
goldText.textshadow = true
goldText.tint = '255,230,140'
goldText.x = left + 32
goldText.y = top + 32
this.laidOutFor = ScreenWidth
this.shownHp = -1 // force a redraw of the values
this.shownGold = -1
}screen = truemakesx/yscreen pixels, measured from the window's top-left. The image ignores the camera and draws above the world, but still below GUI controls.- With
image = ''(the default), an image withwidth/heightdraws a solid rectangle in itstintcolor. That's all a health bar needs. - The coin is a
polygon: a flat list of x,y pairs, in screen pixels because the image is in screen mode. layerorders images: the panel sits at 0 and the text at 3.- There is no anchoring API. The layout is computed from
ScreenWidthandlayout()remembers which width it used, soonUpdatecan re-run it when the window is resized.
Step 3: update from the flags
ts
export function onCreated() {
this.barHp = Number(clientr.hp) || 0
layout()
}
export function onUpdate(dt: number) {
// There is no anchoring API: re-run the layout when the window resizes.
if (this.laidOutFor !== ScreenWidth)
layout()
const maxhp = Math.max(1, Number(clientr.maxhp) || 1)
const hp = Math.max(0, Math.min(maxhp, Number(clientr.hp) || 0))
const gold = Number(clientr.gold) || 0
// The bar eases toward the real value so hits are easy to see.
const diff = hp - this.barHp
this.barHp = Math.abs(diff) < 0.01 ? hp : this.barHp + diff * Math.min(1, dt * 8)
findimg(IMG_BAR_FILL).width = Math.round(BAR_W * this.barHp / maxhp)
// Only touch the text when a value changed: every property write is a
// (batched) host command, so skipping no-op writes keeps frames cheap.
if (hp !== this.shownHp || maxhp !== this.shownMax) {
this.shownHp = hp
this.shownMax = maxhp
findimg(IMG_HP_TEXT).text = `${hp} / ${maxhp}`
}
if (gold !== this.shownGold) {
this.shownGold = gold
findimg(IMG_GOLD_TEXT).text = `${gold} gold`
}
}clientr is a live, read-only mirror. onUpdate reads it every frame, but it only writes image properties when a value actually changed. Image writes are batched host commands, so skipping no-op writes keeps the frame cheap. The bar's width is the exception: it eases toward the real value over a few frames, so hits and heals are easy to see.
Notice what the HUD does not do: it never tracks the player's health itself. Anything that changes clientr.hp, whether this demo, the potion from tutorial 3 or the blaster from tutorial 4, shows up with no extra wiring.
Step 4: a way to test it
ts
// onKeyPressed fires once per press (no auto-repeat) and stays silent while
// the chat bar or a text field has focus - so typing "h" in chat is safe.
export function onKeyPressed(key: string) {
if (key === 'H')
triggerServer('weapon', this.name, 'demo')
}onKeyPressed fires once per key press, with no auto-repeat. It stays silent while the chat bar or a text field has focus, so typing "h" in chat doesn't trigger it. Letter keys arrive uppercase ('H'), and other keys by name ('Space', 'Escape', 'Left').
On the server, the demo action picks the outcome and writes the flags:
ts
// A stand-in for real gameplay: pressing H asks the server to "fight".
// The server decides the outcome and writes the flags; the HUD just reacts.
export function onActionServerSide(player: Player, action: string) {
if (action !== 'demo') return
const maxhp = Number(player.clientr.maxhp) || START_MAX_HP
const hp = Number(player.clientr.hp) - (1 + Math.floor(Math.random() * 3))
const loot = Math.floor(Math.random() * 10)
player.clientr.gold = (Number(player.clientr.gold) || 0) + loot
if (hp > 0) {
player.clientr.hp = hp
player.chat = loot > 0 ? `Found ${loot} gold!` : 'Ouch!'
return
}
// Knocked out: refill and tell the player why, using the message box
// weapon's clientside half. We are inside onActionServerSide, so
// triggerClient knows which player to answer.
player.clientr.hp = maxhp
triggerClient('weapon', 'messagebox', 'show', 'Knocked out',
'You collapse in a heap. A passing healer patches you up and sends you on '
+ 'your way.\n\nYour health has been fully restored, and you kept all of '
+ `your gold (${player.clientr.gold} in total). Try to be more careful next time!`)
}When health runs out, it opens a message box on the player's client with triggerClient('weapon', 'messagebox', 'show', …). triggerClient can target any weapon the player has, not just the weapon that sent the trigger.
Part 2: the message box
Step 5: build the window once
ts
const WIN_W = 420
const WIN_H = 250
const PAD = 16
const TOP = 40 // window children start below the title bar
const FONT_SIZE = 16
const BUTTON_W = 90
const BUTTON_H = 30
const BODY_H = WIN_H - TOP - BUTTON_H - PAD * 2
// Builds the window once, hidden. Showing/hiding existing controls is
// cheaper than recreating them, and nothing flickers.
function build() {
const win = new GuiWindowCtrl('MsgBox_Window')
win.extent = `${WIN_W},${WIN_H}`
win.visible = false
const body = new GuiTextCtrl('MsgBox_Body')
body.position = `${PAD},${TOP}`
body.fontsize = FONT_SIZE
win.addControl(body)
const pageLabel = new GuiTextCtrl('MsgBox_Page')
pageLabel.position = `${PAD},${WIN_H - PAD - BUTTON_H + 6}`
pageLabel.fontsize = 12
pageLabel.color = '180,180,190'
win.addControl(pageLabel)
const prev = new GuiButtonCtrl('MsgBox_Prev')
prev.text = 'Back'
prev.position = `${WIN_W - PAD - BUTTON_W * 2 - 8},${WIN_H - PAD - BUTTON_H}`
prev.extent = `${BUTTON_W},${BUTTON_H}`
win.addControl(prev)
const next = new GuiButtonCtrl('MsgBox_Next')
next.position = `${WIN_W - PAD - BUTTON_W},${WIN_H - PAD - BUTTON_H}`
next.extent = `${BUTTON_W},${BUTTON_H}`
win.addControl(next)
this.win = win
this.body = body
this.pageLabel = pageLabel
this.prevButton = prev
this.nextButton = next
listen(win, prev, next)
}- Controls are created with
new. The name you pass is global across all weapons, so prefix it with your weapon's name (MsgBox_…). addControlparents a control inside the window, with positions relative to the window's top-left. That area includes the title bar, so content starts at abouty = 40.extentandpositionare"w,h"and"x,y"shorthands.- The window is built once, hidden, and then shown and hidden as needed. That's cheaper than rebuilding controls for every message and avoids flicker. Controls are destroyed automatically when the weapon unloads.
Step 6: mouse events
ts
// .on() listeners are plain callbacks. Arrow functions inside a top-level
// function still see the weapon as `this`.
function listen(win: GuiWindowCtrl, prev: GuiButtonCtrl, next: GuiButtonCtrl) {
prev.on('click', () => turnPage(-1))
next.on('click', () => {
if (this.page < this.pages.length - 1) turnPage(1)
else close()
})
// Events bubble from child to parent: a wheel turn over the body label
// reaches the window's listener too.
win.on('mousewheel', e => turnPage(e.delta > 0 ? -1 : 1))
}.on(event, listener) works on every control. Mouse events bubble: a wheel turn over the body label fires the window's mousewheel listener too. GuiWheelEvent.delta is positive when scrolling up.
The listeners are arrow functions created inside a top-level function, so this is still the weapon.
Step 7: paginate with measuretext
ts
// Exported, so other weapons can call it through findweapon(...).trigger.
export function showMessage(title: string, text: string) {
if (this.open) {
// One window at a time: queue the rest.
this.queue.push([title, text])
return
}
// Wrap to the body width, then cut the wrapped lines into pages that
// fit the body height. measuretext wraps exactly like a label would.
const measured = measuretext(text, { fontsize: FONT_SIZE, width: WIN_W - PAD * 2 })
const perPage = Math.max(1, Math.floor(BODY_H / measured.lineheight))
const pages: string[] = []
for (let i = 0; i < measured.lines.length; i += perPage)
pages.push(measured.lines.slice(i, i + perPage).join('\n'))
if (pages.length === 0) pages.push('')
this.pages = pages
this.page = 0
this.open = true
const win: GuiWindowCtrl = this.win
win.text = title
win.position = `${Math.floor((ScreenWidth - WIN_W) / 2)},${Math.floor((ScreenHeight - WIN_H) / 2)}`
win.show()
win.bringtofront()
// Modal: the arrow keys turn pages instead of walking the player.
disabledefmovement()
render()
}
function render() {
const last = this.page >= this.pages.length - 1
this.body.text = this.pages[this.page]
this.pageLabel.text = `Page ${this.page + 1} of ${this.pages.length}`
this.prevButton.visible = this.page > 0
this.nextButton.text = last ? 'Close' : 'Next'
}
function turnPage(delta: number) {
if (!this.open) return
const page = Math.max(0, Math.min(this.pages.length - 1, this.page + delta))
if (page === this.page) return
this.page = page
render()
}
function close() {
if (!this.open) return
this.open = false
this.win.hide()
enabledefmovement()
// Show the next queued message, if any.
const queued = this.queue.shift()
if (queued) showMessage(queued[0], queued[1])
}measuretext(text, { fontsize, width }) wraps text exactly as a label with that width and font size would. It returns the wrapped lines and the lineheight. Slicing lines into groups that fit the body height gives you pages. Joining a page with '\n' reproduces the same wrapping in the label.
showMessage is exported, which makes it a handler that other scripts can call:
- another clientside weapon:
findweapon('messagebox')?.trigger('showMessage', 'Title', 'Text').WeaponHandle.triggerruns it immediately, and any controls it creates belong to the message box weapon; - a serverside script:
triggerClient('weapon', 'messagebox', 'show', title, text), which arrives inonActionClientSide(see step 9).
While the box is open, disabledefmovement() keeps the arrow keys from walking the player, so the box behaves like a modal dialog. enabledefmovement() in close() gives movement back.
Step 8: keyboard
ts
// Keyboard: onKeyPressed hears keys while the game has the keyboard (not
// while the chat bar or a text field is focused), once per press.
export function onKeyPressed(key: string) {
if (!this.open) return
if (key === 'Escape') {
close()
} else if (key === 'Left') {
turnPage(-1)
} else if (key === 'Right' || key === 'Space' || key === 'Enter') {
if (this.page < this.pages.length - 1) turnPage(1)
else if (key !== 'Right') close()
}
}onKeyPressed vs .on('keydown')
GUI controls also support .on('keydown') / .on('keyup'), but those events are routed by keyboard focus: they fire only while that control, or a child of it, is focused. In practice that means a text field the player clicked into. A window or button doesn't hold focus for keys, so a message box that should react to Space as soon as it opens uses onKeyPressed. Tutorial 3 uses .on('keydown') on a quantity field, where focus is exactly what you want.
Step 9: entry points
ts
export function onCreated() {
this.open = false
this.queue = []
this.pages = []
this.page = 0
build()
// client.* flags persist in the account, so the welcome shows once.
if (!client.welcomeSeen) {
client.welcomeSeen = true
showMessage('Welcome!',
'Welcome to the server! This window is the messagebox weapon: it word-wraps '
+ 'long text and splits it into pages that fit.\n\n'
+ 'Turn pages with the Next and Back buttons, the mouse wheel over the window, '
+ 'or the keyboard: Space, Enter and the arrow keys. Escape closes the window.\n\n'
+ 'The health bar and gold counter in the top-right corner are the hud weapon. '
+ 'Press H to pick a fight and watch them change - if your health runs out, the '
+ 'server opens this window again to tell you what happened.')
}
}
// The serverside entry point: triggerClient('weapon', 'messagebox', 'show', title, text).
export function onActionClientSide(action: string, ...params: any[]) {
if (action === 'show')
showMessage(String(params[0] ?? ''), String(params[1] ?? ''))
}client flags are the one kind of flag the client can write. They sync to the server and persist in the account, which makes them good for per-player UI preferences such as "has seen the welcome message". Don't use them for anything the player shouldn't control: a player could set client.welcomeSeen themselves, and nothing bad happens.
The complete files
weapons/hud.ts
ts
// Serverside half of the `hud` weapon. The server owns the numbers the HUD
// shows: they live in clientr flags, which the player's client can read
// but never write, and every write here syncs to that client immediately.
const START_MAX_HP = 10
const START_GOLD = 25
// Seed the stats the first time an account logs in. clientr flags persist
// in the account file, so returning players keep their values.
export function onPlayerJoined(player: Player) {
if (typeof player.clientr.maxhp !== 'number') player.clientr.maxhp = START_MAX_HP
if (typeof player.clientr.hp !== 'number') player.clientr.hp = player.clientr.maxhp
if (typeof player.clientr.gold !== 'number') player.clientr.gold = START_GOLD
}
// A stand-in for real gameplay: pressing H asks the server to "fight".
// The server decides the outcome and writes the flags; the HUD just reacts.
export function onActionServerSide(player: Player, action: string) {
if (action !== 'demo') return
const maxhp = Number(player.clientr.maxhp) || START_MAX_HP
const hp = Number(player.clientr.hp) - (1 + Math.floor(Math.random() * 3))
const loot = Math.floor(Math.random() * 10)
player.clientr.gold = (Number(player.clientr.gold) || 0) + loot
if (hp > 0) {
player.clientr.hp = hp
player.chat = loot > 0 ? `Found ${loot} gold!` : 'Ouch!'
return
}
// Knocked out: refill and tell the player why, using the message box
// weapon's clientside half. We are inside onActionServerSide, so
// triggerClient knows which player to answer.
player.clientr.hp = maxhp
triggerClient('weapon', 'messagebox', 'show', 'Knocked out',
'You collapse in a heap. A passing healer patches you up and sends you on '
+ 'your way.\n\nYour health has been fully restored, and you kept all of '
+ `your gold (${player.clientr.gold} in total). Try to be more careful next time!`)
}weapons/hud.client.ts
ts
// Clientside half of the `hud` weapon: a health bar and a gold counter in
// the top-right corner, drawn with screen-space script images (findimg).
// The values come from clientr flags the serverside half writes.
const IMG_PANEL = 1
const IMG_BAR_BACK = 2
const IMG_BAR_FILL = 3
const IMG_HP_TEXT = 4
const IMG_COIN = 5
const IMG_GOLD_TEXT = 6
const PANEL_W = 180
const PANEL_H = 58
const MARGIN = 12
const BAR_W = PANEL_W - 20
// Places every HUD image relative to the top-right corner. Screen-mode
// images use pixel coordinates from the window's top-left.
function layout() {
const left = ScreenWidth - PANEL_W - MARGIN
const top = MARGIN
const panel = findimg(IMG_PANEL)
panel.screen = true
panel.layer = 0
panel.image = '' // '' = a solid rectangle in the tint color
panel.tint = '20,20,30'
panel.alpha = 0.7
panel.x = left
panel.y = top
panel.width = PANEL_W
panel.height = PANEL_H
const back = findimg(IMG_BAR_BACK)
back.screen = true
back.layer = 1
back.tint = '70,20,20'
back.x = left + 10
back.y = top + 10
back.width = BAR_W
back.height = 14
const fill = findimg(IMG_BAR_FILL)
fill.screen = true
fill.layer = 2
fill.tint = '220,50,60'
fill.x = left + 10
fill.y = top + 10
fill.height = 14
const hpText = findimg(IMG_HP_TEXT)
hpText.screen = true
hpText.layer = 3
hpText.style = 'bc' // bold, centered on x
hpText.fontsize = 12
hpText.textshadow = true
hpText.x = left + 10 + BAR_W / 2
hpText.y = top + 9
// A little diamond-shaped "coin": polygon vertices are screen pixels here.
const coin = findimg(IMG_COIN)
coin.screen = true
coin.layer = 1
coin.tint = '240,200,60'
const cx = left + 18
const cy = top + 41
coin.polygon = [cx, cy - 7, cx + 6, cy, cx, cy + 7, cx - 6, cy]
const goldText = findimg(IMG_GOLD_TEXT)
goldText.screen = true
goldText.layer = 1
goldText.style = 'b'
goldText.fontsize = 14
goldText.textshadow = true
goldText.tint = '255,230,140'
goldText.x = left + 32
goldText.y = top + 32
this.laidOutFor = ScreenWidth
this.shownHp = -1 // force a redraw of the values
this.shownGold = -1
}
export function onCreated() {
this.barHp = Number(clientr.hp) || 0
layout()
}
export function onUpdate(dt: number) {
// There is no anchoring API: re-run the layout when the window resizes.
if (this.laidOutFor !== ScreenWidth)
layout()
const maxhp = Math.max(1, Number(clientr.maxhp) || 1)
const hp = Math.max(0, Math.min(maxhp, Number(clientr.hp) || 0))
const gold = Number(clientr.gold) || 0
// The bar eases toward the real value so hits are easy to see.
const diff = hp - this.barHp
this.barHp = Math.abs(diff) < 0.01 ? hp : this.barHp + diff * Math.min(1, dt * 8)
findimg(IMG_BAR_FILL).width = Math.round(BAR_W * this.barHp / maxhp)
// Only touch the text when a value changed: every property write is a
// (batched) host command, so skipping no-op writes keeps frames cheap.
if (hp !== this.shownHp || maxhp !== this.shownMax) {
this.shownHp = hp
this.shownMax = maxhp
findimg(IMG_HP_TEXT).text = `${hp} / ${maxhp}`
}
if (gold !== this.shownGold) {
this.shownGold = gold
findimg(IMG_GOLD_TEXT).text = `${gold} gold`
}
}
// onKeyPressed fires once per press (no auto-repeat) and stays silent while
// the chat bar or a text field has focus - so typing "h" in chat is safe.
export function onKeyPressed(key: string) {
if (key === 'H')
triggerServer('weapon', this.name, 'demo')
}weapons/messagebox.client.ts
ts
// The `messagebox` weapon: a paged message window any script can open.
// - serverside scripts: triggerClient('weapon', 'messagebox', 'show', title, text)
// - other clientside weapons: findweapon('messagebox')?.trigger('showMessage', title, text)
// Long text is word-wrapped with measuretext and split into pages that fit
// the window. Buttons, the mouse wheel and the keyboard all turn pages.
const WIN_W = 420
const WIN_H = 250
const PAD = 16
const TOP = 40 // window children start below the title bar
const FONT_SIZE = 16
const BUTTON_W = 90
const BUTTON_H = 30
const BODY_H = WIN_H - TOP - BUTTON_H - PAD * 2
// Builds the window once, hidden. Showing/hiding existing controls is
// cheaper than recreating them, and nothing flickers.
function build() {
const win = new GuiWindowCtrl('MsgBox_Window')
win.extent = `${WIN_W},${WIN_H}`
win.visible = false
const body = new GuiTextCtrl('MsgBox_Body')
body.position = `${PAD},${TOP}`
body.fontsize = FONT_SIZE
win.addControl(body)
const pageLabel = new GuiTextCtrl('MsgBox_Page')
pageLabel.position = `${PAD},${WIN_H - PAD - BUTTON_H + 6}`
pageLabel.fontsize = 12
pageLabel.color = '180,180,190'
win.addControl(pageLabel)
const prev = new GuiButtonCtrl('MsgBox_Prev')
prev.text = 'Back'
prev.position = `${WIN_W - PAD - BUTTON_W * 2 - 8},${WIN_H - PAD - BUTTON_H}`
prev.extent = `${BUTTON_W},${BUTTON_H}`
win.addControl(prev)
const next = new GuiButtonCtrl('MsgBox_Next')
next.position = `${WIN_W - PAD - BUTTON_W},${WIN_H - PAD - BUTTON_H}`
next.extent = `${BUTTON_W},${BUTTON_H}`
win.addControl(next)
this.win = win
this.body = body
this.pageLabel = pageLabel
this.prevButton = prev
this.nextButton = next
listen(win, prev, next)
}
// .on() listeners are plain callbacks. Arrow functions inside a top-level
// function still see the weapon as `this`.
function listen(win: GuiWindowCtrl, prev: GuiButtonCtrl, next: GuiButtonCtrl) {
prev.on('click', () => turnPage(-1))
next.on('click', () => {
if (this.page < this.pages.length - 1) turnPage(1)
else close()
})
// Events bubble from child to parent: a wheel turn over the body label
// reaches the window's listener too.
win.on('mousewheel', e => turnPage(e.delta > 0 ? -1 : 1))
}
// Exported, so other weapons can call it through findweapon(...).trigger.
export function showMessage(title: string, text: string) {
if (this.open) {
// One window at a time: queue the rest.
this.queue.push([title, text])
return
}
// Wrap to the body width, then cut the wrapped lines into pages that
// fit the body height. measuretext wraps exactly like a label would.
const measured = measuretext(text, { fontsize: FONT_SIZE, width: WIN_W - PAD * 2 })
const perPage = Math.max(1, Math.floor(BODY_H / measured.lineheight))
const pages: string[] = []
for (let i = 0; i < measured.lines.length; i += perPage)
pages.push(measured.lines.slice(i, i + perPage).join('\n'))
if (pages.length === 0) pages.push('')
this.pages = pages
this.page = 0
this.open = true
const win: GuiWindowCtrl = this.win
win.text = title
win.position = `${Math.floor((ScreenWidth - WIN_W) / 2)},${Math.floor((ScreenHeight - WIN_H) / 2)}`
win.show()
win.bringtofront()
// Modal: the arrow keys turn pages instead of walking the player.
disabledefmovement()
render()
}
function render() {
const last = this.page >= this.pages.length - 1
this.body.text = this.pages[this.page]
this.pageLabel.text = `Page ${this.page + 1} of ${this.pages.length}`
this.prevButton.visible = this.page > 0
this.nextButton.text = last ? 'Close' : 'Next'
}
function turnPage(delta: number) {
if (!this.open) return
const page = Math.max(0, Math.min(this.pages.length - 1, this.page + delta))
if (page === this.page) return
this.page = page
render()
}
function close() {
if (!this.open) return
this.open = false
this.win.hide()
enabledefmovement()
// Show the next queued message, if any.
const queued = this.queue.shift()
if (queued) showMessage(queued[0], queued[1])
}
// Keyboard: onKeyPressed hears keys while the game has the keyboard (not
// while the chat bar or a text field is focused), once per press.
export function onKeyPressed(key: string) {
if (!this.open) return
if (key === 'Escape') {
close()
} else if (key === 'Left') {
turnPage(-1)
} else if (key === 'Right' || key === 'Space' || key === 'Enter') {
if (this.page < this.pages.length - 1) turnPage(1)
else if (key !== 'Right') close()
}
}
export function onCreated() {
this.open = false
this.queue = []
this.pages = []
this.page = 0
build()
// client.* flags persist in the account, so the welcome shows once.
if (!client.welcomeSeen) {
client.welcomeSeen = true
showMessage('Welcome!',
'Welcome to the server! This window is the messagebox weapon: it word-wraps '
+ 'long text and splits it into pages that fit.\n\n'
+ 'Turn pages with the Next and Back buttons, the mouse wheel over the window, '
+ 'or the keyboard: Space, Enter and the arrow keys. Escape closes the window.\n\n'
+ 'The health bar and gold counter in the top-right corner are the hud weapon. '
+ 'Press H to pick a fight and watch them change - if your health runs out, the '
+ 'server opens this window again to tell you what happened.')
}
}
// The serverside entry point: triggerClient('weapon', 'messagebox', 'show', title, text).
export function onActionClientSide(action: string, ...params: any[]) {
if (action === 'show')
showMessage(String(params[0] ?? ''), String(params[1] ?? ''))
}Try it
- Create
hud(both halves) andmessageboxin GRC's Weapons editor and save. Add"hud"and"messagebox"tostartWeaponsin Server Options, click Apply, then log in again (the welcome message is shown on login). - The welcome message opens. Page through it with Space, the Next button and the mouse wheel, and go back with Left or Back. Close it with Escape. Log out and back in: it doesn't come back.
- Press H a few times. The bar shrinks smoothly, your gold goes up, and your character comments in a chat bubble.
- Keep pressing until your health runs out. The server refills it and opens a message box explaining what happened.
- Resize the game window: the HUD stays in the top-right corner.
Next steps
- More stats: add a mana bar. Write
clientr.mpon the server and add three more images. Consider a helperbar(idBase, x, y, value, max, color). - Low health warning: tint the bar and pulse its
alphawhenhp / maxhp < 0.25. - Choice dialogs: give
showMessagean optional list of buttons and report the clicked one back to the server withtriggerServer('weapon', …), for quests and yes/no prompts. - Typing effect: reveal each page one character at a time with
setInterval. Updating an existing label'stextfrom the timer is fine. Don't create controls there, though: controls created in timer callbacks aren't cleaned up when the weapon reloads. - Continue with Shop & inventory, which spends the gold shown here.