I’ve been tinkering around with this project privately and thought I might as well share my take on OSRS’s xp drops/xp tracker menu.
Disclaimer: Code in this tutorial has been cherrypicked from my local files and renamed/cleaned up/commented without testing, so hopefully it still compiles properly!
This is what we are looking to achieve. Most recent xp drops will scroll up the screen and the progress bar will show you the most recent skill you gained XP in and how far away you are from the next level.
This version is much simpler than the one OSRS has (e.g. no custom xp goals, small icons for xp drops, etc) but I leave improving this as an exercise for the reader.
All changes take place in webclient/src/client/Client.ts.
I have prefixed all my changed with XP, as this makes it easier for me to see at glance which code is mine vs original client, but you do not need to do this.
The XP drop type
In order to track the most recent xp drops we need to define a couple of variables and, for neatness, a new data type.
Add the following at the top of client.ts, above export class Client extends GameShell {
:
type XP_SkillDrop = {
xp : number;
skill : number;
dropTick : number;
offset : number;
}
Next add the following variables on a new line below private skillExperience: number[] = [];
:
private XP_skillDrops: XP_SkillDrop[] = [];
private XP_lastSkillDrop : number = -1;
The skill icons
To get that pretty picture of the most recent skill we need to load and keep track of images for each skill so we can draw them later. We do this by creating an array of Pix8 images and loading the skill icons file(s) already found in the archive into it.
In the interest of keeping like types together we’ll add this new array near other Pix8 images together. Locate this line: private imageRedstone2hv: Pix8 | null = null;
and put the following onto a new line below it:
private XP_imageStaticons: (Pix8 | null)[] = new TypedArray1d(19, null); // Note: If/when new skills get added 19 will need replacing with the new number of skills
Next comes loading the stat icons from the archive. Stat icons are stored in 2 files, one containing the first 18 skills and one containing runecraft. Place the following code after this line this.imageCompass = Pix24.fromArchive(media, 'compass', 0);
:
for (let i: number = 0; i < 18; i++)
{
this.XP_imageStaticons[i] = Pix8.fromArchive(media, 'staticons', i);
}
// Load runecraft, in future new skills will likely end up here and this will need replacing with another loop
this.XP_imageStaticons[18] = Pix8.fromArchive(media, 'staticons2', 0)
Drawing the XP drops
We’ll be adding a custom function to draw the xp drops on top the game world, but behind any interfaces (e.g. shops).
Find draw3DEntityElements() : void
and add the following line to the beginning of the function:
this.XP_drawXPTracker();
Below this function add the following code to implement the new function:
private XP_drawXPTracker() : void {
const stats: (string | null)[] = [
'Attack',
'Defence',
'Strength',
'Hitpoints',
'Ranged',
'Prayer',
'Magic',
'Cooking',
'Woodcutting',
'Fletching',
'Fishing',
'Firemaking',
'Crafting',
'Smithing',
'Mining',
'Herblore',
'Agility',
'Thieving',
null,
null,
'Runecraft'
];
// The id of skills doesn't correspond with the order they are draw in the stats image, this maps the correct ordering
const statIcons : number[] = [
0, 2, 1, 6, 3, 4, 5, 15, 17, 11, 14, 16, 10, 13, 12, 8, 7, 9, 0, 0, 18
];
const progressBackX : number = 400;
const progressBackY : number = 0;
const progressBackW : number = 110;
const progressBackH : number = 40;
const progBarH : number = 6;
const progImgX : number = progressBackX + 2;
const progImgY : number = progressBackY + 2;
const progBarX : number = progressBackX + 2;
const progBarY : number = progressBackY + progressBackH - progBarH - 2;
const progBarW : number = progressBackW - 4;
const progTextX : number = progressBackX + progressBackW - 8;
const progTextY : number = progressBackY + 20;
const dropY : number = 12;
const dropMinY : number = progressBackY + progressBackH + dropY + 2;
const dropMaxY : number = dropMinY + dropY * 5;
const dropX : number = progressBackX + progressBackW - 2;
const moveSpeed : number = 1; // Pixels per cycle
Pix2D.fillRectAlpha(progressBackX, progressBackY, progressBackW, progressBackH, 0x4d4d4d, 180);
Pix2D.drawRect(progressBackX, progressBackY, progressBackW, progressBackH, 0x4d4d4d);
if (this.XP_lastSkillDrop >= 0)
{
const totalXp : number = this.skillExperience[this.XP_lastSkillDrop];
const baseLevel : number = this.skillBaseLevel[this.XP_lastSkillDrop] - 1;
let prog : number = 1;
if (baseLevel < 99)
{
const lastLevelXP : number = baseLevel > 1 ? Client.levelExperience[baseLevel - 1] : 0;
const nextLevelXP : number = Client.levelExperience[baseLevel];
prog = Math.min(1, (totalXp - lastLevelXP) / (nextLevelXP - lastLevelXP));
}
const r : number = Math.round((1 - prog) * 255);
const g : number = Math.round(prog * 255);
const col : number = (r << 16) + (g << 8);
const fillW : number = Math.round(prog * progBarW);
// Background bar
Pix2D.fillRect2d(progBarX, progBarY, progBarW, progBarH, 0x000000);
// Progress bar
Pix2D.fillRect2d(progBarX + 1, progBarY + 1, fillW - 2, progBarH - 2, col);
this.fontPlain11?.drawStringRight(progTextX, progTextY, totalXp + '', 0xFFFFFF, true);
this.XP_imageStaticons[statIcons[this.XP_lastSkillDrop]]?.draw(progImgX, progImgY);
}
let lastOffset : number = dropMaxY;
for (let i : number = 0; i < this.XP_skillDrops.length; i++)
{
const drop : XP_SkillDrop = this.XP_skillDrops[i];
// Distance up from bottom of drops area, including any previous offsets applied due to past overlaps
let offset : number = (this.loopCycle - drop.dropTick) * moveSpeed + drop.offset;
// If the new offset isn't smaller than the previous drop by enough to prevent overlap, shift it down
if (offset > lastOffset - dropY)
{
offset = lastOffset - dropY;
this.XP_skillDrops[i].offset = offset - (this.loopCycle - drop.dropTick) * moveSpeed;
}
lastOffset = offset;
const drawY : number = dropMaxY - offset;
// Draw drops that are in bounds, remove any that have reached the top
if (drawY > dropMinY)
{
if (drawY <= dropMaxY)
{
this.fontPlain11?.drawStringRight(dropX, drawY, stats[drop.skill] + ' ' + drop.xp, 0xFFFFFF, true);
}
}
else
{
this.XP_skillDrops.splice(i, 1);
i--;
continue;
}
}
}
The UPDATE_STAT packet
In order to know when the player has actually received any xp we need to listen to the server packet which tells the client to update the values on their stats panel.
Locate this if statement: if (this.inPacketType === ServerProt.UPDATE_STAT) {
and find this line just after: const level: number = this.in.g1();
. Finally add the following code onto a new line below it:
const oldXP = this.skillExperience[stat];
if (oldXP != xp && oldXP != null)
{
// Add new entry to the XP drops
this.XP_skillDrops.push({
skill: stat,
xp: (xp - oldXP),
dropTick: this.loopCycle,
offset : 0
});
// Update the last stat so we know which one to show the xp bar for (you could give this a default value if you don't want the bar to be blank on login)
this.XP_lastSkillDrop = stat;
}
Testing
At this point everything should be working. Rebuild the webclient and launch the server, gain some xp and it should show up on the right. Before receiving any xp in a session the tracker will just show the background with no skill icon/xp bar. This is normal and should be fairly trivial to change to be either hidden entirely or to default to a certain skill.