Swimming Physics and Bubble Pattern

Water pixels finally do something other than turn your character blue!

* When the player character is "wet" (touching water pixels, and so appearing in
  a blue mask), water physics apply: gravity is slower, your jump height is
  halved, but you get infinite jumps to swim higher in the water.
* Holding the jump key under water will incur a short delay between jumps, so
  that you don't just fly straight up to the surface. Tap the jump button to
  move up quicker, you can spam it all you want.

Azulians are also able to handle being under water:

* They'll sink to the bottom and keep walking back and forth normally.
* If you are above them and noticed, they'll jump (swim) up towards you,
  aware of the water and it jumps like you do.
* The Blue Azulian has the poorest vertical aggro range so it isn't a
  very good swimmer. The White Azulian is very good at navigating water
  as it can pursue the player from the furthest distance of them all.

Changes to the editor:

* New brush pattern added: bubbles.png
  * It's the default pattern now for the "water" color of all
    of the built-in palettes instead of ink.png
  * A repeating pattern of bubbles carved out showing the
    level wallpaper.
  * The old "Bubbles (circles.png)" is renamed "Circles"
* The last scroll position is saved with the Level file, so when you reload
  the level later it's scrolled at where you left it.
pull/84/head
Noah 2022-05-05 21:35:32 -07:00
parent 4efa8d00fc
commit 94d0da78e7
13 changed files with 200 additions and 129 deletions

