Skip to main content

Clicking Objects

Open this Example

Clicking Objects example

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.

info

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 MonsterCaughtBy signal 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,
)
}
}
}
info

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,
)
}
}
}
Open in Editor