1
0
forked from 0ad/0ad

# Improved unit animation in new simulation system

Tried to make the motion/AI/animation state transitions saner
Added smoothed rotation of moving units
Slightly more informative error reporting

This was SVN commit r7319.
This commit is contained in:
Ykkrosh 2010-02-10 19:28:46 +00:00
parent 4cf5e1e394
commit 60a9e63c71
9 changed files with 240 additions and 91 deletions

View File

@ -33,7 +33,7 @@ Timer.prototype.OnUpdate = function(msg)
try {
cmp[t[2]](t[4]);
} catch (e) {
print("Error in timer on entity "+t[0]+", IID "+t[1]+", function "+t[2]+": "+e);
print("Error in timer on entity "+t[0]+", IID "+t[1]+", function "+t[2]+": "+e+"\n");
// TODO: should report in an error log
}
delete this.timers[id];

View File

@ -36,26 +36,8 @@ UnitAI.prototype.Init = function()
this.attackRechargeTime = 0;
// Timer for AttackTimeout
this.attackTimer = undefined;
this.nextAnimation = undefined;
};
UnitAI.prototype.OnDestroy = function()
{
if (this.attackTimer)
{
cmpTimer.CancelTimer(this.attackTimer);
this.attackTimer = undefined;
}
};
UnitAI.prototype.OnTurnStart = function()
{
if (this.nextAnimation)
{
this.SelectAnimation(this.nextAnimation.name, this.nextAnimation.once, this.nextAnimation.speed);
this.nextAnimation = undefined;
}
// Current target entity ID
this.attackTarget = undefined;
};
//// Interface functions ////
@ -79,35 +61,102 @@ UnitAI.prototype.Attack = function(target)
if (!cmpAttack)
return;
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
// Remember the target, and start moving towards it
this.attackTarget = target;
this.MoveToTarget(this.attackTarget);
this.state = STATE_ATTACKING;
// Cancel any previous attack timer
if (this.attackTimer)
{
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
cmpTimer.CancelTimer(this.attackTimer);
// TODO: start the attack animation here
// TODO: should check the range and move closer before attempting to attack
// Perform the attack after the prepare time, but not before the previous attack's recharge
var timers = cmpAttack.GetTimers();
var time = Math.max(timers.prepare, this.attackRechargeTime - cmpTimer.GetTime());
var data = { "target": target, "timers": timers };
this.attackTimer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "AttackTimeout", time, data);
this.state = STATE_ATTACKING;
this.attackTimer = undefined;
}
};
//// Message handlers ////
UnitAI.prototype.OnMotionStopped = function()
UnitAI.prototype.OnDestroy = function()
{
this.SelectAnimationDelayed("idle");
if (this.attackTimer)
{
cmpTimer.CancelTimer(this.attackTimer);
this.attackTimer = undefined;
}
};
UnitAI.prototype.OnMotionChanged = function(msg)
{
if (msg.speed)
{
// Started moving
// => play the appropriate animation
this.SelectAnimation("walk", false, msg.speed);
}
else
{
if (this.state == STATE_WALKING)
{
// Stopped walking
this.state = STATE_IDLE;
this.SelectAnimation("idle");
}
else if (this.state == STATE_ATTACKING)
{
// We were attacking, and have stopped moving
// => check if we can still reach the target now
if (!this.MoveIntoAttackRange())
return;
// In range, so perform the attack,
// after the prepare time but not before the previous attack's recharge
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
var timers = cmpAttack.GetTimers();
var time = Math.max(timers.prepare, this.attackRechargeTime - cmpTimer.GetTime());
this.attackTimer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "AttackTimeout", time, {});
// Start the idle animation before we switch to the attack
this.SelectAnimation("idle");
}
}
};
//// Private functions ////
/**
* Tries to move into range of the attack target.
* Returns true if it's already in range.
*/
UnitAI.prototype.MoveIntoAttackRange = function()
{
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
var rangeStatus = cmpAttack.CheckRange(this.attackTarget);
if (rangeStatus.error)
{
if (rangeStatus.error == "out-of-range")
{
// Out of range => need to move closer
// (The target has probably moved while we were chasing it)
this.MoveToTarget(this.attackTarget);
return false;
}
// Otherwise it's impossible to reach the target, so give up
// and switch back to idle
this.state = STATE_IDLE;
this.SelectAnimation("idle");
return false;
}
return true;
};
UnitAI.prototype.SelectAnimation = function(name, once, speed)
{
var cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
@ -115,15 +164,8 @@ UnitAI.prototype.SelectAnimation = function(name, once, speed)
return;
cmpVisual.SelectAnimation(name, once, speed);
this.nextAnimation = undefined;
};
UnitAI.prototype.SelectAnimationDelayed = function(name, once, speed)
{
this.nextAnimation = { "name": name, "once": once, "speed": speed };
}
UnitAI.prototype.MoveToTarget = function(target)
{
var cmpPositionTarget = Engine.QueryInterface(target, IID_Position);
@ -132,8 +174,6 @@ UnitAI.prototype.MoveToTarget = function(target)
var cmpMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
this.SelectAnimation("walk", false, cmpMotion.GetSpeed());
var pos = cmpPositionTarget.GetPosition();
cmpMotion.MoveToPoint(pos.x, pos.z, 0, 1);
};
@ -144,40 +184,25 @@ UnitAI.prototype.AttackTimeout = function(data)
if (this.state != STATE_ATTACKING)
return;
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
// Check if we can still reach the target
var rangeStatus = cmpAttack.CheckRange(data.target);
if (rangeStatus.error)
{
if (rangeStatus.error == "out-of-range")
{
// Out of range => need to move closer
this.MoveToTarget(data.target);
// Try again in a couple of seconds
// (TODO: ought to have a cleverer way of detecting once we're back in range)
this.attackTimer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "AttackTimeout", 2000, data);
return;
}
// Otherwise it's impossible to reach the target, so give up
// and switch back to idle
this.state = STATE_IDLE;
this.SelectAnimation("idle");
if (!this.MoveIntoAttackRange())
return;
}
// Play the attack animation
this.SelectAnimationDelayed("melee", false, 1);
this.SelectAnimation("melee", false, 1);
// Hit the target
cmpAttack.PerformAttack(data.target);
cmpAttack.PerformAttack(this.attackTarget);
// Set a timer to hit the target again
this.attackRechargeTime = cmpTimer.GetTime() + data.timers.recharge;
this.attackTimer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "AttackTimeout", data.timers.repeat, data);
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
var timers = cmpAttack.GetTimers();
this.attackRechargeTime = cmpTimer.GetTime() + timers.recharge;
this.attackTimer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "AttackTimeout", timers.repeat, data);
};
Engine.RegisterComponentType(IID_UnitAI, "UnitAI", UnitAI);

