diff --git a/src/Client.ts b/src/Client.ts index d8b6a458..0fb8a1f0 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -35,6 +35,7 @@ import { CameraFlags, ClientBound, ArenaFlags, InputFlags, NameFlags, ServerBoun import { AI, AIState, Inputs } from "./Entity/AI"; import AbstractBoss from "./Entity/Boss/AbstractBoss"; import { executeCommand } from "./Const/Commands"; +import { sendAchievements } from "./Const/Achievements"; import { bannedClients } from "."; /** XORed onto the tank id in the Tank Upgrade packet. */ @@ -109,6 +110,9 @@ export default class Client { /** Wether or not the player is in godmode. */ public isInvulnerable: boolean = false; + /** Achievements unlocked this tick */ + private pendingAchievements: string[] = []; + /** Returns a new writer stream connected to the socket. */ public write() { return new WSWriterStream(this); @@ -325,7 +329,7 @@ export default class Client { player.destroy(); player.onDeath(player); - player.onKill(player); + player.onKill(player, player); } } @@ -511,6 +515,10 @@ export default class Client { this.write().u8(ClientBound.Notification).stringNT(text).u32(color).float(time).stringNT(id).send(); } + public giveAchievements(achievements: string[]) { + this.pendingAchievements.push(...achievements); + } + /** Bans the ip from all servers until restart. */ public ban() { const ws = this.ws; @@ -543,7 +551,6 @@ export default class Client { const tank = camera.cameraData.player = camera.relationsData.owner = camera.relationsData.parent = new TankBody(this.game, camera, this.inputs); tank.setTank(Tank.Basic); tank.nameData.values.name = name; - this.game.arena.spawnPlayer(tank, this); camera.setLevel(camera.cameraData.values.respawnLevel); if (this.hasCheated()) this.setHasCheated(true); @@ -553,6 +560,8 @@ export default class Client { camera.spectatee = null; this.inputs.isPossessing = false; this.inputs.movement.magnitude = 0; + + this.game.arena.spawnPlayer(tank, this); // Should be last } public tick(tick: number) { @@ -592,9 +601,16 @@ export default class Client { this.camera.cameraData.cameraX = this.camera.cameraData.cameraY = 0; this.camera.cameraData.flags &= ~CameraFlags.showingDeathStats; } + if (tick >= this.lastPingTick + 60 * config.tps) { return this.terminate(); } + + if (this.pendingAchievements.length) { + sendAchievements(this, this.pendingAchievements); + + this.pendingAchievements.length = 0; + } } /** toString override from base Object. Adds debug info */ public toString(verbose: boolean = false): string { diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts new file mode 100644 index 00000000..2e197909 --- /dev/null +++ b/src/Const/Achievements.ts @@ -0,0 +1,268 @@ +/* + DiepCustom - custom tank game server that shares diep.io's WebSocket protocol + Copyright (C) 2022 ABCxFF (github.com/ABCxFF) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see +*/ + +import _Achievements from "./Achievements.json"; +import Client from "../Client"; +import Writer from "../Coder/Writer"; +import { ClientBound, Tank } from "./Enums"; +import { DevTank } from "./DevTankDefinitions"; +import { enableAchievements } from "../config"; + +const NAME_SEED = 170; +const DESC_SEED = 221; + +const OP_EQUALS = 0; +const OP_GTE = 1; +const OP_LTE = 2; + +/** The event types achievements can have */ +export type eventId = "kill" | "score" | "levelUp" | "statUpgraded" | "classChange" | "latency"; + +/** The types of achievements */ +export type achievementType = "counter"; + +/** + * Format that the game stores achievements in its memory. + */ +export interface AchievementDefinition { + /** Achievement name */ + name: string; + /** Achievement description */ + desc: string; + /** Conditions needed to unlock */ + conds: AchievementCondition[]; + /** Achievement hash which is sent to clients */ + hash: string; +} + +export interface AchievementTags { + /** Current value */ + "value"?: string | number; + /** Total value */ + "total"?: string | number; + /** Value change */ + "delta"?: string | number; + /** Tank ID */ + "class"?: Tank | DevTank; + /** Tank level */ + "level"?: number; + /** Stat ID */ + "id"?: number; + /** Is max stat level */ + "isMaxLevel"?: boolean; + /** Used for ramming case */ + "weapon.isTank"?: boolean; + /** Was the victim a tank? */ + "victim.isTank"?: boolean; + /** Was the victim a boss? */ + "victim.isBoss"?: boolean; + /** Was the victim a shiny shape? */ + "victim.isShiny"?: boolean; + /** Victim tank ID */ + "victim.class"?: Tank | DevTank; + /** Victim mob ID */ + "victim.arenaMobID"?: string | null; +} + +export interface AchievementCondition { + event?: eventId; + type?: achievementType; + tags: AchievementTags; + threshold?: number; + /** Created during parsing */ + op?: number | null; +} + +/** From https://github.com/jtpio/murmurhash2 */ +export const MurMurHash2 = (str: string, seed: number): number => { + const m = 0x5bd1e995; + const encoder = new TextEncoder(); + + const data = encoder.encode(str); + let len = data.length; + let h = seed ^ len; + let i = 0; + + while (len >= 4) { + let k = + (data[i] & 0xff) | + ((data[++i] & 0xff) << 8) | + ((data[++i] & 0xff) << 16) | + ((data[++i] & 0xff) << 24); + + k = (k & 0xffff) * m + ((((k >>> 16) * m) & 0xffff) << 16); + k ^= k >>> 24; + k = (k & 0xffff) * m + ((((k >>> 16) * m) & 0xffff) << 16); + + h = ((h & 0xffff) * m + ((((h >>> 16) * m) & 0xffff) << 16)) ^ k; + + len -= 4; + ++i; + } + + switch (len) { + case 3: + h ^= (data[i + 2] & 0xff) << 16; + case 2: + h ^= (data[i + 1] & 0xff) << 8; + case 1: + h ^= data[i] & 0xff; + h = (h & 0xffff) * m + ((((h >>> 16) * m) & 0xffff) << 16); + } + + h ^= h >>> 13; + h = (h & 0xffff) * m + ((((h >>> 16) * m) & 0xffff) << 16); + h ^= h >>> 15; + + return h >>> 0; +} + +export const createAchievementHash = (a: AchievementDefinition) => { + return `${MurMurHash2(a.name, NAME_SEED).toString(16)}${MurMurHash2(a.desc, DESC_SEED).toString(16)}_1`; +} + +export const compileConds = (conds: AchievementCondition[]) => { + for (const c of conds) { + const tags = c.tags; + + for (const key in tags) { + const value = tags[key as keyof AchievementTags] as string; + + if (key === "total" || key === "value" || key === "delta") { + tags[key] = parseInt(value.slice(2)); + const op = value.slice(0, 2); + + switch (op) { + case "==": + c.op = OP_EQUALS; + break; + case ">=": + c.op = OP_GTE; + break; + case "<=": + c.op = OP_LTE; + break; + default: throw new Error(`Invalid operation: ${op}`); + } + } + } + } + + return conds; +} + +export const compileAchievement = (a: AchievementDefinition) => { + return { + ...a, + hash: createAchievementHash(a), + conds: compileConds(a.conds) + } +} + +const Achievements = _Achievements.map(a => compileAchievement(a as AchievementDefinition)); +export default Achievements; + +export const achievementEventMap = Achievements.reduce((map, a) => { + for (const c of a.conds) { + const byEvent = map.get(c.event); + + if (!byEvent) { + map.set(c.event, [a]); + continue; + } + + byEvent.push(a); + } + + return map; +}, new Map()); + +export const sendAchievementEvent = (client: Client, event: eventId, data: AchievementTags) => { + if (!enableAchievements) return; + + const completed = []; + + const achievements = achievementEventMap.get(event); + + for (const a of achievements) { + if (checkCondition(a, data)) { + completed.push(a.hash); + } + } + + if (completed.length) { + client.giveAchievements(completed); + } +} + +const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { + const conds = achievement.conds; + + return conds.every(condition => parseCondition(condition, data)); +} + +const parseCondition = (conds: AchievementCondition | null, data: AchievementTags): boolean => { + if (!conds) return true; + + const tags = conds.tags + for (const key in tags) { + const value = tags[key as keyof AchievementTags]!; + const givenValue = data[key as keyof AchievementTags]!; + + if (key === "total" || key === "value" || key === "delta") { + const op = conds.op; + + switch (op) { + case OP_EQUALS: // == + if (givenValue !== value) return false; + break; + case OP_GTE: // >= + if (givenValue < value) return false; + break; + case OP_LTE: // <= + if (givenValue > value) return false; + break; + } + } else { + if (givenValue !== value) { + return false; + } + } + } + + return true; +} + +export const sendAchievements = (client: Client, hashes: string[]) => { + if (client.terminated) return; + + const w = client.write(); + + w.u8(ClientBound.Achievement); + w.vu(hashes.length); + + for (let i = 0; i < hashes.length; ++i) { + w.stringNT(hashes[i]); + } + + w.send(); +} + +export const getAchievementByName = (name: string): AchievementDefinition | null => { + return Achievements.find(a => a.name === name) || null; +} diff --git a/src/Const/Commands.ts b/src/Const/Commands.ts index 586f397b..30719448 100644 --- a/src/Const/Commands.ts +++ b/src/Const/Commands.ts @@ -53,6 +53,7 @@ import { Entity, EntityStateFlags } from "../Native/Entity"; import { saveToVLog } from "../util"; import { ClientBound, Stat, StatCount, PhysicsFlags, StyleFlags, Tank } from "./Enums"; import { getTankByName } from "./TankDefinitions"; +import { sendAchievements, getAchievementByName } from "./Achievements"; const RELATIVE_POS_REGEX = new RegExp(/~(-?\d+)?/); @@ -69,6 +70,7 @@ export const enum CommandID { gameAnnounce = "game_announce", gameGoldenName = "game_golden_name", gameNeutral = "game_neutral", + gameAchievement = "game_achievement", adminSummon = "admin_summon", adminKillAll = "admin_kill_all", adminKillEntity = "admin_kill_entity", @@ -164,12 +166,19 @@ export const commandDefinitions = { permissionLevel: AccessLevel.FullAccess, isCheat: false }, - game_neutral: { + game_neutral: { id: CommandID.gameNeutral, description: "Sets your tank's team to the neutral team", permissionLevel: AccessLevel.FullAccess, isCheat: false }, + game_achievement: { + id: CommandID.gameAchievement, + usage: "[achievementName]", + description: "Increments the given achievement. Example usage: game_achievement \"Shiny!\"", + permissionLevel: AccessLevel.FullAccess, + isCheat: false + }, admin_summon: { id: CommandID.adminSummon, usage: "[entityName] [?count] [?x] [?y]", @@ -313,7 +322,7 @@ export const commandCallbacks = { .float(parseInt(time)) .stringNT(id).send(); }, - game_golden_name: (client: Client, activeArg?: string) => { + game_golden_name: (client: Client) => { client.setHasCheated(!client.hasCheated()); }, game_neutral: (client: Client) => { @@ -325,6 +334,12 @@ export const commandCallbacks = { TeamEntity.setTeam(team, player); }, + game_achievement: (client: Client, nameArg: string) => { + const achievement = getAchievementByName(nameArg); + if (!achievement) return; + + sendAchievements(client, [achievement.hash]); + }, admin_summon: (client: Client, entityArg: string, countArg?: string, xArg?: string, yArg?: string) => { const count = countArg ? parseInt(countArg) : 1; let x = parseInt(xArg || "0", 10); diff --git a/src/Entity/Live.ts b/src/Entity/Live.ts index 845144e5..bc536fab 100644 --- a/src/Entity/Live.ts +++ b/src/Entity/Live.ts @@ -119,12 +119,12 @@ export default class LivingEntity extends ObjectEntity { this.onDeath(killer); } - source.onKill(this); + source.onKill(this, source); } } /** Called when the entity kills another via collision. */ - public onKill(entity: LivingEntity) {} + public onKill(entity: LivingEntity, weapon: LivingEntity) {} /** Called when the entity is killed via collision */ public onDeath(killer: LivingEntity) {} diff --git a/src/Entity/Object.ts b/src/Entity/Object.ts index 0f0e8448..63b94a0f 100644 --- a/src/Entity/Object.ts +++ b/src/Entity/Object.ts @@ -50,6 +50,8 @@ class DeletionAnimation { case 5: this.entity.styleData.opacity = 1 - (1 / 6); default: + // when being deleted, entities slow down half speed + this.entity.velocity.magnitude *= this.entity.deathAccelFactor; this.entity.scale(1.1); this.entity.styleData.opacity -= 1 / 6; if (this.entity.styleData.values.opacity < 0) this.entity.styleData.opacity = 0; @@ -106,6 +108,9 @@ export default class ObjectEntity extends Entity { /** Velocity used for physics. */ public velocity = new Vector(); + /** Percent of accel applied when dying. */ + public deathAccelFactor = 0.9; + /** For internal spatial hash grid */ private _queryId: number = -1; @@ -273,8 +278,7 @@ export default class ObjectEntity extends Entity { /** Internal physics method used for calculating the current position of the object. */ public applyPhysics() { if (this.velocity.magnitude < 0.01) this.velocity.magnitude = 0; - // when being deleted, entities slow down half speed - else if (this.deletionAnimation) this.velocity.magnitude /= 2; + this.positionData.x += this.velocity.x; this.positionData.y += this.velocity.y; diff --git a/src/Entity/Tank/Addons.ts b/src/Entity/Tank/Addons.ts index dc36ef25..618494fa 100644 --- a/src/Entity/Tank/Addons.ts +++ b/src/Entity/Tank/Addons.ts @@ -175,9 +175,9 @@ export class GuardObject extends ObjectEntity implements BarrelBase { * Called (if ever) similarly to LivingEntity.onKill * Spreads onKill to owner */ - public onKill(killedEntity: LivingEntity) { + public onKill(killedEntity: LivingEntity, weapon: LivingEntity) { if (!LivingEntity.isLive(this.owner)) return; - this.owner.onKill(killedEntity); + this.owner.onKill(killedEntity, weapon); } public tick(tick: number): void { diff --git a/src/Entity/Tank/AutoTurret.ts b/src/Entity/Tank/AutoTurret.ts index cc05fefb..bc251dc9 100644 --- a/src/Entity/Tank/AutoTurret.ts +++ b/src/Entity/Tank/AutoTurret.ts @@ -120,8 +120,8 @@ export default class AutoTurret extends ObjectEntity { * Called similarly to LivingEntity.onKill * Spreads onKill to owner */ - public onKill(killedEntity: LivingEntity) { - (this.owner as unknown as LivingEntity)?.onKill?.(killedEntity); + public onKill(killedEntity: LivingEntity, weapon: LivingEntity) { + (this.owner as unknown as LivingEntity)?.onKill?.(killedEntity, weapon); } public tick(tick: number) { diff --git a/src/Entity/Tank/Projectile/Bullet.ts b/src/Entity/Tank/Projectile/Bullet.ts index 92460611..463ac306 100644 --- a/src/Entity/Tank/Projectile/Bullet.ts +++ b/src/Entity/Tank/Projectile/Bullet.ts @@ -37,7 +37,7 @@ export default class Bullet extends LivingEntity { /** Starting velocity of the bullet. */ protected baseSpeed = 0; /** Percent of accel applied when dying. */ - protected deathAccelFactor = 0.5; + public deathAccelFactor = 0.5; /** Life length in ticks before the bullet dies. */ protected lifeLength = 0; /** Angle the projectile is shot at. */ @@ -103,8 +103,8 @@ export default class Bullet extends LivingEntity { } /** Extends LivingEntity.onKill - passes kill to the owner. */ - public onKill(killedEntity: LivingEntity) { - (this.tank as unknown as LivingEntity)?.onKill?.(killedEntity); + public onKill(killedEntity: LivingEntity, weapon: LivingEntity) { + (this.tank as unknown as LivingEntity)?.onKill?.(killedEntity, weapon); } public tick(tick: number) { diff --git a/src/Entity/Tank/Projectile/Drone.ts b/src/Entity/Tank/Projectile/Drone.ts index 89d0827d..909fb5ed 100644 --- a/src/Entity/Tank/Projectile/Drone.ts +++ b/src/Entity/Tank/Projectile/Drone.ts @@ -40,6 +40,9 @@ export default class Drone extends Bullet { /** Cached prop of the definition. */ protected canControlDrones: boolean; + + /** Percent of accel applied when dying. */ + public deathAccelFactor = 1.0; public constructor(barrel: Barrel, tank: BarrelBase, tankDefinition: TankDefinition | null, shootAngle: number) { super(barrel, tank, tankDefinition, shootAngle); diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index bdf2e4bf..16209654 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -22,6 +22,7 @@ import type GameServer from "../../Game"; import type { CameraEntity } from "../../Native/Camera"; import AbstractShape from "../Shape/AbstractShape"; +import AbstractBoss from "../Boss/AbstractBoss"; import NecromancerSquare from "./Projectile/NecromancerSquare"; import LivingEntity from "../Live"; import ObjectEntity from "../Object"; @@ -32,10 +33,11 @@ import { Entity } from "../../Native/Entity"; import { NameGroup, ScoreGroup } from "../../Native/FieldGroups"; import { Addon, AddonById } from "./Addons"; import { getTankById, TankDefinition, visibilityRateDamage } from "../../Const/TankDefinitions"; +import { sendAchievementEvent } from "../../Const/Achievements"; import { DevTank } from "../../Const/DevTankDefinitions"; import { Inputs } from "../AI"; import { ArenaState } from "../../Native/Arena"; -import { AccessLevel, maxPlayerLevel } from "../../config"; +import { AccessLevel, maxPlayerLevel, enableAchievements } from "../../config"; /** * Abstract type of entity which barrels can connect to. @@ -102,7 +104,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.entityTags |= EntityTags.isTank; } - + public static isTank(entity: Entity | null | undefined): entity is TankBody { if (!ObjectEntity.isObject(entity)) return false; @@ -130,7 +132,8 @@ export default class TankBody extends LivingEntity implements BarrelBase { const tank = getTankById(id); const camera = this.cameraEntity; - if (!tank) throw new TypeError("Invalid tank ID"); + if (!tank) throw new TypeError(`Invalid tank ID: ${tank}`); + this.definition = tank; if (!Entity.exists(camera)) throw new Error("No camera"); @@ -156,10 +159,6 @@ export default class TankBody extends LivingEntity implements BarrelBase { else if (this.positionData.flags & PositionFlags.canMoveThroughWalls) this.positionData.flags ^= PositionFlags.canMoveThroughWalls; camera.cameraData.tank = this._currentTank = id; - const client = camera.getClient(); - if (client && tank.upgradeMessage) { - client.notify(tank.upgradeMessage, 0x000000, 10000); - } // Build addons, then tanks, then addons. const preAddon = tank.preAddon; @@ -181,17 +180,47 @@ export default class TankBody extends LivingEntity implements BarrelBase { // Yeah, yeah why not this.cameraEntity.cameraData.tankOverride = tank.name; camera.setFieldFactor(tank.fieldFactor); - + this.scale(1); // Update addons and etc this.calculateStatData(); // Re-calculate everything once this is done + + const client = camera.getClient(); + if (client) { + if (tank.upgradeMessage) client.notify(tank.upgradeMessage, 0x000000, 10000); + + if (enableAchievements && !this.game.arena.disableAchievements) { + sendAchievementEvent(client, "classChange", { + "class": id + }); + } + } } + /** See LivingEntity.onKill */ - public onKill(entity: LivingEntity) { - if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) this.cameraEntity.addScore(entity.scoreReward); + public onKill(entity: LivingEntity, weapon: LivingEntity) { + if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) { + this.cameraEntity.addScore(entity.scoreReward); + } + + const client = this.cameraEntity.getClient(); + if (client) { + if (entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName)) { + client.notify(`You've killed ${entity.nameData.values.name || "an unnamed tank"}`); + } - if ((entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName))) { - const client = this.cameraEntity.getClient(); - if (client) client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); + if (enableAchievements && !this.game.arena.disableAchievements) { + const victimIsTank = TankBody.isTank(entity); + + sendAchievementEvent(client, "kill", { + "weapon.isTank": TankBody.isTank(weapon), + "victim.arenaMobID": entity.arenaMobID, + "victim.isTank": victimIsTank, + "victim.isBoss": AbstractBoss.isBoss(entity), + "victim.isShiny": !!(entity.entityTags & EntityTags.isShiny), + "class": this.currentTank, + "victim.class": victimIsTank ? entity.currentTank : -1 + }); + } } // TODO(ABC): @@ -222,7 +251,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { if (this.styleData.flags & StyleFlags.isFlashing) this.styleData.flags ^= StyleFlags.isFlashing; if (this.isInvulnerable === invulnerable) return; - + if (invulnerable) { this.damageReduction = 0.0; this.physicsData.absorbtionFactor = 0.0; @@ -230,7 +259,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.damageReduction = 1.0; this.physicsData.absorbtionFactor = this.definition.absorbtionFactor; } - + this.isInvulnerable = invulnerable; } @@ -247,7 +276,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { super.receiveDamage(source, amount); } - + public calculateStatData() { // Body damage this.damagePerTick = this.cameraEntity.cameraData.statLevels[Stat.BodyDamage] + 5 + (this.definition.bodyDamage ?? 0); @@ -269,7 +298,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { // Movement speed this.cameraEntity.cameraData.movementSpeed = this.definition.speed * 2.55 * Math.pow(1.07, this.cameraEntity.cameraData.values.statLevels.values[Stat.MovementSpeed]) / Math.pow(1.015, this.cameraEntity.cameraData.values.level - 1); - + for (const barrel of this.barrels) barrel.calculateStatData(); } @@ -327,6 +356,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.healthData.health -= 2 + this.healthData.values.maxHealth / 500; if (this.isInvulnerable) this.setInvulnerability(false); + if (this.styleData.values.flags & StyleFlags.isFlashing) { this.styleData.flags ^= StyleFlags.isFlashing; this.damageReduction = 1.0; @@ -345,10 +375,10 @@ export default class TankBody extends LivingEntity implements BarrelBase { } else if (this.cameraEntity.cameraData.values.flags & CameraFlags.usesCameraCoords) this.cameraEntity.cameraData.flags ^= CameraFlags.usesCameraCoords; if (this.definition.flags.invisibility) { - if (this.inputs.flags & InputFlags.leftclick) this.styleData.opacity += this.definition.visibilityRateShooting; + if (this.inputs.flags & (InputFlags.up | InputFlags.down | InputFlags.left | InputFlags.right) || this.inputs.movement.x || this.inputs.movement.y) this.styleData.opacity += this.definition.visibilityRateMoving; - + this.styleData.opacity -= this.definition.invisibilityRate; this.styleData.opacity = util.constrain(this.styleData.values.opacity, 0, 1); @@ -369,6 +399,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { x: this.inputs.movement.x * this.cameraEntity.cameraData.values.movementSpeed, y: this.inputs.movement.y * this.cameraEntity.cameraData.values.movementSpeed }); + this.inputs.movement.set({ x: 0, y: 0 diff --git a/src/Gamemodes/Sandbox.ts b/src/Gamemodes/Sandbox.ts index f04770d4..772b21dd 100644 --- a/src/Gamemodes/Sandbox.ts +++ b/src/Gamemodes/Sandbox.ts @@ -49,6 +49,7 @@ export default class SandboxArena extends ArenaEntity { this.arenaData.values.flags |= ArenaFlags.canUseCheats; this.state = ArenaState.OPEN; // Sandbox should start instantly, no countdown + this.disableAchievements = true; this.setSandboxArenaSize(0); } diff --git a/src/Native/Arena.ts b/src/Native/Arena.ts index 6e704b4e..09a24cf2 100644 --- a/src/Native/Arena.ts +++ b/src/Native/Arena.ts @@ -70,6 +70,9 @@ export default class ArenaEntity extends Entity implements TeamGroupEntity { public state: ArenaState = ArenaState.COUNTDOWN; public shapeScoreRewardMultiplier: number = 1; + + /** If achievements cannot be obtained in this arena */ + public disableAchievements: boolean = false; /** The boss spawner. Set to null in gamemode file to disable boss spawning. */ public bossManager: BossManager | null = new BossManager(this); diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index 505b32fc..ef628469 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -26,10 +26,11 @@ import { Entity, EntityStateFlags } from "./Entity"; import { CameraGroup, RelationsGroup } from "./FieldGroups"; import { CameraFlags, ClientBound, levelToScore, levelToScoreTable, PhysicsFlags, Stat } from "../Const/Enums"; import { getTankById } from "../Const/TankDefinitions"; +import { sendAchievementEvent } from "../Const/Achievements"; import { removeFast } from "../util"; import { compileCreation, compileUpdate } from "./UpcreateCompiler"; -import { maxPlayerLevel } from "../config"; +import { maxPlayerLevel, enableAchievements } from "../config"; /** * Represents any entity with a camera field group. @@ -76,7 +77,18 @@ export class CameraEntity extends Entity { this.setFieldFactor(getTankById(this.cameraData.values.tank)?.fieldFactor ?? 1); this.calculateLevelData(); + + if (!enableAchievements || this.game.arena.disableAchievements) return; + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "levelUp", { + "level": level, + "class": this.cameraData.values.tank + }); } + /** Returns the camera's client if it exists */ public getClient(): Client | null { return null; @@ -94,8 +106,19 @@ export class CameraEntity extends Entity { if (player?.scoreData) player.scoreData.score += score; this.calculateLevelData(); + + if (!enableAchievements && !this.game.arena.disableAchievements) return; + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "score", { + "total": this.cameraData.values.score, + "delta": score, + "class": this.cameraData.values.tank + }); } - + public setScore(score: number) { this.cameraData.score = score; @@ -103,22 +126,53 @@ export class CameraEntity extends Entity { if (player?.scoreData) player.scoreData.score = score; this.calculateLevelData(); + + if (!enableAchievements || this.game.arena.disableAchievements) return; + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "score", { + "total": this.cameraData.values.score, + "delta": score, + "class": this.cameraData.values.tank + }); } - + public addStat(statId: Stat, amount: number) { this.cameraData.statLevels[statId] += amount; const player = this.cameraData.values.player; if (TankBody.isTank(player)) player.calculateStatData(); + + if (!enableAchievements || this.game.arena.disableAchievements) return; + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "statUpgraded", { + "id": statId, + "isMaxLevel": this.cameraData.values.statLevels[statId] >= this.cameraData.values.statLimits[statId] + }); } - + public setStat(statId: Stat, amount: number) { this.cameraData.statLevels[statId] = amount; const player = this.cameraData.values.player; if (TankBody.isTank(player)) player.calculateStatData(); + + if (!enableAchievements && !this.game.arena.disableAchievements) return; + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "statUpgraded", { + "id": statId, + "isMaxLevel": this.cameraData.values.statLevels[statId] >= this.cameraData.values.statLimits[statId] + }); } public calculateLevelData() { diff --git a/src/config.ts b/src/config.ts index faffafdf..4112ed21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,9 @@ export const shinyChance: number = 1 / 1_000_000; /** Chance for a player to spawn out of an allied factory. */ export const factorySpawnChance: number = 0.05; +/** Enable achievement event system. */ +export const enableAchievements: boolean = true; + /** Is hosting a rest api */ export const enableApi: boolean = true; diff --git a/src/index.ts b/src/index.ts index 7a981a30..aef74bb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import * as config from "./config" import * as util from "./util"; import GameServer from "./Game"; import TankDefinitions from "./Const/TankDefinitions"; +import Achievements from "./Const/Achievements"; import { commandDefinitions } from "./Const/Commands"; import { ColorsHexCode } from "./Const/Enums"; @@ -106,6 +107,9 @@ app.get("/*", (res, req) => { case "/colors": res.writeStatus("200 OK").end(JSON.stringify(ColorsHexCode)); return; + case "/achievements": + res.writeStatus("200 OK").end(JSON.stringify(Achievements)); + return; } } @@ -144,7 +148,7 @@ app.get("/*", (res, req) => { res.writeStatus("404 Not Found").end(fs.readFileSync(config.clientLocation + "/404.html")); return; - } + } }); app.listen(PORT, (success) => { @@ -157,9 +161,10 @@ app.listen(PORT, (success) => { // NOTES(0): As of now, both servers run on the same process (and thread) here const ffa = new GameServer(FFAArena, "FFA"); const sbx = new GameServer(SandboxArena, "Sandbox"); - + games.push(ffa, sbx); + util.saveToLog("Servers up", "All servers booted up.", 0x37F554); util.log("Dumping endpoint -> gamemode routing table"); for (const game of games) console.log("> " + `localhost:${config.serverPort}/${game.gamemode}`.padEnd(40, " ") + " -> " + game.name);