Clicking Objects

How can I make it so players can click on objects in the game to interact with them?
This is an example of "Where's Waldo?" style game where you need to find the right monster in a crowd of monsters. This will be a multiplayer game, so players will be racing to be the first to click on the target monster. We will use hotspots to make the monsters clickable. When the player hovers over a monster, the monster will be highlighted to show that it is clickable.
This example is for clicking on objects in the game world. This may be more complicated than you need. If you simply want to respond to clicks but not at any particular object, you can simply use ButtonDown:
on ButtonDown(Click) {
Print { "Hello, world!" }
}
The Bots and Mobs examples both show you how you can fire projectiles by clicking. They may be more appropriate example depending on what you are trying to achieve.
Step 1: Create some monsters to click on
Each round of our game will have 101 monsters - 100 "decoy" monsters and 1 "target" monster that the player is trying to find.
Step 1a: Create a crowd of monsters
First we need to spawn our 100 decoy monsters. Each monster will have a random position and random appearance.
await fn CatchMonsterRound() {
await Spawn round {
// ...
// Create the sea of monsters
repeat 100 {
Subspawn unit {
Monster(
pos=@(SignedRandom * BoundaryHalfWidth, SignedRandom * BoundaryHalfHeight),
color=PickRandom(roundColors),
arm=PickRandom(@arm-*.png), chest=PickRandom(@chest-*.png), eye=PickRandom(@eye-*.png),
horn=PickRandom(@horn-*.png), leg=PickRandom(@leg-*.png), mouth=PickRandom(@mouth-*.png),
)
WanderingSystem
CatchMeSystem
}
}
// ...
}
}
Step 1b: Create the target monster
Now, we need to spawn our target monster.
This monster will have a random position and random appearance, just like the decoy monsters,
but it will have a special property IsTargetMonster set to true so we can identify it later.
await fn CatchMonsterRound() {
await Spawn round {
// ...
// Create the target monster
let color=PickRandom(roundColors)
let arm=PickRandom(@arm-*.png), chest=PickRandom(@chest-*.png), eye=PickRandom(@eye-*.png)
let horn=PickRandom(@horn-*.png), leg=PickRandom(@leg-*.png), mouth=PickRandom(@mouth-*.png)
Subspawn unit {
IsTargetMonster = true
Monster(
pos=@(SignedRandom * BoundaryHalfWidth, SignedRandom * BoundaryHalfHeight),
color=, arm=, chest=, eye=, horn=, leg=, mouth=,
)
WanderingSystem
CatchMeSystem
}
// ...
}
}
Step 2: Clicking on monsters
Step 2a: Make them clickable
To make our monsters clickable, we need to add a PolygonHotspot to each monster.
We give the PolygonHotspot a PressIntent so that a signal will be sent when the hotspot is clicked on,
which we will handle in the next step.
Because the PressIntent will be the same for all 101 monsters,
we are also adding a PolygonCollider here so that we can quickly find the monster
that was underneath the pointer when a click occurs.
As this is our only purpose for the PolygonCollider in this example,
we set collideWith=Category:None to stop the monsters from colliding with each other and pushing each other around.
pub fn unit.CatchMeSystem([round]) {
use body=this, radius=1
use shape=Rectangle(width=2*radius, height=3*radius)
PolygonHotspot(PressIntent<touchedMonster>)
PolygonCollider(category=Category:Monster, collideWith=Category:None)
}
Step 2b: Handle clicks
This is a multiplayer game, and so each player will handle their own clicks on the monsters.
When a Pressed<touchedMonster> event is received, we will query for the nearest monster to the pointer position
to determine which monster the player clicked on.
- If the player clicked on the target monster, we will send a
MonsterCaughtBysignal to the whole world to say that the monster has been caught by that player. This will be handled in the next step to end the round. - If the player clicked on a decoy monster, we will show "Not me!" so the player knows they clicked on the wrong monster.
pub fn owner.CatchingSystem() {
on Pressed<touchedMonster> {
let unit = Pointer.QueryNearest(filter=Category:Monster)
if !unit { continue }
if unit.IsTargetMonster {
MonsterCaughtBy(owner)
} else {
TextSpark(
"Not me!", fontSize=1, bold=true, layer=1,
body=Pointer, screenOffset=@(0, -1), velocity=@(0, -1),
taper=0, fade=1, dissipate=1s,
)
}
}
}
In a multiplayer game, objects may be spawned and despawned in different sequences for different players,
and so it is not reliable to send an ID identifying which object was clicked on.
It is better to send a PressIntent that says "the player clicked on an object, but we don't know which one"
and then use the pointer position to find the nearest object to the click.
That is what we are doing here with QueryNearest.
Step 3: Win condition
The round will be waiting for a MonsterCaughtBy signal to be sent by one of the players.
Once it receives that signal, the round is over.
We will display a game over dialog for 5 seconds before ending the round.
await fn CatchMonsterRound() {
await Spawn round {
let start = Tick
// ...
// Wait for someone to find the target
let winner = await MonsterCaughtBy
MonsterCaught = true
GameOverDialog(catchDuration=(Tick - start), winner=)
await Tick(5s)
Expire
}
}
Full Code Listing
Thanks to Kenney for the monster art used in this example!
pub const BoundaryHalfWidth = 32
pub const BoundaryHalfHeight = 18
pub tangible category Category:Monster
pub const MonsterColors = [#c44, #c84, #4c8, #48c, #84c]
pub const ChestAspectRatios = {
@chest-1.png = 1,
@chest-2.png = 1,
@chest-3.png = 0.72,
@chest-4.png = 0.95,
@chest-5.png = 0.55,
@chest-6.png = 0.72,
}
pub signal World.MonsterCaughtBy(owner)
pub prop unit.IsTargetMonster = false
pub prop round.MonsterCaught = false
pub game fn World.Main(maxHumanPlayers=5) {
Camera(@(0, 0), radius=@(BoundaryHalfWidth, BoundaryHalfHeight))
SolidBackground(#112)
SpawnEachPlayer owner {
CatchingSystem
}
loop {
await CatchMonsterRound
}
}
await fn CatchMonsterRound() {
await Spawn round {
let start = Tick
// Choose how many colors we will have this round
let numRoundColors = Floor(1 + Random * MonsterColors.Length).Max(2)
let roundColors = Shuffle(MonsterColors.ToArray).Slice(0, numRoundColors)
// Create the sea of monsters
repeat 100 {
Subspawn unit {
Monster(
pos=@(SignedRandom * BoundaryHalfWidth, SignedRandom * BoundaryHalfHeight),
color=PickRandom(roundColors),
arm=PickRandom(@arm-*.png), chest=PickRandom(@chest-*.png), eye=PickRandom(@eye-*.png),
horn=PickRandom(@horn-*.png), leg=PickRandom(@leg-*.png), mouth=PickRandom(@mouth-*.png),
)
WanderingSystem
CatchMeSystem
}
}
// Create the target monster
let color=PickRandom(roundColors)
let arm=PickRandom(@arm-*.png), chest=PickRandom(@chest-*.png), eye=PickRandom(@eye-*.png)
let horn=PickRandom(@horn-*.png), leg=PickRandom(@leg-*.png), mouth=PickRandom(@mouth-*.png)
Subspawn unit {
IsTargetMonster = true
Monster(
pos=@(SignedRandom * BoundaryHalfWidth, SignedRandom * BoundaryHalfHeight),
color=, arm=, chest=, eye=, horn=, leg=, mouth=,
)
WanderingSystem
CatchMeSystem
}
// Display the target monster in a special camera
let exampleCamera = Subspawn camera {
Camera(@(0, 0), radius=2.5)
SolidBackground(#0008)
TextSprite("Catch me if you can!", body=@(0, -2), fontSize=1)
Viewport<profile>(width=12, height=10, anchor=@(-1, 1))
await MonsterCaught
Viewport<profile>(scale=0) // hide the camera once monster has been caught
}
Subspawn unit {
Monster(
pos=@(0, 0),
color=, arm=, chest=, eye=, horn=, leg=, mouth=,
camera=exampleCamera,
)
}
// Wait for someone to find the target
let winner = await MonsterCaughtBy
MonsterCaught = true
GameOverDialog(catchDuration=(Tick - start), winner=)
await Tick(5s)
Expire
}
}
fn this.GameOverDialog(catchDuration, winner) {
BottomContent {
InsetPanel {
P(fontSize=1.5) {
PlayerNameDisplay(winner)
" caught the monster in "
Span(bold=true, color=#e8f) {
%((catchDuration / 1s).FormatWithDecimals(1) + "s")
}
}
}
}
}
pub prop unit.StrideProportion = 0 // a value between -1 and 1 to animate
pub fn unit.Monster(pos, color, arm, chest, eye, horn, leg, mouth, radius=1, [camera?]) {
use body=this
let aspectRatio = ChestAspectRatios[chest] ?? 1
let armOffset = @(0.7 * radius, 0)
let armRadius = 0.75 * radius
let armAngle = -0.1rev
let legOffset = @(0.6 * radius, 0.6 * radius / aspectRatio)
let legRadius = 0.75 * radius
let hornOffset = @(0.4 * radius, -0.7 * radius / aspectRatio)
let hornRadius = 0.25 * radius
let mouthOffset = @(0, 0.3 * radius / aspectRatio)
let mouthRadius = 0.5 * radius
let eyeOffset = @(0.3 * radius, -0.4 * radius / aspectRatio)
let eyeRadius = 0.25 * radius
Body(pos=, noRotation=true)
with StrideProportion p {
let armRotate = 0.05rev * p
let legShift = @(0, 0.1 * p * radius / aspectRatio)
ImageSprite(image=arm, bodyOffset=armOffset, radius=armRadius, angleOffset=armAngle-armRotate, imageAnchor=@(-0.8, -0.8), color=)
ImageSprite(image=arm, bodyOffset=armOffset, radius=armRadius, angleOffset=armAngle+armRotate, imageAnchor=@(-0.8, -0.8), color=, bodyScale=@(-1, 1))
ImageSprite(image=leg, bodyOffset=legOffset + legShift, radius=legRadius, imageAnchor=@(0, -0.8), color=)
ImageSprite(image=leg, bodyOffset=legOffset - legShift, radius=legRadius, imageAnchor=@(0, -0.8), color=, bodyScale=@(-1, 1))
}
ImageSprite(image=chest, color=, radius=radius/aspectRatio)
ImageSprite(image=horn, bodyOffset=hornOffset, radius=hornRadius, imageAnchor=@(-0.8, 0.8), color=)
ImageSprite(image=horn, bodyOffset=hornOffset, radius=hornRadius, imageAnchor=@(-0.8, 0.8), color=, bodyScale=@(-1, 1))
ImageSprite(image=mouth, bodyOffset=mouthOffset, radius=mouthRadius)
ImageSprite(image=eye, bodyOffset=eyeOffset, radius=eyeRadius)
ImageSprite(image=eye, bodyOffset=eyeOffset, radius=eyeRadius, bodyScale=@(-1, 1))
}
pub fn unit.WanderingSystem {
use body=this
on BeforePhysics(2s * Random) {
let target = @(SignedRandom * BoundaryHalfWidth, SignedRandom * BoundaryHalfHeight)
let speedFactor = 0.75 + Random * 0.75
let start = Tick
let strideInterval = 1s * speedFactor
loop {
let diff = target - Pos
if diff.Length < 1 { break }
StrideProportion = Sin(2 * Pi * ((Tick - start) / strideInterval))
Move(0.03 * diff.Direction)
await BeforePhysics
}
}
}
pub fn unit.CatchMeSystem([round]) {
use body=this, radius=1
use shape=Rectangle(width=2*radius, height=3*radius)
PolygonHotspot(PressIntent<touchedMonster>)
PolygonCollider(category=Category:Monster, collideWith=Category:None)
once MonsterCaught {
if IsTargetMonster {
Strobe(growth=1, shine=0.5, dissipate=0.5s)
TextSprite<speech>("Oh no!", screenOffset=@(0, -2.5*radius), fontSize=1, bold=true, strobe=false, layer=1)
await Tick(2s)
TextSprite<speech>("You got me!", screenOffset=@(0, -2.5*radius), fontSize=1, bold=true, strobe=false, layer=1)
} else {
await Tick(1s * Random)
Expire
}
}
}
pub fn owner.CatchingSystem() {
on Pressed<touchedMonster> {
let unit = Pointer.QueryNearest(filter=Category:Monster)
if !unit { continue }
if unit.IsTargetMonster {
MonsterCaughtBy(owner)
} else {
TextSpark(
"Not me!", fontSize=1, bold=true, layer=1,
body=Pointer, screenOffset=@(0, -1), velocity=@(0, -1),
taper=0, fade=1, dissipate=1s,
)
}
}
}