View File

@ -68,8 +68,8 @@ public:
{
}
float frameTime;
float offset;
float frameTime; // time in seconds since previous interpolate
float offset; // range [0, 1] (inclusive); fractional time of current frame between previous/next simulation turns
};
class CMessageRenderSubmit : public CMessage
@ -140,14 +140,21 @@ public:
entity_angle_t a;
};
class CMessageMotionStopped : public CMessage
/**
* Sent by CCmpUnitMotion during Update, whenever the motion status has changed
* since the previous update.
*/
class CMessageMotionChanged : public CMessage
{
public:
DEFAULT_MESSAGE_IMPL(MotionStopped)
DEFAULT_MESSAGE_IMPL(MotionChanged)
CMessageMotionStopped()
CMessageMotionChanged(CFixed_23_8 speed) :
speed(speed)
{
}
CFixed_23_8 speed; // metres per second, or 0 if not moving
};
#endif // INCLUDED_MESSAGETYPES

View File

@ -37,7 +37,7 @@ MESSAGE(RenderSubmit) // non-deterministic (use with caution)
MESSAGE(Destroy)
MESSAGE(OwnershipChanged)
MESSAGE(PositionChanged)
MESSAGE(MotionStopped)
MESSAGE(MotionChanged)
// TemplateManager must come before all other (non-test) components,
// so that it is the first to be (de)serialized
@ -79,7 +79,7 @@ INTERFACE(PlayerManager)
COMPONENT(PlayerManagerScripted)
INTERFACE(Position)
COMPONENT(Position)
COMPONENT(Position) // must be before VisualActor
INTERFACE(Selectable)
COMPONENT(Selectable)

