diff --git a/server/src/Core/Item.php b/server/src/Core/Item.php index cfe81fc..cf0d80e 100644 --- a/server/src/Core/Item.php +++ b/server/src/Core/Item.php @@ -15,6 +15,7 @@ abstract class Item private int $skinId; protected bool $equipped = false; protected int $price = 9999; + protected int $scopeLevel = 0; private ?EquipEvent $eventEquip = null; /** @var array */ public readonly array $toArrayCache; @@ -64,6 +65,11 @@ abstract class Item return 1; } + public function getScopeLevel(): int + { + return $this->scopeLevel; + } + public function decrementQuantity(): void { // empty hook @@ -119,6 +125,7 @@ abstract class Item if ($this->eventEquip === null) { $this->eventEquip = new EquipEvent(function () { $this->equipped = true; + $this->scopeLevel = 0; }, static::equipReadyTimeMs); } @@ -129,6 +136,7 @@ abstract class Item public function unEquip(): void { $this->equipped = false; + $this->scopeLevel = 0; } public function isEquipped(): bool diff --git a/server/src/Core/Player.php b/server/src/Core/Player.php index b77c603..5940867 100644 --- a/server/src/Core/Player.php +++ b/server/src/Core/Player.php @@ -326,6 +326,7 @@ final class Player 'ammo' => $ammo, 'ammoReserve' => $ammoReserve, 'isReloading' => $reloading, + 'scopeLevel' => $equippedItem->getScopeLevel(), ]; } diff --git a/server/src/Interface/ScopeItem.php b/server/src/Interface/ScopeItem.php new file mode 100644 index 0000000..554c88e --- /dev/null +++ b/server/src/Interface/ScopeItem.php @@ -0,0 +1,12 @@ +getEquippedItem(); + if ($item instanceof ScopeItem) { + $item->scope(); + } if (!($item instanceof AttackEnable)) { return null; // @codeCoverageIgnore } diff --git a/server/src/Traits/Player/MovementTrait.php b/server/src/Traits/Player/MovementTrait.php index 7105256..df6a847 100644 --- a/server/src/Traits/Player/MovementTrait.php +++ b/server/src/Traits/Player/MovementTrait.php @@ -11,6 +11,7 @@ use cs\Enum\ItemType; use cs\Enum\SoundType; use cs\Event\PlayerMovementEvent; use cs\Event\SoundEvent; +use cs\Interface\ScopeItem; trait MovementTrait { @@ -168,6 +169,9 @@ trait MovementTrait } elseif ($equippedItem->getType() === ItemType::TYPE_WEAPON_SECONDARY) { $speed *= Setting::getWeaponSecondarySpeedMultiplier($equippedItem->getId()); } + if ($equippedItem instanceof ScopeItem && $equippedItem->isScopedIn()) { + $speed *= .5; + } if ($this->isJumping()) { $speed *= Setting::jumpMovementSpeedMultiplier(); } elseif ($this->isFlying()) { diff --git a/server/src/Weapon/AmmoBasedWeapon.php b/server/src/Weapon/AmmoBasedWeapon.php index 4a213ca..dc06ac1 100644 --- a/server/src/Weapon/AmmoBasedWeapon.php +++ b/server/src/Weapon/AmmoBasedWeapon.php @@ -71,9 +71,18 @@ abstract class AmmoBasedWeapon extends BaseWeapon implements Reloadable, AttackE $this->lastAttackTick = $event->getTickId(); $this->recoilModifier($event); + $event->applyRecoil(...$this->getSpreadOffsets()); return $event->fire(); } + /** + * @return float[] [offsetHorizontal, offsetVertical] + */ + protected function getSpreadOffsets(): array + { + return [0.0, 0.0]; + } + protected function resetRecoil(int $tickId = 0): void { $this->lastRecoilTick = $tickId; diff --git a/server/src/Weapon/RifleAWP.php b/server/src/Weapon/RifleAWP.php index 3967279..8aeec6d 100644 --- a/server/src/Weapon/RifleAWP.php +++ b/server/src/Weapon/RifleAWP.php @@ -4,8 +4,9 @@ namespace cs\Weapon; use cs\Enum\ArmorType; use cs\Enum\HitBoxType; +use cs\Interface\ScopeItem; -class RifleAWP extends AmmoBasedWeapon +class RifleAWP extends AmmoBasedWeapon implements ScopeItem { public const reloadTimeMs = 3700; @@ -31,4 +32,27 @@ class RifleAWP extends AmmoBasedWeapon }; } + protected function getSpreadOffsets(): array + { + if ($this->scopeLevel === 0) { + return [rand(50, 120) / (rand(0, 1) === 0 ? -10 : +10), rand(40, 80) / (rand(0, 1) === 0 ? +10 : -10)]; + } + + $this->scopeLevel = 0; + return [0.0, 0.0]; + } + + public function scope(): void + { + $this->scopeLevel++; + if ($this->scopeLevel > 2) { + $this->scopeLevel = 0; + } + } + + public function isScopedIn(): bool + { + return ($this->scopeLevel !== 0); + } + } diff --git a/test/og/Unit/ProtocolTest.php b/test/og/Unit/ProtocolTest.php index 729abf6..2077eee 100644 --- a/test/og/Unit/ProtocolTest.php +++ b/test/og/Unit/ProtocolTest.php @@ -105,6 +105,7 @@ class ProtocolTest extends BaseTest 'ammo' => 12, 'ammoReserve' => 120, 'isReloading' => false, + 'scopeLevel' => 0, ]; $this->assertSame($playerSerializedExpected, $player->serialize()); diff --git a/www/assets/css/hud.css b/www/assets/css/hud.css index 5210619..20957c6 100644 --- a/www/assets/css/hud.css +++ b/www/assets/css/hud.css @@ -9,6 +9,7 @@ --color-opponent: #de6868; --color-opponent-hsl: 0, 64%, 64%; --flash-bang-color: #FFFFFF; + --scope-size: 2px; } #hud .color-me { @@ -19,11 +20,56 @@ color: var(--color-opponent); } +#hud-container { + position: absolute; + width: 100%; + height: 100%; +} + #hud #cross { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + opacity: 1; +} + +#hud #scope { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + height: 82%; + width: auto; + aspect-ratio: 1; + border-radius: 50%; + margin: auto auto; + box-shadow: 0 0 0 9999px #000, rgba(0, 0, 0, 0.5) 0 0 10px 4px inset; + opacity: 0; +} + +#hud #scope .scope-cross { + width: 100%; + height: 100%; + filter: blur(0); +} + +#hud #scope .scope-cross::before, +#hud #scope .scope-cross::after { + width: 100%; + height: 100%; + display: block; + content: ""; +} + +#hud #scope .scope-cross::before { + border-left: var(--scope-size) solid #000000; + transform: translate(calc(50% - 0.5 * var(--scope-size)), 0%); +} + +#hud #scope .scope-cross::after { + border-top: var(--scope-size) solid #000000; + transform: translate(0%, calc(-50% - 0.5 * var(--scope-size))); } #hud #flash { diff --git a/www/assets/js/Game.js b/www/assets/js/Game.js index 5ed4c77..3de85f9 100644 --- a/www/assets/js/Game.js +++ b/www/assets/js/Game.js @@ -8,6 +8,7 @@ export class Game { #hud #stats #pointer + #setting #shouldRenderInsideTick #tick = 0 #round = 1 @@ -467,6 +468,9 @@ export class Game { player.get3DObject().getObjectByName('sight').position.y = serverState.sight player.get3DObject().position.set(serverState.position.x, serverState.position.y, -serverState.position.z) + if (player.data.scopeLevel !== serverState.scopeLevel) { + this.#updateScopeState(player, serverState.scopeLevel) + } if (player.data.isAttacker === this.playerMe.data.isAttacker) { // if player on my team if (player.data.money !== serverState.money) { this.#hud.updateMyTeamPlayerMoney(player.data, serverState.money) @@ -476,6 +480,7 @@ export class Game { player.data.item = serverState.item player.data.sight = serverState.sight player.data.isAttacker = serverState.isAttacker + player.data.scopeLevel = serverState.scopeLevel } if (this.playerMe.getId() === serverState.id || this.playerSpectate.getId() === serverState.id) { @@ -544,6 +549,20 @@ export class Game { modelInHand.visible = true } + #updateScopeState(player, scopeLevel) { + const isPlayerSpectate = (this.playerSpectate.getId() === player.getId()) + if (scopeLevel > 0) { + this.#world.playSound('210018__supakid13__sniper-scope-zoom-in.wav', player.data.position, isPlayerSpectate) + } + if (isPlayerSpectate) { + this.#hud.updateCrossHair(scopeLevel) + this.#world.updateCameraZoom(scopeLevel === 0 ? 1.0 : scopeLevel * 2.2) + if (this.meIsAlive()) { + this.#pointer.pointerSpeed = (scopeLevel === 0 ? this.#setting.getSensitivity() : this.#setting.getInScopeSensitivity()) + } + } + } + getMyTeamPlayers() { let meIsAttacker = this.playerMe.isAttacker() return this.players.filter((player) => player.isAttacker() === meIsAttacker) @@ -557,9 +576,10 @@ export class Game { return (!this.meIsAlive()) } - setDependency(pointer, renderWorldInsideTick) { + setDependency(pointer, setting) { this.#pointer = pointer - this.#shouldRenderInsideTick = renderWorldInsideTick + this.#setting = setting + this.#shouldRenderInsideTick = setting.shouldMatchServerFps() } getPlayerMeRotation() { diff --git a/www/assets/js/Hud.js b/www/assets/js/Hud.js index e744d66..48fc5da 100644 --- a/www/assets/js/Hud.js +++ b/www/assets/js/Hud.js @@ -43,6 +43,8 @@ export class HUD { aliveOpponentTeam: null, time: null, killFeed: null, + cross: null, + scope: null, } #flashInterval = null; #countDownIntervalId = null; @@ -114,6 +116,11 @@ export class HUD { this.#flashInterval = setTimeout(callback, fullFlashTimeMs) } + updateCrossHair(scopeLevel) { + this.#elements.cross.style.opacity = (scopeLevel === 0 ? 1.0 : 0.0) + this.#elements.scope.style.opacity = (scopeLevel === 0 ? 0.0 : 1.0) + } + roundStart(roundTimeMs) { this.#startCountDown(roundTimeMs) } @@ -242,7 +249,9 @@ export class HUD { } elementHud.innerHTML = ` +
+
+ `; elementHud.style.setProperty('--flash-bang-color', setting.getFlashBangColor()) + elementHud.style.setProperty('--scope-size', setting.getScopeSize()) this.#elements.flash = elementHud.querySelector('#flash') this.#elements.score = elementHud.querySelector('#scoreboard') @@ -334,9 +345,10 @@ export class HUD { this.#elements.aliveOpponentTeam = elementHud.querySelector('.team-opponent-alive') this.#elements.time = elementHud.querySelector('#time') this.#elements.killFeed = elementHud.querySelector('.kill-feed') + this.#elements.cross = elementHud.querySelector('#cross') + this.#elements.scope = elementHud.querySelector('#scope') - const cross = elementHud.querySelector('#cross') - cross.innerText = setting.getCrosshairSymbol() + this.#elements.cross.innerText = setting.getCrosshairSymbol() setting.addUpdateCallback('crosshairColor', (newValue) => cross.style.color = newValue) setting.update('crosshairColor', setting.getCrosshairColor()) setting.addUpdateCallback('crosshairSize', (newValue) => cross.style.fontSize = newValue + 'px') diff --git a/www/assets/js/ModelRepository.js b/www/assets/js/ModelRepository.js index a63d703..25142fb 100644 --- a/www/assets/js/ModelRepository.js +++ b/www/assets/js/ModelRepository.js @@ -172,6 +172,7 @@ export class ModelRepository { models[ItemId.Knife] = 'knife.glb' models[ItemId.RifleAk] = 'ak.glb' models[ItemId.RifleM4A4] = 'm4.glb' + models[ItemId.RifleAWP] = 'm4.glb' // fixme :D models[ItemId.PistolUsp] = 'pistol.glb' // fixme models[ItemId.PistolP250] = 'pistol.glb' models[ItemId.PistolGlock] = 'pistol.glb' // fixme diff --git a/www/assets/js/Player.js b/www/assets/js/Player.js index 1f8f344..5b3ee14 100644 --- a/www/assets/js/Player.js +++ b/www/assets/js/Player.js @@ -24,6 +24,7 @@ export class Player { ammo: null, ammoReserve: null, isReloading: null, + scopeLevel: null, } #custom = { slotId: null, diff --git a/www/assets/js/Setting.js b/www/assets/js/Setting.js index 00625e0..1404617 100644 --- a/www/assets/js/Setting.js +++ b/www/assets/js/Setting.js @@ -8,11 +8,13 @@ export class Setting { volume: 20, radarZoom: 0.9, sensitivity: 1.0, + inScopeSensitivity: 0.5, sprayTriggerDeltaMs: 80, crosshair: '✛', crosshairColor: '#d31b1b', crosshairSize: 40, flashBangColor: '#FFFFFF', + scopeSize: '2px', preferPerformance: false, matchServerFps: false, anisotropic: 16, @@ -127,6 +129,10 @@ export class Setting { return this.#setting.base.sensitivity ?? 1.0 } + getInScopeSensitivity() { + return this.#setting.base.inScopeSensitivity ?? 0.5 + } + getExposure() { return this.#setting.base.exposure ?? 0.8 } @@ -150,4 +156,8 @@ export class Setting { getFlashBangColor() { return this.#setting.base.flashBangColor ?? '#FFFFFF'; } + + getScopeSize() { + return this.#setting.base.scopeSize ?? '2px'; + } } diff --git a/www/assets/js/SoundRepository.js b/www/assets/js/SoundRepository.js index 181d4ec..8398ef3 100644 --- a/www/assets/js/SoundRepository.js +++ b/www/assets/js/SoundRepository.js @@ -1,4 +1,4 @@ -import {InventorySlot, SoundType} from "./Enums.js"; +import {InventorySlot, SoundType, ItemId} from "./Enums.js"; export class SoundRepository { #alwaysInHeadTypes = [ @@ -152,6 +152,10 @@ export class SoundRepository { } getItemAttackSound(item) { + if (item.id === ItemId.RifleAWP) { + return '371574__matrixxx__rifle-gun-tikka-t3-tactical-shot-04.wav' + } + if (item.slot === InventorySlot.SLOT_SECONDARY) { return '387480__cosmicembers__dart-thud-2.wav' } diff --git a/www/assets/js/World.js b/www/assets/js/World.js index 99af77e..9a9af51 100644 --- a/www/assets/js/World.js +++ b/www/assets/js/World.js @@ -256,6 +256,11 @@ export class World { this.#renderer.render(this.#scene, this.#camera) } + updateCameraZoom(zoomLevel) { + this.#camera.zoom = zoomLevel + this.#camera.updateProjectionMatrix() + } + getCamera() { return this.#camera } diff --git a/www/assets/js/start.js b/www/assets/js/start.js index 8a5da75..11394b8 100644 --- a/www/assets/js/start.js +++ b/www/assets/js/start.js @@ -56,7 +56,7 @@ let launchGame hud.createHud(elementHud, map, setting) control.init(canvasParent, pointerLock, setting) - game.setDependency(pointerLock, setting.shouldMatchServerFps()) + game.setDependency(pointerLock, setting) canvas.addEventListener("click", () => game.requestPointerLock()) canvasParent.appendChild(canvas) stats.dom.style.position = 'inherit' diff --git a/www/resources/sound/210018__supakid13__sniper-scope-zoom-in.wav b/www/resources/sound/210018__supakid13__sniper-scope-zoom-in.wav new file mode 100644 index 0000000..c730db7 Binary files /dev/null and b/www/resources/sound/210018__supakid13__sniper-scope-zoom-in.wav differ diff --git a/www/resources/sound/371574__matrixxx__rifle-gun-tikka-t3-tactical-shot-04.wav b/www/resources/sound/371574__matrixxx__rifle-gun-tikka-t3-tactical-shot-04.wav new file mode 100644 index 0000000..2e0c8c7 Binary files /dev/null and b/www/resources/sound/371574__matrixxx__rifle-gun-tikka-t3-tactical-shot-04.wav differ