Files
aego/modules/gameplay/motion_solve.go
sanspie 2e10968d48 Stage 11f3: landing, Getting started, guides; demo fixes (D347, D289)
Landing page and Install Aego / Your first project in English and Russian; the 2D template matches them. Platformer from scratch and Recipes with every step scene run by docs check. Builtin data and handle methods in the reference, Inspector names in field tables, Markdown copies and llms-full.txt, strict docs check in CI, engine/docs/README.md.
Fixes: a body cast from a touching contact uses the face normal, so characters no longer stick after a respawn or against walls; Respawn.resetVelocity works; a touch gamepad on exported web pages.
2026-10-25 15:31:22 +03:00

681 lines
17 KiB
Go

package gameplay
import (
"aego/core/ids"
"aego/engine/recorder"
"math"
"aego/contracts/physics2d"
"aego/contracts/scriptcall"
"aego/core/log"
"aego/core/mathx"
"aego/core/rewind"
"aego/engine"
"aego/scene"
"aego/scene/propclaim"
"aego/scene/propref"
"aego/script/vm"
)
const eps2 = 1e-12
type hitKind uint8
const (
hitFloor hitKind = iota
hitWall
hitCeiling
)
type pass struct {
h scene.NodeHandle
c *Motion2D
f physics2d.QueryFilter
up mathx.Vec2
right mathx.Vec2
cosMax float32
margin float32
floor bool
wall bool
ceil bool
floorN mathx.Vec2
wallN mathx.Vec2
ground scene.NodeHandle
}
type Stats struct {
Hits uint64
Deaths uint64
Respawns uint64
Pickups uint64
Controllers uint64
Casts uint64
Steps uint64
}
type Service struct {
scene *scene.Scene
world *engine.World
q physics2d.Queries
sig *signals
log *log.ChannelLogger
dt float32
g float32
game float32
real float32
p pass
pen [4]physics2d.CastHit
hurts []hurtRec
cand []scene.NodeHandle
entered []scene.NodeHandle
stats Stats
hh *rewind.Hasher
call scriptcall.Caller
fdt float32
args [1]vm.Value
fsmFixFn func(scene.NodeHandle, *StateMachine)
cl *propclaim.Table
owner propclaim.Owner
fsmUpdFn func(scene.NodeHandle, *StateMachine)
stepFn func(scene.NodeHandle, *Motion2D)
postFn func(scene.NodeHandle, *Motion2D)
hurtFn func(scene.NodeHandle, *Hurtbox2D)
hitFn func(scene.NodeHandle, *Hitbox2D)
killFn func(scene.NodeHandle, *KillZone2D)
cpFn func(scene.NodeHandle, *Checkpoint2D)
pickFn func(scene.NodeHandle, *Pickup2D)
respFn func(scene.NodeHandle, *Respawn)
hpFixFn func(scene.NodeHandle, *Health)
hpFn func(scene.NodeHandle, *Health)
lifeFn func(scene.NodeHandle, *Lifetime)
candHealth func(scene.NodeHandle, *Health)
candResp func(scene.NodeHandle, *Respawn)
candMotion func(scene.NodeHandle, *Motion2D)
hashFns hashFns
}
func NewService(s *scene.Scene, w *engine.World, q physics2d.Queries) *Service {
svc := &Service{scene: s, world: w, q: q}
svc.stepFn = svc.stepOne
svc.postFn = svc.postOne
svc.hurtFn = svc.collectHurt
svc.hitFn = svc.hitOne
svc.killFn = svc.killOne
svc.cpFn = svc.checkpointOne
svc.pickFn = svc.pickupOne
svc.respFn = svc.respawnOne
svc.hpFixFn = svc.healthFixed
svc.hpFn = svc.flushHealth
svc.lifeFn = svc.lifeOne
svc.fsmFixFn = svc.fsmFixed
svc.fsmUpdFn = svc.fsmUpdate
svc.candHealth = func(h scene.NodeHandle, _ *Health) { svc.cand = append(svc.cand, h) }
svc.candResp = func(h scene.NodeHandle, _ *Respawn) {
if !scene.Has[Health](svc.scene, h) {
svc.cand = append(svc.cand, h)
}
}
svc.candMotion = func(h scene.NodeHandle, _ *Motion2D) { svc.cand = append(svc.cand, h) }
svc.hashFns = newHashFns(svc)
return svc
}
// queries finds the physics queries lazily: physics may attach to the world after this module.
func (s *Service) queries() physics2d.Queries {
if s.q == nil && s.world != nil {
s.q, _ = engine.ServiceOf[physics2d.Queries](s.world, physics2d.ServiceID)
}
return s.q
}
func (s *Service) Stats() Stats {
return s.stats
}
func (s *Service) Fixed(dt float32) {
s.dt = dt
q := s.queries()
if q == nil || dt <= 0 {
return
}
s.g = q.Gravity().Len()
s.stats.Steps++
s.cl.Begin(s.owner)
scene.Store[Motion2D](s.scene).EachAll(s.stepFn)
s.cl.End(s.owner)
}
func (s *Service) claim(h scene.NodeHandle) {
if s.cl == nil {
return
}
pos := propref.TransformRef(s.scene, h, propref.PartPosition)
rot := propref.TransformRef(s.scene, h, propref.PartRotation)
s.cl.Claim(s.owner, h, &pos)
s.cl.Claim(s.owner, h, &rot)
}
func (s *Service) Post() {
scene.Query1[Motion2D](s.scene).Each(s.postFn)
if s.dt <= 0 {
return
}
s.hurts = s.hurts[:0]
scene.Query1[Hurtbox2D](s.scene).Each(s.hurtFn)
scene.Query1[Hitbox2D](s.scene).Each(s.hitFn)
scene.Query1[KillZone2D](s.scene).Each(s.killFn)
scene.Query1[Checkpoint2D](s.scene).Each(s.cpFn)
scene.Query1[Pickup2D](s.scene).Each(s.pickFn)
scene.Query1[Respawn](s.scene).Each(s.respFn)
scene.Query1[Health](s.scene).Each(s.hpFixFn)
}
func (s *Service) Frame(game, real float32) {
s.game, s.real = game, real
scene.Query1[Health](s.scene).Each(s.hpFn)
scene.Query1[Lifetime](s.scene).Each(s.lifeFn)
}
func normalize(v mathx.Vec2) (mathx.Vec2, float32) {
l := v.Len()
if l < 1e-6 {
return mathx.Vec2{}, 0
}
return v.Scale(1 / l), l
}
func approach(v, target, d float32) float32 {
if v < target {
return min(v+d, target)
}
return max(v-d, target)
}
func approach2(v, target mathx.Vec2, d float32) mathx.Vec2 {
diff, l := normalize(target.Sub(v))
if l <= d {
return target
}
return v.Add(diff.Scale(d))
}
func jumpSpeed(c *Motion2D, g float32) float32 {
if c.JumpSpeed > 0 {
return c.JumpSpeed
}
return float32(math.Sqrt(float64(2 * g * c.GravityScale * c.JumpHeight)))
}
func (p *pass) kind(n mathx.Vec2) hitKind {
if p.c.Mode == TopDown {
return hitWall
}
switch d := n.Dot(p.up); {
case d >= p.cosMax:
return hitFloor
case d <= -p.cosMax:
return hitCeiling
}
return hitWall
}
func (p *pass) note(k hitKind, n mathx.Vec2, node scene.NodeHandle) {
switch k {
case hitFloor:
p.floor, p.floorN, p.ground = true, n, node
case hitCeiling:
p.ceil = true
default:
p.wall, p.wallN = true, n
}
}
func (s *Service) cast(from, delta mathx.Vec2) (physics2d.CastHit, bool) {
if delta.LenSq() < eps2 {
return physics2d.CastHit{}, false
}
s.stats.Casts++
return s.q.CastBody(s.p.h, from, delta, s.p.f)
}
func (s *Service) warn(h scene.NodeHandle, c *Motion2D, err error) {
if c.st.warned || s.log == nil {
return
}
c.st.warned = true
s.log.Warn(CodeNotKinematic+": Motion 2D needs a kinematic Body2D on its node", log.F("node", s.scene.Path(h)), log.F("error", err.Error()))
}
func (s *Service) AddDisplacement(node scene.NodeHandle, d mathx.Vec2) bool {
m, ok := scene.Get[Motion2D](s.scene, node)
if !ok {
return false
}
m.AddDisplacement(d)
return true
}
func (s *Service) stepOne(h scene.NodeHandle, c *Motion2D) {
if !s.scene.EffectiveEnabled(h) {
c.st.disp = mathx.Vec2{}
return
}
s.stats.Controllers++
s.claim(h)
pos, ang, ok := s.q.BodyPose(h)
if !ok {
return
}
st := &c.st
if st.teleport {
to, v := st.teleportTo, mathx.Vec2{}
if st.keepVel {
v = c.Velocity
}
*st = motionState{move: st.move, warned: st.warned}
c.Velocity, c.Grounded, c.OnWall, c.OnCeiling, c.Climbing = v, false, false, false, false
c.GroundBody = scene.NodeRef{}
if err := s.q.Teleport(h, to, ang); err != nil {
s.warn(h, c, err)
}
return
}
up, _ := normalize(c.UpDirection)
if up == (mathx.Vec2{}) {
up = mathx.Vec2{Y: -1}
}
s.p = pass{
h: h, c: c, up: up, right: mathx.Vec2{X: -up.Y, Y: up.X},
cosMax: mathx.Cos(mathx.Deg2Rad(c.MaxSlope)), margin: max(c.SafeMargin, 0.001),
f: physics2d.QueryFilter{LayerMask: uint32(c.CollisionMask), SkipDynamic: c.PushBodies, ExcludeBody: h},
}
dt := s.dt
var carry mathx.Vec2
if c.MovingPlatforms && st.grounded && !st.ground.IsZero() && s.scene.IsAlive(st.ground) {
if pv, _, ok := s.q.BodyVelocity(st.ground); ok {
st.platVel, carry = pv, pv.Scale(dt)
}
}
if st.climbLeft > 0 {
if !st.stop && st.dashLeft <= 0 && st.addVel == (mathx.Vec2{}) {
st.disp, st.jumpReq = mathx.Vec2{}, false
s.climb(h, c, pos, ang)
return
}
st.climbLeft, c.Climbing = 0, false
}
prevUp := c.Velocity.Dot(up)
v, jumped := c.Velocity, false
if c.Mode == TopDown {
v = s.topDown(c, v, dt)
} else {
v, jumped = s.platformer(c, v, dt)
}
if st.dashLeft > 0 {
v = st.dashVel
if c.Mode == Platformer && c.DashGravity {
v = v.Sub(up.Scale(s.g * c.GravityScale * dt))
}
st.dashLeft -= dt
}
v = v.Add(st.addVel)
st.addVel = mathx.Vec2{}
if st.stop {
v, st.stop, st.dashLeft = mathx.Vec2{}, false, 0
}
off := s.depenetrate()
move := v.Scale(dt).Add(carry).Add(st.disp)
st.disp = mathx.Vec2{}
off = s.slide(off, move, &v)
off = s.snap(off, jumped)
if s.ledge(c, pos, off, v) {
c.Velocity = mathx.Vec2{}
s.climb(h, c, pos, ang)
return
}
s.finish(h, c, pos, ang, off, v, prevUp, jumped)
}
// ledge starts a climb when the airborne character moves into a wall whose top is within Ledge Climb above its feet.
func (s *Service) ledge(c *Motion2D, pos, off, v mathx.Vec2) bool {
p, st := &s.p, &c.st
if c.Mode != Platformer || c.LedgeClimb <= 0 || p.floor || st.grounded || !p.wall {
return false
}
dir := st.move.X
if dir == 0 || p.wallN.Dot(p.right)*dir >= 0 || v.Dot(p.up) > jumpSpeed(c, s.g)*0.5 {
return false
}
lift := p.up.Scale(c.LedgeClimb)
if _, hit := s.cast(off, lift); hit {
return false
}
reach := p.right.Scale(mathx.Sign(dir) * c.LedgeClimb)
o := off.Add(lift)
if h, hit := s.cast(o, reach); hit {
if h.Initial || h.Fraction*c.LedgeClimb < c.LedgeClimb*0.5 {
return false
}
reach = reach.Scale(max(h.Fraction-p.margin/c.LedgeClimb, 0))
}
drop := p.up.Scale(-c.LedgeClimb)
h, hit := s.cast(o.Add(reach), drop)
if !hit || h.Initial || p.kind(h.Normal) != hitFloor {
return false
}
rise := c.LedgeClimb*(1-h.Fraction) - p.margin
if rise <= p.margin {
return false
}
st.climbFrom, st.climbRise, st.climbFwd = pos.Add(off), p.up.Scale(rise), reach
st.climbLeft, st.climbFloor = max(c.ClimbTime, s.dt), h.Node
st.events |= evClimbed
return true
}
func smooth(t float32) float32 {
t = mathx.Clamp(t, 0, 1)
return t * t * (3 - 2*t)
}
// climb moves the character along the ledge path: up first, then onto the ledge, and lands it there.
func (s *Service) climb(h scene.NodeHandle, c *Motion2D, pos mathx.Vec2, ang float32) {
p, st := &s.p, &c.st
total := max(c.ClimbTime, s.dt)
st.climbLeft -= s.dt
t := 1 - max(st.climbLeft, 0)/total
at := st.climbFrom.Add(st.climbRise.Scale(smooth(t / 0.6))).Add(st.climbFwd.Scale(smooth((t - 0.4) / 0.6)))
done := st.climbLeft <= 0
if done {
st.climbLeft = 0
p.floor, p.floorN, p.ground = true, p.up, st.climbFloor
}
c.Climbing = !done
s.finish(h, c, pos, ang, at.Sub(pos), mathx.Vec2{}, 0, false)
}
func (s *Service) platformer(c *Motion2D, v mathx.Vec2, dt float32) (mathx.Vec2, bool) {
p, st := &s.p, &c.st
grounded := st.grounded
if grounded {
st.coyote = c.CoyoteTime
} else {
st.coyote -= dt
}
if st.jumpReq {
st.buffer, st.jumpReq = max(c.JumpBuffer, dt*0.5), false
}
tangent := p.right
if grounded && st.floorN != (mathx.Vec2{}) {
tangent = mathx.Vec2{X: -st.floorN.Y, Y: st.floorN.X}
if tangent.Dot(p.right) < 0 {
tangent = tangent.Neg()
}
}
vx, vy := v.Dot(tangent), v.Dot(p.up)
target := st.move.X * c.MaxSpeed
rate := c.Decel
if target != 0 && (vx*target <= 0 || mathx.Abs(target) > mathx.Abs(vx)) {
rate = c.Accel
}
if !grounded {
rate *= c.AirControl
}
vx = approach(vx, target, rate*dt)
jumped := false
if st.buffer > 0 && (grounded || st.coyote > 0) {
vy, jumped, st.jumping = jumpSpeed(c, s.g), true, true
st.buffer, st.coyote = 0, 0
st.events |= evJumped
}
st.buffer -= dt
if grounded && !jumped {
st.jumpRel = false
return tangent.Scale(vx), false
}
if st.dashLeft <= 0 {
vy -= s.g * c.GravityScale * dt
}
if c.MaxFallSpeed > 0 {
vy = max(vy, -c.MaxFallSpeed)
}
if st.jumpRel && st.jumping && vy > 0 {
vy *= c.JumpCut
st.jumping = false
}
if vy <= 0 {
st.jumping = false
}
st.jumpRel = false
return p.right.Scale(vx).Add(p.up.Scale(vy)), jumped
}
func (s *Service) topDown(c *Motion2D, v mathx.Vec2, dt float32) mathx.Vec2 {
dir := c.st.move
if l := dir.Len(); l > 1 {
dir = dir.Scale(1 / l)
}
target := dir.Scale(c.MaxSpeed)
rate := c.Decel
if target.LenSq() > 0 {
rate = c.Accel
}
return approach2(v, target, rate*dt)
}
func (s *Service) depenetrate() mathx.Vec2 {
p := &s.p
var off mathx.Vec2
n := s.q.Penetration(p.h, p.f, s.pen[:])
for _, hit := range s.pen[:n] {
if need := hit.Depth + p.margin - off.Dot(hit.Normal); need > 0 {
off = off.Add(hit.Normal.Scale(need))
}
}
return off
}
func (s *Service) slide(off, move mathx.Vec2, v *mathx.Vec2) mathx.Vec2 {
p := &s.p
for i := int32(0); i < p.c.MaxSlides && move.LenSq() > eps2; i++ {
hit, ok := s.cast(off, move)
if !ok {
return off.Add(move)
}
n := hit.Normal
if hit.Initial {
off = off.Add(n.Scale(hit.Depth + p.margin))
if d := move.Dot(n); d < 0 {
move = move.Sub(n.Scale(d))
}
continue
}
dir, l := normalize(move)
t := max(hit.Fraction*l-p.margin, 0)
off = off.Add(dir.Scale(t))
rest := dir.Scale(l - t)
k := p.kind(n)
if k == hitWall && s.stepUp(&off, rest) {
return off
}
p.note(k, n, hit.Node)
if k == hitWall && p.c.Mode == Platformer {
if gap := hit.Fraction * l; gap < p.margin && !p.c.st.grounded {
off = off.Add(n.Scale(p.margin - gap))
}
rest, *v = wallSlide(rest, n, p.up, p.right), wallSlide(*v, n, p.up, p.right)
move = rest
continue
}
if d := rest.Dot(n); d < 0 {
rest = rest.Sub(n.Scale(d))
}
if d := v.Dot(n); d < 0 {
*v = v.Sub(n.Scale(d))
}
move = rest
}
return off
}
// wallSlide moves along a platformer wall: no push into it, a slide along its surface, and never more rise than before.
func wallSlide(m, n, up, right mathx.Vec2) mathx.Vec2 {
was := max(m.Dot(up), 0)
if h := m.Dot(right); h*n.Dot(right) < 0 {
m = m.Sub(right.Scale(h))
}
if d := m.Dot(n); d < 0 {
m = m.Sub(n.Scale(d))
}
if u := m.Dot(up); u > was {
m = m.Sub(up.Scale(u - was))
}
return m
}
func (s *Service) stepUp(off *mathx.Vec2, rest mathx.Vec2) bool {
p := &s.p
c := p.c
if c.Mode != Platformer || c.StepHeight <= 0 || !c.st.grounded {
return false
}
fwd := p.right.Scale(rest.Dot(p.right))
fl := fwd.Len()
if fl < 1e-6 {
return false
}
lift := p.up.Scale(c.StepHeight)
if hit, ok := s.cast(*off, lift); ok {
lift = lift.Scale(max(hit.Fraction-p.margin/c.StepHeight, 0))
}
o := off.Add(lift)
if hit, ok := s.cast(o, fwd); ok {
t := max(hit.Fraction*fl-p.margin, 0)
if hit.Initial || t < p.margin {
return false
}
fwd = fwd.Scale(t / fl)
}
o = o.Add(fwd)
dl := lift.Len() + 2*p.margin
drop := p.up.Scale(-dl)
hit, ok := s.cast(o, drop)
if !ok || hit.Initial || p.kind(hit.Normal) != hitFloor {
return false
}
*off = o.Add(drop.Scale(max(hit.Fraction*dl-p.margin, 0) / dl))
p.note(hitFloor, hit.Normal, hit.Node)
return true
}
func (s *Service) snap(off mathx.Vec2, jumped bool) mathx.Vec2 {
p := &s.p
c := p.c
if c.Mode != Platformer || p.floor || jumped || !c.st.grounded {
return off
}
dist := max(c.SnapToGround, 2*p.margin)
down := p.up.Scale(-dist)
hit, ok := s.cast(off, down)
if !ok || hit.Initial || p.kind(hit.Normal) != hitFloor {
return off
}
p.note(hitFloor, hit.Normal, hit.Node)
return off.Add(down.Scale(max(hit.Fraction*dist-p.margin, 0) / dist))
}
func (s *Service) finish(h scene.NodeHandle, c *Motion2D, pos mathx.Vec2, ang float32, off, v mathx.Vec2, prevUp float32, jumped bool) {
p, st := &s.p, &c.st
grounded := p.floor && !jumped
if grounded && !st.grounded {
st.events |= evLanded
st.landSpeed = max(-prevUp, 0)
}
if !grounded && st.grounded {
if !jumped {
st.events |= evLeft
}
switch c.PlatformOnLeave {
case LeaveKeep:
v = v.Add(st.platVel)
case LeaveAdd:
v = v.Add(p.right.Scale(st.platVel.Dot(p.right)))
}
st.platVel = mathx.Vec2{}
}
if p.wall && !c.OnWall {
st.events |= evWall
st.wallN = p.wallN
}
if p.ceil && !c.OnCeiling {
st.events |= evCeiling
}
if grounded {
if d := v.Dot(p.floorN); d < 0 {
v = v.Sub(p.floorN.Scale(d))
}
st.floorN, st.ground = p.floorN, p.ground
c.FloorNormal = p.floorN
c.FloorAngle = mathx.Rad2Deg(float32(math.Acos(float64(mathx.Clamp(p.floorN.Dot(p.up), -1, 1)))))
c.GroundBody.Set(s.scene, p.ground)
} else {
st.floorN, st.ground = mathx.Vec2{}, scene.NodeHandle{}
c.FloorNormal, c.FloorAngle, c.GroundBody = mathx.Vec2{}, 0, scene.NodeRef{}
}
st.grounded = grounded
c.Velocity, c.Grounded, c.OnWall, c.OnCeiling, c.WallNormal = v, grounded, p.wall, p.ceil, p.wallN
angle := ang
if c.Mode == TopDown && c.FaceMovement && v.LenSq() > 1e-4 {
angle = mathx.Atan2(v.Y, v.X)
}
if err := s.q.MoveKinematic(h, pos.Add(off), angle, s.dt); err != nil {
s.warn(h, c, err)
}
}
func (s *Service) postOne(h scene.NodeHandle, c *Motion2D) {
st := &c.st
ev := st.events
if ev == 0 {
return
}
st.events = 0
if s.world == nil || s.sig == nil {
return
}
if ev&evJumped != 0 {
s.sig.jumped.Emit(s.world, h)
}
if ev&evLeft != 0 {
s.sig.left.Emit(s.world, h)
}
if ev&evLanded != 0 {
s.sig.landed.Emit(s.world, h, st.landSpeed)
}
if ev&evWall != 0 {
s.sig.wall.Emit(s.world, h, st.wallN)
}
if ev&evCeiling != 0 {
s.sig.ceiling.Emit(s.world, h)
}
if ev&evClimbed != 0 {
s.sig.climbed.Emit(s.world, h)
}
}
var amounts = [...]string{"", "1", "2", "3", "4", "5", "6", "7", "8", "9"}
func amount(n int32) string {
if n > 0 && int(n) < len(amounts) {
return amounts[n]
}
return ""
}
// record adds a gameplay entry to the Event Monitor; name and detail are static strings, so it does not allocate.
func (s *Service) record(h scene.NodeHandle, name, detail string) {
if s.world != nil && s.world.Recorder != nil {
s.world.Recorder.Add(recorder.Gameplay, s.scene.ID(h), ids.NodeID{}, name, detail)
}
}