View File

@ -40,6 +40,7 @@ public:
static void ClassInit(CComponentManager& componentManager)
{
componentManager.SubscribeToMessageType(MT_TurnStart);
componentManager.SubscribeToMessageType(MT_Interpolate);
// TODO: if this component turns out to be a performance issue, it should
// be optimised by creating a new PositionStatic component that doesn't subscribe
@ -62,6 +63,7 @@ public:
entity_pos_t m_YOffset;
bool m_Floating;
float m_RotYSpeed; // maximum radians per second, used by InterpolatedRotY to follow RotY
// Dynamic state:
@ -69,6 +71,7 @@ public:
entity_pos_t m_X, m_Z, m_LastX, m_LastZ; // these values contain undefined junk if !InWorld
entity_angle_t m_RotX, m_RotY, m_RotZ;
float m_InterpolatedRotY; // not serialized
bool m_Dirty; // true if position/rotation has changed since last TurnStart
@ -110,7 +113,10 @@ public:
m_YOffset = paramNode.GetChild("Altitude").ToFixed();
m_Floating = paramNode.GetChild("Floating").ToBool();
m_RotYSpeed = 6.f; // TODO: should get from template
m_RotX = m_RotY = m_RotZ = entity_angle_t::FromInt(0);
m_InterpolatedRotY = 0;
m_Dirty = false;
}
@ -128,6 +134,8 @@ public:
serialize.NumberFixed_Unbounded("z", m_Z);
serialize.NumberFixed_Unbounded("last x", m_LastX);
serialize.NumberFixed_Unbounded("last z", m_LastZ);
// TODO: for efficiency, we probably shouldn't actually store the last position - it doesn't
// matter if we don't have smooth interpolation after reloading a game
}
serialize.NumberFixed_Unbounded("rot x", m_RotX);
serialize.NumberFixed_Unbounded("rot y", m_RotY);
@ -167,6 +175,8 @@ public:
deserialize.NumberFixed_Unbounded(m_YOffset);
deserialize.Bool(m_Dirty);
// TODO: should there be range checks on all these values?
m_InterpolatedRotY = m_RotY.ToFloat();
}
virtual bool IsInWorld()
@ -239,9 +249,17 @@ public:
return CFixedVector3D(m_X, ground + m_YOffset, m_Z);
}
virtual void TurnTo(entity_angle_t y)
{
m_RotY = y;
m_Dirty = true;
}
virtual void SetYRotation(entity_angle_t y)
{
m_RotY = y;
m_InterpolatedRotY = m_RotY.ToFloat();
m_Dirty = true;
}
@ -286,8 +304,8 @@ public:
CMatrix3D m;
CMatrix3D mXZ;
float Cos = cosf(m_RotY.ToFloat());
float Sin = sinf(m_RotY.ToFloat());
float Cos = cosf(m_InterpolatedRotY);
float Sin = sinf(m_InterpolatedRotY);
m.SetIdentity();
m._11 = -Cos;
@ -309,7 +327,26 @@ public:
{
switch (msg.GetType())
{
case MT_Interpolate:
{
const CMessageInterpolate& msgData = static_cast<const CMessageInterpolate&> (msg);
float rotY = m_RotY.ToFloat();
float delta = rotY - m_InterpolatedRotY;
// Wrap delta to -PI..PI
delta = fmod(delta + PI, 2*PI); // range -2PI..2PI
if (delta < 0) delta += 2*PI; // range 0..2PI
delta -= PI; // range -PI..PI
// Clamp to max rate
float deltaClamped = clamp(delta, -m_RotYSpeed*msgData.frameTime, +m_RotYSpeed*msgData.frameTime);
// Calculate new orientation, in a peculiar way in order to make sure the
// result gets close to m_orientation (rather than being n*2*PI out)
m_InterpolatedRotY = rotY + deltaClamped - delta;
break;
}
case MT_TurnStart:
{
m_LastX = m_X;
m_LastZ = m_Z;
if (m_Dirty)
@ -323,6 +360,7 @@ public:
break;
}
}
}
};

