Repository navigation
NPCs
The sprite size formula:
# mul2 == mul3 == 160
# scale2 == 200
# var3 == ???
baseH <- ((sprite.H * obj.Scale)/256*scale2)/256
baseW <- (sprite.W * obj.Scale)/256
pX,pY <- project(obj.X-camera.X,obj.Y-camera.Y)
if pY<=20 then return
pX <- pX + var3
WW <- (baseW*mul2)/pY
HH <- (baseH*mul3)/pY
XX <- (pX*mul2)/pY + centerX - WW/2
YY <- (((sprite.Z+camera.Z)-baseH)*mul3)/pY + centerYThe monster races are 1-based. The lists below are indexed by race - 1. There are 24 different monster types.
- Names:
szlist @3A8EE - Level: byte array
@42096(zero-based, as with the player) - Hit points: 48-entry
WORDarray@420DE(min, max format) - Base experience:
DWORDarray@4213E. - Experience multiplier: byte array
@4219E - Sound: byte array
@4201E. An index into theVOCszlist @437CD - Damage: 48-entry byte array
@421B6(min, max format) - Magical effects:
WORDarray@420AE(these go into the NPC'sActiveEffects) - Scale:
WORDarray@42066(in 1/256s, 0 = 100%) - Y offset: signed byte array
@42036 - Has no corpse: byte array
@4204E - Blood: byte array
@4762F(index into animation list@42EFC) - Disease chance: signed byte array
@42212. Negative values have special meaning.
Monster experience is calculated as baseExperience + maxHP * experienceMultiplier.
- Animation:
@4222B. Monsters have a special 3-frame death animation at the index 5 (...6).
00..05 walk
06..07 look around (a special sequence of 6, 0, 7, 0)
08..11 attack (damage is done in the frame 10)
Every game tick the following occurs (incomplete).
void AI_Update(NPCSprite* npc) {
// Citizen
if(npc->flags & 4) {
if(npc->flat == (DAT_4b80_c7a8 + DAT_4b80_c200)) { // dying flat
npc->frame = -1;
}
else {
AI_Citizen_Move(npc);
}
return;
}
// Enemy
if(npc->flat == 197 || npc->flat == (DAT_4b80_c7a8 + DAT_4b80_c200)) { // dying flat
return;
}
NPCData* npcData = GetNPCData(npc->data);
if(npcData->statusFlags & 0x0040) { // paralyzed
npc->frame = 0; // standing
return;
}
// Calculate speed based on current Speed stat
npc->speed = ((npcData->currentSpeed * 20) >> 8) + 20;
// Do melee hit and early exit if monster and already attacking. Humanoid melee hits are handled in the animation function.
if(npcData->statusFlags & 0x1000 && npc->frame >= 8) {
if(npc->frame == 10) { // second-to-last frame of attack
Attacker = npcData;
Defender = PlayerData;
PerformMeleeAttack(); // calculate melee hit or miss, apply damage, etc.
}
return;
}
// Randomly turn a little, to be overridden if the player is not hidden
uint16 facingDir = (GetRandomNumber() >> 13) + npc->angle;
facingDir &= 0x1FF; // limit to 512 values
// Check for invisibility and no-target effects
bool playerHidden = IsPlayerInvisible() || (ActiveEffects & 0x0002);
if(!playerHidden) {
// Get direction to player
facingDir = ComputeDirection(npc->x, npc->z, PlayerX, PlayerZ);
}
else {
npc->frame = 0; // standing
}
// Set direction
npc->angle = facingDir;
if(npcData->statusFlags & 0x1000) { // monster
npc->flags &= ~0x0010; // clear melee flag
}
// Melee attack
if(!playerHidden) {
int dx = abs(npc->x - PlayerX);
int dz = abs(npc->z - PlayerZ);
if(dz <= dx) swap(dx, dz);
dx >>= 2;
dx += dz;
if(dx <= 170 && ((PlayerY >= 0) || (Unknown < 1)) { // in melee range and player Y is high enough, or unknown condition
if(actor->statusFlags & 0x1000) { // monster
npc->flags |= 0x0010; // set melee flag
npc->flags &= ~0x0001; // clear spellcasting flag
if(npc->frame > 5) { // if looking around finish that first?
return;
}
}
else { // humanoid
if(npc->flags & 0x0010) // already doing melee attack
return;
}
npc->frame = 0; // standing
if((GetRandomNumber() & 7) != 0) { // randomly delay
return;
}
if(!(npcData->statusFlags & 0x1000)) { // humanoid
npc->frame = 5; // start of humanoid melee attack
npc->flags |= 0x0010; // set melee flag
}
else {
npc->frame = 8; // start of monster melee attack
}
return;
}
}
// Move
if (!playerHidden)
{
if(npc->flags & 0x0001)) { // spellcasting
npc->frame = 0; // standing
else
AttemptMoveTowardsPlayer(npc);
}
// Spellcasting
npc->flags &= ~0x0001; // clear spellcasting flag
if(!IsPlayerInvisible()) // check for invisibility
{
if(!(npcData->activeEffects & 0x0200)) { // not silenced
if(!(npcData->statusFlags & 0x1000)) { // humanoid
if((npcData->class & 0x20) && !IsLOSBlocked(npc)) { // spellcaster and LOS with the player
uint8_t spell_id;
Spell* spell = SelectSpell(npcData, &spell_id); // randomly select a castable spell
if(!spell) return; // abort if there were no castable spells
npc->flags |= 0x0001; // set spellcasting flag
if(GetRandomNumber() % 0x16 == 0) {
npcData->spellPoints -= spell->spellCost;
AI_PerformSpellCast(); // TODO
}
}
}
else { // monster
// spellcaster, no obstructions to the player and is either not a medusa or the player is not paralyzed
if(npcData->knownSpellCount > 0 && !IsLOSBlocked(npc) && (npcData->monsterID != 0x15 || !(PlayerStatusFlags & 0x40))) {
uint8 spellID = npcData->knownSpellIDs[GetRandomNumberFromRange(0, npcData->knownSpellCount)]; // select random spell
if(GetRandomNumber() % 0x16 == 0) {
PerformSpellCast(); // TODO
}
}
}
}
}
return;
}
bool IsPlayerInvisible(NPCSprite* npc)
{
if(((ActivePlayerEffects & 1) != 0) && (InvisibilityOffTimer == 0)) // if player has the "invisibility" effect and is not temporarily visible
{
NPCData* npcData = GetNPCData(npc->data);
if(((npcData->statusFlags & 0x1000) == 0) || // not a monster
((((npcData->monsterID != 20 && (npcData->monsterID != 12)) && (npcData->monsterID != 15)) &&
(((npcData->monsterID != 23 && (npcData->monsterID != 22)) && (npcData->monsterID != 42)))))) // not a fire daemon, ghost, wraith, lich, vampire, or last boss
{
return false;
}
}
return true;
}
int ComputeDirection(int fromX, int fromZ, int toX, int toZ) {
uint16 bpFlags;
int dir = ComputeDelta(fromX, fromZ, toX, toZ, &bpFlags);
if(bpFlags & 0x2) { // flip vertically
dir = -dir + 0xFF;
}
if(bpFlags & 0x1) { // flip horizontally
dir = -dir + 0x1FF;
}
return dir;
}
// Returns scaled slope between two points (0x40 = 45 degrees).
// Sets BP bit 0 = X sign flag, bit 1 = Z sign flag.
int ComputeDelta(int fromX, int fromZ, int toX, int toZ, uint16* bpFlags) {
*bpFlags = 0;
int dx = toX - fromX;
if(dx < 0) {
*bpFlags |= 0x1;
dx = -dx;
}
int dz = toZ - fromZ;
if(dz < 0) {
*bpFlags |= 0x2;
dz = -dz;
}
if(dx <= dz) {
// slope = dx / dz * 0x40
int a = dx, b = dz;
if(b == 0) b = 1;
return (a * 0x40) / b;
}
else {
// slope = dz / dx * 0x40, negated and offset
int a = dz, b = dx;
if(b == 0) b = 1;
return ((-(a * 0x40) / b) + 0x7F);
}
}
// Returns a random spell for a humanoid if conditions are met
Spell* SelectSpell(NPCData* npcData, uint8_t* out_id) {
if(!(npcData->class & 0x20)) // not a spellcaster
return NULL;
// Required spell point threshold based on level
int requiredSpellPoints = 200 / (npcData->level + 1);
if(npcData->spellPoints < requiredSpellPoints)
return NULL;
while(1) {
int idx = GetRandomNumberInRange(0, 16);
uint8 spellID = HumanoidEnemySpellList[idx]; // @418B9
struct Spell* spell = GetSpellRecord(spellID);
int costThreshold = spell->spellCost / (npcData->level + 1);
if(npcData->spellPoints >= costThreshold) {
*out_id = idx;
return spell; // found a castable spell
}
}
}
void AttemptMoveTowardsPlayer(NPCSprite* npc)
{
int facing = npc->angle;
// Try moving in current facing direction. IsStepBlocked will zero the X or Z step if it will avoid collision.
if(IsStepBlocked(npc, facing))
return;
if(IsStepTooShort(npc, step.x, step.z)) {
// Set default coordinates
tmpPos1.x = tmpPos2.x = tmpPos3.x = tmpPos4.x = 0x6000;
// Calculate right turn (+0x64)
if(!IsStepBlocked(npc, facing + 0x64))
{
// Set position 1 with the new X and Z calculated within IsStepBlocked
tmpPos1.x = newX; tmpPos1.z = newZ;
}
// Calculate left turn (−0x64)
if(!IsStepBlocked(npc, facing - 0x64))
{
// Set position 2 with the new X and Z calculated within IsStepBlocked
tmpPos2.x = newX; tmpPos2.z = newZ;
}
// Pick position closer to player
int distRight = Distance_Manhattanish(tmpPos1.x, tmpPos1.z, PlayerX, PlayerZ);
int distLeft = Distance_Manhattanish(tmpPos2.x, tmpPos2.z, PlayerX, PlayerZ);
if(distRight < distLeft)
{
newX = tmpPos1.x; newZ = tmpPos1.z;
}
else
{
newX = tmpPos2.x; newZ = tmpPos2.z;
}
}
npc->x = newX;
npc->z = newZ;
}
// Returns true if blocked. WIP
bool IsStepBlocked(NPCSprite* npc, int angle) {
angle &= 0x1FF;
int speed = npc->speed * 2;
// Look up cosine/sine from precomputed tables, multiply by speed and add to current position
int newX = npc->x + ((speed * CosineTable[angle]) >> 16); // @466A6
int newZ = npc->z + ((speed * SineTable[angle]) >> 16); // @467A6
// Check for collision (and handle opening doors?).
// If the newX and new Z are OK, keep them as is.
// If not, try newX with currentZ.
// If collided, try currentX with newZ.
// If collided, movement is blocked.
// Check if the new coordinates (one or both may have been set to current X or Z) are already occupied
if(!IsTileOccupied(newX, newZ))
return false; // OK to move
return true; // Way is blocked
}
// Checks if calculated step is not long enough.
// This can happen when the step in either the X or Z axis was canceled to avoid collision.
bool IsStepTooShort(NPCSprite* npc, int xStep, int zStep) {
int dx = abs(xStep - npc->x);
int dz = abs(zStep - npc->z);
// Make sure dx <= dz
if(dx > dz) swap(dx, dz);
dx >>= 2;
dx += dz;
int threshold = npc->speed >> 2;
return (dx < threshold);
}
// Distance function: quarter of larger delta plus smaller delta
int Distance_Manhattanish(int x1, int z1, int x2, int z2) {
int dx = abs(x1 - x2);
int dz = abs(z1 - z2);
if(dx < dz) swap(dx, dz);
dx >>= 2;
return dx + dz;
}
bool IsLOSBlocked(NPCSprite* npc)
{
npc->flags &= 0xf7ff; // clear LOS flag
uint16 startX = npc->x >> 7;
uint16 startZ = npc->z >> 7;
uint16 endX = PlayerX >> 7;
uint16 endZ = PlayerZ >> 7;
bool LOSBlocked = IsLOSBlocked_Core(startX, startZ, endX, endZ); // TODO
if(!LOSBlocked) {
npc->flags |= 0x800; // set LOS flag
}
return LOSBlocked;
}Every game tick, for nearby enemies:
if(MonsterSoundIndex > -1) {
if(GetRandomNumber() <= 2000) {
// Play monster sound
}
}Every game tick:
if(npcData->statusFlags & 0x1000) { // is a monster
if(npc->flat == 0xC5) { // is in dying animation
if(npcData->CurrentHP < 1) {
npc->frame++;
if(npc->frame < 3) // haven't reached end of animation yet
return;
if(MonsterHasNoCorpse) {
npc->flags |= 0x4000; // hide sprite?
npc->frame = 0;
return;
}
else {
npc->frame = 2; // reset to final dying frame
if(npc->playedBodyFall == 0) {
npc->playedBodyFall++;
// Play "BODYFALL.VOC" at the NPC's coordinates
}
}
}
else { // resurrect
npc->frame--;
if(npc->frame < 0) { // animation finished reversing
npc->flat = 0xC0; // facing camera
npc->flags &= ~0x0100; // clear dead flag
npc->frame = 0; // standing
return;
}
}
}
if(npc->flags & 0x0001) { // spellcasting
return;
}
else {
if(npc->frame < 6) { // walking
if((GetRandomNumber() & 0x1f) == 0) {
if(npcData->monsterID != 0x10) { // not a homonculus
npc->flags |= 0x0040; // set looking flag
}
}
if((UpdateCount & 0x1) != 0 && !(npc->flags & 0x0010)) { // not in melee
if(AI_IsPlayerInvisible()) {
npc->frame = 0;
}
else {
npc->frame++;
if(npc->frame >= 6) {
npc->frame = 0;
}
}
}
else if(npc->frame < 8) { // unreachable since nothing sets animation frame to 6 or 7
if(npc->flags & 0xC0) { // looking left and right
int flagPart = npc->flags & 0xC0;
int newFrame;
if(flagPart == 0x40) newFrame = 6; // first looking frame
else if(flagPart == 0x80) newFrame = 0; // looking straight
else newFrame = 7; // both flags set. Second looking frame.
npc->frame = newFrame;
npc->flags += 0x40; // advance flags to next part of sequence
return;
}
}
}
}
}
else { // humanoid
if(npcDataFlags & 0x0100) { // dead flag set
npc->frame = -1;
npc->flat = (DAT_4b80_c7a8 + DAT_4b80_c200);
npc->flags &= ~0x0010; // clear melee flag
return;
}
if(!(npc->flags & 0x0010)) { // not in melee state
if(npc->frame == 8) { // at last melee frame
return;
}
if(npc->flags & 0x0001 || AI_IsPlayerInvisible()) { // spellcasting flag is set or player is invisible
npc->frame = 0; // standing
return;
}
else {
// animate walking frames
npc->frame++;
if(npc->frame >= 6) {
npc->frame = 0;
}
return;
}
}
else { // in melee state
npc->frame++;
if(npc->frame < 9) { // still advancing through melee frames (6 through 8)
return;
}
// Reached the end of the attack animation
AttackingActor = npcData;
DefendingActor = PlayerData;
AI_PerformMeleeAttack(); // calculate melee hit or miss
npc->frame = 8; // reset to last attack frame
npc->flags &= ~0x0020; // clear unknown flag
}
}Humanoids have an animation combined of walk and attack part. The attack part is also overlaid with a weapon animation, when applicable.
- If an NPC has a cuirass, or at least 3 other items, of a certain armor type, that type is used for the sprite (indices 0..2)
- Otherwise, it is 3
- For spellcasters, it is 4, for Monks 5, and for Barbarians 6.
The sprite type array is located @45D20.
- The weapon index is 0 to 2 for swords, axes, and maces correspondingly.
- If the sprite index was 0, weapon index increases by 3, if 6 by 6.
A female variant is not selected for the plate armor sprite (index 0).
The weapon sprite array is located @45D97
- For monks and mages, the special attack animations are used,
MNKKIC,MAGSWD, andMAGSTF.
Humanoids do not have the death animation, they are replaced with a 'corpse' sprite (ITEM 2).
Humanoids roll for max health in the same manner as the player, using the same per-class health dice. However, instead of having 25 added to the total as the player does in character generation, they roll their level + 1 times. (TODO: Needs more research)
Humanoid experience values are calculated as follows. Note that this function appears to not work as intended because the class value does not have ID_MASK applied to it, resulting in every class getting the "warrior type" multiplier.:
void SetHumanoidEXP(NPCSTATS* npcStats)
{
unsigned char class = npcStats->Class;
int index = 0;
if (class > 5) // Not a mage-type class
{
index = 1;
if (class > 12) // Not a thief-type class
{
index = 2;
}
}
unsigned char level = npcStats->Level;
npcStats->Experience = (level * level * HumanoidExpModifiers[index]);
return;
}
HumanoidExpModifiers `@43591`
0Fh // Mage type
14h // Thief type
19h // Warrior typeTownsfolk do not have NPC properties. They are dying with a single hit, yielding 1..4 gold and corresponding message.
Male sprites (@4559A) are chosen by the tileset. Special female sprites are chosen for the desert tileset (FMGEND) and snow/snow overcast weather (FMGENW).
For clothing transformation, the random value Data & 0x7fff is used. colorBase is a byte array @47096.
val <- seed
for i <- 0, 16
flag <- val & 0x8000
val <- rol16(val,1)
if flag then
block <- val & 0xF
dest <- colorBase[i]
if dest == 128 and block == 11 then continue # no green hair
src <- colorBase[block]
for j <- 0, 10
new[src+j] <- old[dest+j]For skin transformation, the following values are used:
- Bretons, Nords, Wood Elves, Khajiits - no transformation
- Dark Elves -
52 - High Elves -
192 - Argonians -
116 - Everyone else -
148
skinColor is a byte array @470A6.
for i <- 0, 10
new[skinColor[i]] <- old[VAL+i]Movement logic (whether to stop and idle near the player or wander around the area) is as follows:
int xDiff = PlayerX - CitizenX;
if (xDiff < 0) {
xDiff = -xDiff;
}
int zDiff = PlayerZ - CitizenZ;
if (zDiff < 0) {
zDiff = -zDiff;
}
int shorterDist = xDiff;
int longerDist = zDiff;
if (zDiff <= xDiff) {
shorterDist = zDiff;
longerDist = xDiff;
}
int calculatedDistance = (shorterDist >> 2) + longerDist;
if ((calculatedDistance < 200) &&
((MouseCursorIsXIcon || PlayerTargetMoveSpeed == 0) || !LeftMouseButtonPressed) &&
(!PlayerWeaponDrawn && !PlayerInvisible)) {
npcIdlingFlag = true;
if (npcSprite->Frame < 6) {
npcSprite->Frame = 6;
return;
}
}
else {
npcIdlingFlag = false;
int coordinateOfMovement;
if ((npcSprite->Angle & 0x80) == 0) { // Moving along the Z-axis, so center on the X-axis of the voxel
npcSprite->X = npcSprite->X & 0xff80;
npcSprite->X += 0x40;
coordinateOfMovement = npcSprite->Z;
}
else { // Moving along the X-axis, so center on the Z-axis of the voxel
npcSprite->Z = npcSprite->Z & 0xff80;
npcSprite->Z += 0x40;
coordinateOfMovement = npcSprite)->X;
}
if ((49 < (coordinateOfMovement & 0x7f)) && ((coordinateOfMovement & 0x7f) < 79)) { // Collision check every once in a while
bool collision = TownspersonCollisionCheck();
if (collision) {
int angleChange = 128;
ushort rand = GetRandomNumber();
if (rand + rand > 65535) { // If adding the 16-bit number from GetRandomNumber() to itself overflows, in other words a 1 out of 2 random chance
angleChange = -angleChange;
}
npcSprite->Angle += angleChange; // Ex. Change from angle 0 to angle -128.
npcSprite->Angle = npcSprite->Angle & 0x1ff; // Limit to 511 or lower
return;
}
}
int index = (npcSprite->Angle >> 7) * 4; // Angle >> 7 will be a value from 0 to 3
int xMovement = CitizenMovementsX[index]; // 0, 16 or -16
int zMovement = CitizenMovementsZ[index]; // 0, 16 or -16
npcSprite->X += xMovement;
npcSprite->Z += zMovement;
}
return;
}
// CitizenMovementsX are at offset 0x45586, and CitizenMovementsZ are at 0x45588, in the unpacked 1.06 executable.
// You could also treat them like a 4-element array of XZ pairs starting from 0x45586.
00 00 // X0 0x45586
10 00 // Z0 0x45588
10 00 // X1
00 00 // Z1
00 00 // X2
F0 FF // Z2
F0 FF // X3
00 00 // Z3Citizen animation is as follows:
// If idle flag set -> idle handling:
if(npc->flags & 0x0001) {
// If frame > 6, we are in the idling frames
if(npc->frame > 6) {
// RNG gating: call RNG, accept 1-in-8 chance to advance frame
if((GetRandomNumber() & 0x7) != 0)
return;
npc->frame++;
return;
}
// else test UpdateCount low bits: only advance on one in eight ticks
if((UpdateCount & 0x7) != 0)
return;
npc->frame++;
if(npc->frame > 8)
npc->frame = 6; // clamp back into idle range
return;
}
// walking
npc->frame++;
if(npc->frame >= 6)
npc->frame = 0;
return;