BIN
assets/pattern/bubbles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -2,9 +2,11 @@
const color = Self.GetTag("color");
var playerSpeed = color === 'blue' ? 2 : 4,
swimSpeed = playerSpeed * 0.4,
aggroX = 250, // X/Y distance sensitivity from player
aggroY = color === 'blue' ? 100 : 200,
jumpSpeed = color === 'blue' ? 14 : 18,
swimJumpSpeed = jumpSpeed * 0.4,
animating = false,
direction = "right",
lastDirection = "right";
@ -14,7 +16,9 @@ if (color === 'white') {
aggroX = 1000;
aggroY = 400;
playerSpeed = 8;
swimSpeed = playerSpeed * 0.4;
jumpSpeed = 20;
swimJumpSpeed = jumpSpeed * 0.4;
}
function setupAnimations(color) {
@ -30,6 +34,9 @@ function setupAnimations(color) {
function main() {
playerSpeed = color === 'blue' ? 2 : 4;
let swimJumpCooldownTick = 0, // minimum Game Tick before we can jump while swimming
swimJumpCooldown = 10; // CONFIG: delta of ticks between jumps while swimming
Self.SetMobile(true);
Self.SetGravity(true);
Self.SetInventory(true);
@ -95,8 +102,25 @@ function main() {
sampleTick++;
}
let Vx = parseFloat(playerSpeed * (direction === "left" ? -1 : 1)),
Vy = jump && Self.Grounded() ? parseFloat(-jumpSpeed) : Self.GetVelocity().Y;
// Handle being underwater.
let canJump = Self.Grounded();
if (Self.IsWet()) {
let tick = GetTick();
if (tick > swimJumpCooldownTick) {
canJump = true;
swimJumpCooldownTick = tick + swimJumpCooldown;
}
}
// How speedy would our movement and jump be?
let xspeed = playerSpeed, yspeed = jumpSpeed;
if (Self.IsWet()) {
xspeed = swimSpeed;
yspeed = swimJumpSpeed;
}
let Vx = parseFloat(xspeed * (direction === "left" ? -1 : 1)),
Vy = jump && canJump ? parseFloat(-yspeed) : Self.GetVelocity().Y;
Self.SetVelocity(Vector(Vx, Vy));
// If we changed directions, stop animating now so we can

View File

@ -41,7 +41,10 @@ var (
PlayerAcceleration float64 = 0.12
Gravity float64 = 7
GravityAcceleration float64 = 0.1
SlopeMaxHeight = 8 // max pixel height for player to walk up a slope
SwimGravity float64 = 3
SwimJumpVelocity float64 = -12
SwimJumpCooldown uint64 = 24 // number of frames of cooldown between swim-jumps
SlopeMaxHeight = 8 // max pixel height for player to walk up a slope
// Default chunk size for canvases.
ChunkSize = 128

View File

@ -35,6 +35,7 @@ func (c *Collide) Reset() {
c.Left = false
c.Right = false
c.Bottom = false
c.InWater = false
}
// Side of the collision box (top, bottom, left, right)
@ -85,8 +86,6 @@ func CollidesWithGrid(d Actor, grid *level.Chunker, target render.Point) (*Colli
if result.Bottom {
if !d.Grounded() {
d.SetGrounded(true)
} else {
// result.Bottom = false
}
} else {
d.SetGrounded(false)
@ -250,10 +249,10 @@ func (c *Collide) ScanBoundingBox(box render.Rect, grid *level.Chunker) bool {
side Side
}
jobs := []jobSide{ // We'll scan each side of the bounding box in parallel
jobSide{col.Top[0], col.Top[1], Top},
jobSide{col.Bottom[0], col.Bottom[1], Bottom},
jobSide{col.Left[0], col.Left[1], Left},
jobSide{col.Right[0], col.Right[1], Right},
{col.Top[0], col.Top[1], Top},
{col.Bottom[0], col.Bottom[1], Bottom},
{col.Left[0], col.Left[1], Left},
{col.Right[0], col.Right[1], Right},
}
var wg sync.WaitGroup

View File

@ -225,7 +225,11 @@ func (s *EditorScene) setupAsync(d *Doodle) error {
// Scroll the level to the remembered position from when we went
// to Play Mode and back. If no remembered position, this is zero
// anyway.
s.UI.Canvas.ScrollTo(s.RememberScrollPosition)
if s.RememberScrollPosition.IsZero() && s.Level != nil {
s.UI.Canvas.ScrollTo(s.Level.ScrollPosition)
} else {
s.UI.Canvas.ScrollTo(s.RememberScrollPosition)
}
d.Flash("Editor Mode.")
if s.DrawingType == enum.LevelDrawing {
@ -514,6 +518,9 @@ func (s *EditorScene) SaveLevel(filename string) error {
m.Palette = s.UI.Canvas.Palette
m.Chunker = s.UI.Canvas.Chunker()
// Store the scroll position.
m.ScrollPosition = s.UI.Canvas.Scroll
// Clear the modified flag on the level.
s.UI.Canvas.SetModified(false)

View File

@ -37,7 +37,7 @@ var (
Name: "water",
Color: render.MustHexColor("#09F"),
Water: true,
Pattern: "ink.png",
Pattern: "bubbles.png",
},
{
Name: "hint",
@ -89,7 +89,7 @@ var (
Name: "water",
Color: render.RGBA(0, 153, 255, 255),
Water: true,
Pattern: "ink.png",
Pattern: "bubbles.png",
},
{
Name: "hint",
@ -126,7 +126,7 @@ var (
{
Name: "water",
Color: render.MustHexColor("#09F"),
Pattern: "ink.png",
Pattern: "bubbles.png",
},
{
Name: "hint",
@ -159,7 +159,7 @@ var (
Name: "water",
Color: render.RGBA(0, 153, 255, 255),
Water: true,
Pattern: "ink.png",
Pattern: "bubbles.png",
},
{
Name: "electric",

View File

@ -57,6 +57,9 @@ type Level struct {
MaxHeight int64 `json:"boundedHeight"`
Wallpaper string `json:"wallpaper"`
// The last scrolled position in the editor.
ScrollPosition render.Point `json:"scroll"`
// Actors keep a list of the doodad instances in this map.
Actors ActorMap `json:"actors"`

View File

@ -42,6 +42,10 @@ var Builtins = []Pattern{
},
{
Name: "Bubbles",
Filename: "bubbles.png",
},
{
Name: "Circles",
Filename: "circles.png",
},
{

View File

@ -74,6 +74,7 @@ type PlayScene struct {
godMode bool // Cheat: player can't die
godModeUntil time.Time // Invulnerability timer at respawn.
playerJumpCounter int // limit jump length
jumpCooldownUntil uint64 // future game tick for jump cooldown (swimming esp.)
// Inventory HUD. Impl. in play_inventory.go
invenFrame *ui.Frame
@ -211,6 +212,8 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
// Handler when an actor touches water or fire.
s.drawing.OnLevelCollision = func(a *uix.Actor, col *collision.Collide) {
a.SetWet(col.InWater)
if col.InFire != "" {
a.Canvas.MaskColor = render.Black
if a.ID() == "PLAYER" { // only the player dies in fire.
@ -472,8 +475,7 @@ func (s *PlayScene) installPlayerDoodad(filename string, spawn render.Point, cen
// Set up the movement physics for the player.
s.playerPhysics = &physics.Mover{
MaxSpeed: physics.NewVector(balance.PlayerMaxVelocity, balance.PlayerMaxVelocity),
// Gravity: physics.NewVector(balance.Gravity, balance.Gravity),
MaxSpeed: physics.NewVector(balance.PlayerMaxVelocity, balance.PlayerMaxVelocity),
Acceleration: 0.025,
Friction: 0.1,
}
@ -804,111 +806,6 @@ func (s *PlayScene) Draw(d *Doodle) error {
return nil
}
// movePlayer updates the player's X,Y coordinate based on key pressed.
func (s *PlayScene) movePlayer(ev *event.State) {
var (
playerSpeed = float64(balance.PlayerMaxVelocity)
velocity = s.Player.Velocity()
direction float64
jumping bool
)
// Antigravity: player can move anywhere with arrow keys.
if s.antigravity || !s.Player.HasGravity() {
velocity.X = 0
velocity.Y = 0
// Shift to slow your roll to 1 pixel per tick.
if keybind.Shift(ev) {
playerSpeed = 1
}
if keybind.Left(ev) {
velocity.X = -playerSpeed
} else if keybind.Right(ev) {
velocity.X = playerSpeed
}
if keybind.Up(ev) {
velocity.Y = -playerSpeed
} else if keybind.Down(ev) {
velocity.Y = playerSpeed
}
} else {
// Moving left or right.
if keybind.Left(ev) {
direction = -1
} else if keybind.Right(ev) {
direction = 1
}
// Up button to signal they want to jump.
if keybind.Up(ev) {
if s.Player.Grounded() {
velocity.Y = balance.PlayerJumpVelocity
}
} else if velocity.Y < 0 {
velocity.Y = 0
}
// if keybind.Up(ev) && (s.Player.Grounded() || s.playerJumpCounter >= 0) {
// jumping = true
// if s.Player.Grounded() {
// // Allow them to sustain the jump this many ticks.
// s.playerJumpCounter = 32
// }
// }
// Moving left or right? Interpolate their velocity by acceleration.
if direction != 0 {
if s.playerLastDirection != direction {
velocity.X = 0
}
// TODO: fast turn-around if they change directions so they don't
// slip and slide while their velocity updates.
velocity.X = physics.Lerp(
velocity.X,
direction*s.playerPhysics.MaxSpeed.X,
s.playerPhysics.Acceleration,
)
} else {
// Slow them back to zero using friction.
velocity.X = physics.Lerp(
velocity.X,
0,
s.playerPhysics.Friction,
)
}
// Moving upwards (jumping): give them full acceleration upwards.
if jumping {
velocity.Y = -playerSpeed
}
// While in the air, count down their jump counter; when zero they
// cannot jump again until they touch ground.
if !s.Player.Grounded() {
s.playerJumpCounter--
}
}
s.playerLastDirection = direction
// Move the player unless frozen.
// TODO: if Y=0 then gravity fails, but not doing this allows the
// player to jump while frozen. Not a HUGE deal right now as only Warp Doors
// freeze the player currently but do address this later.
if s.Player.IsFrozen() {
velocity.X = 0
}
s.Player.SetVelocity(velocity)
// If the "Use" key is pressed, set an actor flag on the player.
s.Player.SetUsing(keybind.Use(ev))
s.scripting.To(s.Player.ID()).Events.RunKeypress(keybind.FromEvent(ev))
}
// Drawing returns the private world drawing, for debugging with the console.
func (s *PlayScene) Drawing() *uix.Canvas {
return s.drawing

119
pkg/player_physics.go Normal file
View File

@ -0,0 +1,119 @@
package doodle
// Subset of the PlayScene that is responsible for movement of the player character.
import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/physics"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render/event"
)
// movePlayer updates the player's X,Y coordinate based on key pressed.
func (s *PlayScene) movePlayer(ev *event.State) {
var (
playerSpeed = float64(balance.PlayerMaxVelocity)
velocity = s.Player.Velocity()
direction float64
jumping bool
// holdingJump bool // holding down the jump button vs. tapping it
)
// Antigravity: player can move anywhere with arrow keys.
if s.antigravity || !s.Player.HasGravity() {
velocity.X = 0
velocity.Y = 0
// Shift to slow your roll to 1 pixel per tick.
if keybind.Shift(ev) {
playerSpeed = 1
}
if keybind.Left(ev) {
velocity.X = -playerSpeed
} else if keybind.Right(ev) {
velocity.X = playerSpeed
}
if keybind.Up(ev) {
velocity.Y = -playerSpeed
} else if keybind.Down(ev) {
velocity.Y = playerSpeed
}
} else {
// Moving left or right.
if keybind.Left(ev) {
direction = -1
} else if keybind.Right(ev) {
direction = 1
}
// Up button to signal they want to jump.
if keybind.Up(ev) {
if s.Player.IsWet() {
// If they are holding Up put a cooldown in how fast they can swim
// to the surface. Tapping the Jump button allows a faster ascent.
if shmem.Tick > s.jumpCooldownUntil {
s.jumpCooldownUntil = shmem.Tick + balance.SwimJumpCooldown
velocity.Y = balance.SwimJumpVelocity
}
} else if s.Player.Grounded() {
velocity.Y = balance.PlayerJumpVelocity
}
} else {
s.jumpCooldownUntil = 0
if velocity.Y < 0 {
velocity.Y = 0
}
}
// Moving left or right? Interpolate their velocity by acceleration.
if direction != 0 {
if s.playerLastDirection != direction {
velocity.X = 0
}
// TODO: fast turn-around if they change directions so they don't
// slip and slide while their velocity updates.
velocity.X = physics.Lerp(
velocity.X,
direction*s.playerPhysics.MaxSpeed.X,
s.playerPhysics.Acceleration,
)
} else {
// Slow them back to zero using friction.
velocity.X = physics.Lerp(
velocity.X,
0,
s.playerPhysics.Friction,
)
}
// Moving upwards (jumping): give them full acceleration upwards.
if jumping {
velocity.Y = -playerSpeed
}
// While in the air, count down their jump counter; when zero they
// cannot jump again until they touch ground.
if !s.Player.Grounded() {
s.playerJumpCounter--
}
}
s.playerLastDirection = direction
// Move the player unless frozen.
// TODO: if Y=0 then gravity fails, but not doing this allows the
// player to jump while frozen. Not a HUGE deal right now as only Warp Doors
// freeze the player currently but do address this later.
if s.Player.IsFrozen() {
velocity.X = 0
}
s.Player.SetVelocity(velocity)
// If the "Use" key is pressed, set an actor flag on the player.
s.Player.SetUsing(keybind.Use(ev))
s.scripting.To(s.Player.ID()).Events.RunKeypress(keybind.FromEvent(ev))
}

View File

@ -36,6 +36,7 @@ type Actor struct {
// Actor runtime variables.
hasGravity bool
hasInventory bool
wet bool
isMobile bool // Mobile character, such as the player or an enemy
noclip bool // Disable collision detection
hidden bool // invisible, via Hide() and Show()
@ -152,6 +153,16 @@ func (a *Actor) SetInvulnerable(v bool) {
a.immortal = v
}
// Wet returns whether the actor is in contact with water pixels in a level.
func (a *Actor) IsWet() bool {
return a.wet
}
// SetWet updates the state of the actor's wet-ness.
func (a *Actor) SetWet(v bool) {
a.wet = v
}
// Size returns the size of the actor, from the underlying doodads.Drawing.
func (a *Actor) Size() render.Rect {
return a.Drawing.Size()

View File

@ -73,9 +73,13 @@ func (w *Canvas) loopActorCollision() error {
// Apply gravity to the actor's velocity.
if a.hasGravity && !a.Grounded() { //v.Y >= 0 {
if !a.Grounded() {
var gravity = balance.Gravity
if a.IsWet() {
gravity = balance.SwimGravity
}
v.Y = physics.Lerp(
v.Y, // current speed
balance.Gravity, // target max gravity falling downwards
v.Y, // current speed
gravity, // target max gravity falling downwards
balance.GravityAcceleration,
)
} else {
@ -98,12 +102,11 @@ func (w *Canvas) loopActorCollision() error {
// Check collision with level geometry.
chkPoint := delta.ToPoint()
info, ok := collision.CollidesWithGrid(a, w.chunks, chkPoint)
if ok {
// Collision happened with world.
if w.OnLevelCollision != nil {
w.OnLevelCollision(a, info)
}
info, _ := collision.CollidesWithGrid(a, w.chunks, chkPoint)
// Inform the caller about the collision state every tick
if w.OnLevelCollision != nil {
w.OnLevelCollision(a, info)
}
// Move us back where the collision check put us

View File

@ -122,6 +122,7 @@ func (w *Canvas) MakeSelfAPI(actor *Actor) map[string]interface{} {
"Destroy": actor.Destroy,
"Freeze": actor.Freeze,
"Unfreeze": actor.Unfreeze,
"IsWet": actor.IsWet,
"Hide": actor.Hide,
"Show": actor.Show,
"GetLinks": func() []map[string]interface{} {