View File

@ -37,19 +37,31 @@ public:
const CSimContext* m_Context;
// Template state:
CFixed_23_8 m_Speed; // in units per second
// Dynamic state:
bool m_HasTarget;
ICmpPathfinder::Path m_Path;
entity_pos_t m_TargetX, m_TargetZ; // these values contain undefined junk if !HasTarget
enum
{
IDLE,
WALKING,
STOPPING
};
int m_State;
virtual void Init(const CSimContext& context, const CParamNode& paramNode)
{
m_Context = &context;
m_HasTarget = false;
m_Speed = paramNode.GetChild("WalkSpeed").ToFixed();
m_State = IDLE;
}
virtual void Deinit(const CSimContext& UNUSED(context))
@ -65,6 +77,8 @@ public:
serialize.NumberFixed_Unbounded("target x", m_TargetX);
serialize.NumberFixed_Unbounded("target z", m_TargetZ);
}
// TODO: m_State
}
virtual void Deserialize(const CSimContext& context, const CParamNode& paramNode, IDeserializer& deserialize)
@ -86,12 +100,58 @@ public:
case MT_Update:
{
CFixed_23_8 dt = static_cast<const CMessageUpdate&> (msg).turnLength;
if (m_State == STOPPING)
{
CMessageMotionChanged msg(CFixed_23_8::FromInt(0));
context.GetComponentManager().PostMessage(GetEntityId(), msg);
m_State = IDLE;
}
Move(context, dt);
break;
}
}
}
void SwitchState(const CSimContext& context, int state)
{
debug_assert(state == IDLE || state == WALKING);
// IDLE -> IDLE -- no change
// IDLE -> WALKING -- send a MotionChanged message
// WALKING -> IDLE -- set to STOPPING, so we'll send MotionChanged in the next Update
// WALKING -> WALKING -- no change
// STOPPING -> IDLE -- stay in STOPPING
// STOPPING -> WALKING -- set to WALKING, send no messages
if (m_State == IDLE && state == WALKING)
{
CMessageMotionChanged msg(m_Speed);
context.GetComponentManager().PostMessage(GetEntityId(), msg);
m_State = WALKING;
return;
}
if (m_State == WALKING && state == IDLE)
{
m_State = STOPPING;
return;
}
if (m_State == STOPPING && state == IDLE)
{
return;
}
if (m_State == STOPPING && state == WALKING)
{
m_State = WALKING;
return;
}
}
virtual void MoveToPoint(entity_pos_t x, entity_pos_t z, entity_pos_t minRadius, entity_pos_t maxRadius)
{
CmpPtr<ICmpPathfinder> cmpPathfinder (*m_Context, SYSTEM_ENTITY);
@ -102,6 +162,8 @@ public:
if (cmpPosition.null())
return;
SwitchState(*m_Context, WALKING);
CFixedVector3D pos = cmpPosition->GetPosition();
m_Path.m_Waypoints.clear();
@ -126,9 +188,14 @@ public:
// If there's no waypoints then we've stopped already, otherwise move to the first one
if (m_Path.m_Waypoints.empty())
m_Context->GetComponentManager().BroadcastMessage(CMessageMotionStopped());
{
m_HasTarget = false;
SwitchState(*m_Context, IDLE);
}
else
{
PickNextWaypoint(pos);
}
}
}
@ -154,8 +221,6 @@ void CCmpUnitMotion::Move(const CSimContext& context, CFixed_23_8 dt)
if (cmpPosition.null())
return;
CFixed_23_8 maxdist = m_Speed.Multiply(dt);
CFixedVector3D pos = cmpPosition->GetPosition();
pos.Y = CFixed_23_8::FromInt(0); // remove Y so it doesn't influence our distance calculations
@ -168,9 +233,12 @@ void CCmpUnitMotion::Move(const CSimContext& context, CFixed_23_8 dt)
// Face towards the target
entity_angle_t angle = atan2_approx(offset.X, offset.Z);
cmpPosition->SetYRotation(angle);
cmpPosition->TurnTo(angle);
// If it's close, we can move there directly
// Work out how far we can travel in dt
CFixed_23_8 maxdist = m_Speed.Multiply(dt);
// If the target is close, we can move there directly
if (offset.Length() <= maxdist)
{
// If we've reached the last waypoint, stop
@ -178,12 +246,12 @@ void CCmpUnitMotion::Move(const CSimContext& context, CFixed_23_8 dt)
{
cmpPosition->MoveTo(target.X, target.Z);
m_HasTarget = false;
context.GetComponentManager().BroadcastMessage(CMessageMotionStopped());
SwitchState(context, IDLE);
return;
}
// Otherwise, spend the rest of the time heading towards the next waypoint
dt = dt - dt.Multiply(offset.Length() / maxdist);
dt = dt - (offset.Length() / m_Speed);
pos = target;
PickNextWaypoint(pos);
continue;

View File

@ -90,6 +90,12 @@ public:
*/
virtual CFixedVector3D GetPosition() = 0;
/**
* Rotate smoothly to the given angle around the upwards axis.
* @param y clockwise radians from the +Z axis.
*/
virtual void TurnTo(entity_angle_t y) = 0;
/**
* Rotate immediately to the given angle around the upwards axis.
* @param y clockwise radians from the +Z axis.

View File

@ -154,14 +154,18 @@ CMessage* CMessagePositionChanged::FromJSVal(ScriptInterface& UNUSED(scriptInter
////////////////////////////////
jsval CMessageMotionStopped::ToJSVal(ScriptInterface& UNUSED(scriptInterface)) const
jsval CMessageMotionChanged::ToJSVal(ScriptInterface& scriptInterface) const
{
return JSVAL_VOID;
TOJSVAL_SETUP();
SET_MSG_PROPERTY(speed);
return OBJECT_TO_JSVAL(obj);
}
CMessage* CMessageMotionStopped::FromJSVal(ScriptInterface& UNUSED(scriptInterface), jsval UNUSED(val))
CMessage* CMessageMotionChanged::FromJSVal(ScriptInterface& scriptInterface, jsval val)
{
return NULL;
FROMJSVAL_SETUP();
GET_MSG_PROPERTY(CFixed_23_8, speed);
return new CMessageMotionChanged(speed);
}
////////////////////////////////////////////////////////////////

View File

@ -221,7 +221,8 @@ void CComponentManager::Script_RegisterComponentType(void* cbdata, int iid, std:
std::map<std::string, MessageTypeId>::const_iterator mit = componentManager->m_MessageTypeIdsByName.find(name);
if (mit == componentManager->m_MessageTypeIdsByName.end())
{
componentManager->m_ScriptInterface.ReportError("Registered component has unrecognised 'On...' message handler method"); // TODO: report the actual name
std::string msg = "Registered component has unrecognised '" + *it + "' message handler method";
componentManager->m_ScriptInterface.ReportError(msg.c_str());
return;
}