From ec579650d740a2057392e2696835dfe8b5005771 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 11 Jun 2026 21:43:49 +0100 Subject: [PATCH] Even more improvements --- backend/src/players/npc.ts | 3 +- frontend/src/main.scss | 471 ++++++++++++++++-- .../scripts/objects/types/character-view.ts | 168 ++++++- .../src/scripts/objects/types/planet-view.ts | 144 ++++-- shared/src/settings.ts | 47 +- 5 files changed, 724 insertions(+), 109 deletions(-) diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index bd2dee9..7bcb3f7 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -311,7 +311,8 @@ export class NPC extends PlayerBase { const leadTime = distance / projectileSpeed; const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime); - (npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * + const spread = + (npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * (npcTuning.spreadAggressionFalloff - this.aggression); aim.x += Random.getRandomInRange(-spread, spread); aim.y += Random.getRandomInRange(-spread, spread); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 4231090..08e6889 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -20,6 +20,7 @@ html { font-size: 0.85rem; + @media (max-width: $breakpoint) { font-size: 0.6rem; } @@ -30,17 +31,21 @@ img { } h1 { + &, * { font-family: 'Comfortaa', sans-serif; font-weight: 400; } + font-size: 6rem; text-align: center; padding-bottom: $medium-padding; + @media (max-width: $height-breakpoint) { font-size: 4.5rem; } + @media (max-height: $height-breakpoint) { display: none; } @@ -48,6 +53,7 @@ h1 { .red { color: $accent; + &::selection { color: $accent; background-color: white; @@ -75,8 +81,10 @@ body { top: 0; @include background; + #spinner { @include square(20vmax); + @media (max-width: $breakpoint) { @include square(20vmax); } @@ -107,7 +115,7 @@ body { overflow: hidden; - & > * { + &>* { display: inline-block; position: absolute; top: 0; @@ -120,8 +128,10 @@ body { &.decla { color: $bright-decla; - div:first-child { + + .health { background-color: $bright-decla; + &:before { background-color: $bright-decla; opacity: 0.3; @@ -131,8 +141,10 @@ body { &.red { color: $bright-red; - div:first-child { + + .health { background-color: $bright-red; + &:before { background-color: $bright-red; opacity: 0.4; @@ -140,7 +152,7 @@ body { } } - div:first-child { + .health { position: relative; height: 5px; border-radius: 1000px; @@ -155,9 +167,33 @@ body { } } - div:not(:first-child) { - letter-spacing: 2px; + .charge { + height: 3px; + margin-top: 2px; + border-radius: 1000px; + background-color: rgba(255, 255, 255, 0.65); + } + + .stats { + display: flex; + align-items: center; + gap: 8px; font-size: 0.8em; + font-weight: 700; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + + .stat { + display: inline-flex; + align-items: center; + gap: 3px; + } + + .icon { + width: 1.1em; + height: 1.1em; + fill: currentColor; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + } } } @@ -172,9 +208,13 @@ body { .falling-point { font-size: 1.2em; + font-weight: 700; + animation: falling-point 2s ease-out forwards; + &.decla { color: $bright-decla; } + &.red { color: $bright-red; } @@ -184,6 +224,7 @@ body { transition: transform 150ms; @include square($large-icon); + @media (max-width: $breakpoint) { @include square($small-icon); } @@ -219,6 +260,56 @@ body { } } + .touch-button { + display: none; + pointer-events: auto; + touch-action: none; + cursor: pointer; + + $size: $large-icon * 1.6; + @include square($size); + top: auto; + left: auto; + bottom: $medium-padding; + border-radius: 1000px; + background-color: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33); + border: $border-width solid rgba(255, 255, 255, 0.4); + + font-size: 1.6rem; + line-height: $size - 2 * $border-width; + text-align: center; + text-shadow: 0 0 6px rgba(0, 0, 0, 0.8); + + @media (hover: none) and (pointer: coarse) { + display: inline-block; + } + + &.fire { + right: $medium-padding; + + &::after { + content: '\25C9'; + } + } + + .strength-ring { + position: absolute; + top: -3px; + left: -3px; + width: calc(100% + 6px); + height: calc(100% + 6px); + border-radius: 1000px; + pointer-events: none; + -webkit-mask: radial-gradient(farthest-side, + transparent calc(100% - 5px), + #000 calc(100% - 5px)); + mask: radial-gradient(farthest-side, + transparent calc(100% - 5px), + #000 calc(100% - 5px)); + } + } + .announcement { top: 25%; transform: translateX(calc(-50% + 50vw)) translateY(-50%); @@ -242,20 +333,65 @@ body { } } + .tutorial-hint { + top: auto; + bottom: calc(#{$medium-padding} + #{$large-icon} * 1.6 + #{$medium-padding}); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + + font-size: 1.6rem; + @include background; + z-index: 1000; + padding: $small-padding $medium-padding; + border-radius: 1000px; + opacity: 0.9; + + &:empty { + display: none; + } + } + .planet-progress { top: $small-padding; left: 50%; transform: translateX(-50%); width: 50%; - display: flex; - $height: 8px; + $height: 20px; height: $height; - justify-content: space-between; - box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); - - border-radius: 4px; z-index: 100; + pointer-events: none; + + background-color: rgba(0, 0, 0, 0.35); + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.4); + border-radius: 1000px; + + .fill { + position: absolute; + top: 0; + height: $height; + transition: width $animation-time; + } + + .fill.decla { + right: 50%; + background: $bright-decla; + color: $bright-decla; + border-radius: 1000px 0 0 1000px; + } + + .fill.red { + left: 50%; + background: $bright-red; + color: $bright-red; + border-radius: 0 1000px 1000px 0; + } + + .fill.match-point { + z-index: 1; + animation: match-point-pulse 1.2s ease-in-out infinite; + } &::before { content: ''; @@ -263,38 +399,87 @@ body { left: 50%; top: 50%; transform: translateX(-50%) translateY(-50%); - background-color: #888; - height: 24px; - width: 4px; + background-color: #fff; + height: $height + 8px; + width: 3px; border-radius: 1000px; + z-index: 2; } - &::after { - content: ''; + .score { position: absolute; - left: 50%; top: 50%; - transform: translateX(-50%) translateY(50%); - - @include square(24px); - - background-image: url('../static/flag.svg'); - background-size: contain; + transform: translateY(-50%); + z-index: 3; + font-size: 0.95rem; + font-weight: 700; + line-height: 1; + color: #fff; + text-shadow: + 0 0 3px rgba(0, 0, 0, 0.95), + 0 0 6px rgba(0, 0, 0, 0.8); + opacity: 0.8; + transition: + opacity $animation-time, + text-shadow $animation-time, + font-size $animation-time; } - div { - height: $height; - box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); + .score.decla { + right: calc(50% + #{$small-padding}); } - div:nth-child(1) { - background: $bright-decla; - border-radius: 100px 0 0 100px; + .score.red { + left: calc(50% + #{$small-padding}); } - div:nth-child(2) { - background: $bright-red; - border-radius: 0 100px 100px 0; + .score.leading { + opacity: 1; + font-size: 1.2rem; + } + + .score.decla.leading { + text-shadow: + 0 0 3px rgba(0, 0, 0, 0.95), + 0 0 8px $bright-decla; + } + + .score.red.leading { + text-shadow: + 0 0 3px rgba(0, 0, 0, 0.95), + 0 0 8px $bright-red; + } + + .you-marker { + position: absolute; + top: 100%; + margin-top: 4px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + white-space: nowrap; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + + &::after { + content: ''; + position: absolute; + left: 50%; + bottom: 100%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid currentColor; + } + + &.decla { + right: calc(50% + #{$small-padding}); + color: $bright-decla; + } + + &.red { + left: calc(50% + #{$small-padding}); + color: $bright-red; + } } } } @@ -308,6 +493,7 @@ body { background-color: transparent; width: 3px; } + &::-webkit-scrollbar-thumb { background-color: $accent; border-radius: $border-radius; @@ -321,6 +507,10 @@ body { bottom: 0; left: 0; + @media (hover: none) and (pointer: coarse) { + display: none; + } + box-sizing: content-box; user-select: none; @@ -328,6 +518,7 @@ body { padding: $medium-padding; @include square($large-icon); + @media (max-width: $breakpoint) { @include square($small-icon); padding: $small-padding; @@ -338,3 +529,217 @@ body { } } } + +.feedback-hud { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; + z-index: 2000; + overflow: hidden; + + .hitmarker { + position: absolute; + transform: translate(-50%, -50%); + @include square(26px); + opacity: 0.9; + + &::before, + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 12px; + height: 2px; + background-color: white; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); + transform-origin: center; + } + + &::before { + transform: translate(-50%, -50%) rotate(45deg); + } + + &::after { + transform: translate(-50%, -50%) rotate(-45deg); + } + + animation: hitmarker-pop 250ms ease-out forwards; + } + + .kill-popup { + position: absolute; + transform: translate(-50%, -50%); + font-weight: 700; + font-size: 1.4rem; + white-space: nowrap; + color: $bright-neutral; + text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); + animation: kill-popup-rise 1200ms ease-out forwards; + + .heal { + color: #6fcf6f; + } + } + + .streak-callout { + position: absolute; + top: 33%; + left: 50%; + transform: translateX(-50%); + font-weight: 700; + font-size: 2.6rem; + color: $accent; + text-shadow: 0 0 12px rgba(0, 0, 0, 0.9); + white-space: nowrap; + animation: streak-pop 1400ms ease-out forwards; + } + + .killfeed { + position: absolute; + top: calc(#{$small-padding} + 36px); + right: $medium-padding; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + + .kill-entry { + font-size: 1rem; + padding: 4px 10px; + border-radius: 1000px; + @include background; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + animation: kill-entry-fade 4500ms ease-out forwards; + + b { + color: $accent; + } + } + } +} + +.charge-ring { + position: fixed; + @include square(56px); + margin: -28px 0 0 -28px; + border-radius: 1000px; + pointer-events: none; + user-select: none; + z-index: 2000; + opacity: 0; + transition: opacity 100ms; + -webkit-mask: radial-gradient(farthest-side, + transparent calc(100% - 6px), + #000 calc(100% - 6px)); + mask: radial-gradient(farthest-side, + transparent calc(100% - 6px), + #000 calc(100% - 6px)); + + &.full { + filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); + } +} + +@keyframes match-point-pulse { + 0%, + 100% { + box-shadow: 0 0 4px 0 currentColor; + } + + 50% { + box-shadow: 0 0 14px 3px currentColor; + } +} + +@keyframes falling-point { + 0% { + opacity: 1; + transform: translate(-50%, -50%); + } + + 100% { + opacity: 0; + transform: translate(-50%, calc(-50% - 90px)); + } +} + +@keyframes hitmarker-pop { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(1.6); + } + + 25% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.9); + } +} + +@keyframes kill-popup-rise { + 0% { + opacity: 0; + transform: translate(-50%, -30%) scale(0.8); + } + + 20% { + opacity: 1; + transform: translate(-50%, -60%) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -160%) scale(1); + } +} + +@keyframes streak-pop { + 0% { + opacity: 0; + transform: translateX(-50%) scale(0.6); + } + + 20% { + opacity: 1; + transform: translateX(-50%) scale(1.1); + } + + 70% { + opacity: 1; + transform: translateX(-50%) scale(1); + } + + 100% { + opacity: 0; + transform: translateX(-50%) scale(1); + } +} + +@keyframes kill-entry-fade { + 0% { + opacity: 0; + transform: translateX(20px); + } + + 10% { + opacity: 1; + transform: translateX(0); + } + + 80% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} \ No newline at end of file diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index d06d56e..034d97a 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -1,4 +1,5 @@ import { vec2 } from 'gl-matrix'; +import { CircleLight, Renderer } from 'sdf-2d'; import { Circle, @@ -6,6 +7,9 @@ import { CharacterBase, CharacterTeam, settings, + clamp, + clamp01, + mix, CommandExecutors, UpdatePropertyCommand, } from 'shared'; @@ -13,15 +17,36 @@ import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator'; -import { BlobShape } from '../../shapes/blob-shape'; +import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator'; +import { Pointer } from '../../helper/pointer'; +import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; import { VibrationHandler } from '../../vibration-handler'; +import { FeedbackHud } from '../../feedback-hud'; + +const muzzleFlashDecaySeconds = 0.12; +const hitFlashDecaySeconds = 0.15; + +const killIcon = + ''; +const deathIcon = + ''; export class CharacterView extends CharacterBase { - private shape: BlobShape; + private shape: CharacterShape; + private muzzleFlash: CircleLight; + private muzzleFlashIntensity = 0; + private hitFlashIntensity = 0; + private strength = settings.playerMaxStrength; + private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength); private nameElement: HTMLElement = document.createElement('div'); private statsElement: HTMLElement = document.createElement('div'); + private killCountElement: HTMLElement = document.createElement('span'); + private deathCountElement: HTMLElement = document.createElement('span'); private healthElement: HTMLElement = document.createElement('div'); + private chargeElement: HTMLElement = document.createElement('div'); public isMainCharacter = false; @@ -48,7 +73,12 @@ export class CharacterView extends CharacterBase { rightFoot?: Circle, ) { super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); - this.shape = new BlobShape(settings.colorIndices[team]); + this.shape = new CharacterShape(settings.colorIndices[team]); + this.muzzleFlash = new CircleLight( + vec2.clone(this.head!.center), + settings.paletteDim[settings.colorIndices[team]], + 0, + ); this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!); this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!); @@ -56,7 +86,24 @@ export class CharacterView extends CharacterBase { this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; + this.healthElement.className = 'health'; + this.chargeElement.className = 'charge'; + + this.statsElement.className = 'stats'; + this.killCountElement.className = 'value'; + this.deathCountElement.className = 'value'; + const killStat = document.createElement('span'); + killStat.className = 'stat kills'; + killStat.innerHTML = killIcon; + killStat.appendChild(this.killCountElement); + const deathStat = document.createElement('span'); + deathStat.className = 'stat deaths'; + deathStat.innerHTML = deathIcon; + deathStat.appendChild(this.deathCountElement); + this.statsElement.append(killStat, deathStat); + this.nameElement.appendChild(this.healthElement); + this.nameElement.appendChild(this.chargeElement); this.nameElement.appendChild(this.statsElement); } @@ -64,6 +111,29 @@ export class CharacterView extends CharacterBase { return this.head!.center; } + public get bodyCenter(): vec2 { + const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center); + vec2.add(center, center, this.rightFoot!.center); + return vec2.scale(center, center, 1 / 3); + } + + public get facingDirection(): vec2 { + const footAverage = vec2.add( + vec2.create(), + this.leftFoot!.center, + this.rightFoot!.center, + ); + vec2.scale(footAverage, footAverage, 0.5); + const forward = vec2.subtract(footAverage, this.head!.center, footAverage); + return vec2.length(forward) > 0 + ? vec2.normalize(forward, forward) + : vec2.fromValues(0, 1); + } + + public get strengthFraction(): number { + return clamp01(this.strength / settings.playerMaxStrength); + } + private updateProperty({ propertyKey, propertyValue, @@ -78,18 +148,25 @@ export class CharacterView extends CharacterBase { if (propertyKey === 'rightFoot') { this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange); } + if (propertyKey === 'strength') { + this.strengthExtrapolator.addFrame(propertyValue, rateOfChange); + } } public setHealth(health: number) { - const previousHealth = this.health; + const damage = this.health - health; super.setHealth(health); - SoundHandler.play( - Sounds.hit, - (0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength, - ); - if (this.isMainCharacter) { - VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4)); + if (damage > 0) { + SoundHandler.play( + Sounds.hit, + Math.min(1, (0.8 * damage) / settings.playerMaxStrength), + ); + this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength); + + if (this.isMainCharacter) { + VibrationHandler.vibrate(Math.min(200, damage * 4)); + } } } @@ -99,14 +176,58 @@ export class CharacterView extends CharacterBase { } } + public onHitConfirmed() { + if (!this.isMainCharacter) { + return; + } + SoundHandler.play(Sounds.click, 0.4, 1.7); + FeedbackHud.hitMarker(); + } + + public onKillConfirmed(victimName?: string, streak = 1) { + if (!this.isMainCharacter) { + return; + } + SoundHandler.play(Sounds.click, 1, 0.7); + VibrationHandler.vibrate(60); + FeedbackHud.killConfirmed(victimName, streak); + } + private step({ deltaTimeInSeconds }: StepCommand): void { this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds); this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds); this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds); + + this.strength = clamp( + this.strengthExtrapolator.getValue(deltaTimeInSeconds), + 0, + settings.playerMaxStrength, + ); + + if (this.muzzleFlashIntensity > 0) { + this.muzzleFlashIntensity = Math.max( + 0, + this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds, + ); + this.muzzleFlash.center = this.head!.center; + this.muzzleFlash.intensity = this.muzzleFlashIntensity; + } + + if (this.hitFlashIntensity > 0) { + this.hitFlashIntensity = Math.max( + 0, + this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds, + ); + } } public onShoot(strength: number) { - SoundHandler.play(Sounds.shoot, (0.6 * strength) / settings.playerMaxStrength); + const q = clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); + SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); + this.muzzleFlashIntensity = mix(0.35, 1, q); } private beforeDestroy(): void { @@ -127,15 +248,34 @@ export class CharacterView extends CharacterBase { this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; - this.statsElement.innerText = this.getStatsText(); + this.chargeElement.style.width = + (50 * this.strength) / settings.playerMaxStrength + 'px'; + this.killCountElement.innerText = String(this.killCount); + this.deathCountElement.innerText = String(this.deathCount); } + this.shape.hitFlash = this.hitFlashIntensity; + this.shape.gazeTarget = this.calculateGazeTarget(renderer); this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); renderer.addDrawable(this.shape); + + if (this.muzzleFlashIntensity > 0) { + renderer.addDrawable(this.muzzleFlash); + } } - private getStatsText(): string { - return `${this.killCount}⚔/${this.deathCount}☠`; + private calculateGazeTarget(renderer: Renderer): vec2 { + const cursor = Pointer.getDisplayPosition(); + if (this.isMainCharacter && cursor) { + return renderer.displayToWorldCoordinates(cursor); + } + + return vec2.scaleAndAdd( + vec2.create(), + this.head!.center, + this.facingDirection, + this.head!.radius * 8, + ); } private calculateTextPosition(): vec2 { diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index fbae00f..9c38963 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -1,21 +1,49 @@ -import { vec2 } from 'gl-matrix'; -import { Id, Random, PlanetBase, UpdatePropertyCommand, CommandExecutors } from 'shared'; +import { vec2, vec3 } from 'gl-matrix'; +import { CircleLight } from 'sdf-2d'; +import { + Id, + Random, + PlanetBase, + UpdatePropertyCommand, + CommandExecutors, + CharacterTeam, + settings, +} from 'shared'; import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; import { PlanetShape } from '../../shapes/planet-shape'; -type FallingPoint = { - velocity: vec2; - position: vec2; - element: HTMLElement; - addedToOverlay: boolean; - timeToLive: number; -}; +const fallingPointLifetimeMs = 2000; + +// Global budget for simultaneously-lit capture flares, so a clustered wave of +// captures can never white out the SDF exposure — excess flips still pulse the +// ring and toast, they just skip the extra light. The acquire/release pair keeps +// the count in one place instead of being hand-maintained at every call site. +abstract class FlareBudget { + private static active = 0; + + public static tryAcquire(): boolean { + if (FlareBudget.active >= settings.maxConcurrentFlipFlares) { + return false; + } + FlareBudget.active++; + return true; + } + + public static release(): void { + FlareBudget.active = Math.max(0, FlareBudget.active - 1); + } +} export class PlanetView extends PlanetBase { private shape: PlanetShape; private ownershipProgress: HTMLElement; + private readonly rotationSpeed: number; + + private flareLight?: CircleLight; + private flareIntensity = 0; + private holdsFlareSlot = false; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -27,45 +55,69 @@ export class PlanetView extends PlanetBase { constructor(id: Id, vertices: Array, ownership: number) { super(id, vertices); this.shape = new PlanetShape(vertices, ownership); - (this.shape as any).randomOffset = Random.getRandom(); + this.shape.randomOffset = Random.getRandom(); + this.rotationSpeed = + (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); this.ownershipProgress = document.createElement('div'); this.ownershipProgress.className = 'ownership'; } private step({ deltaTimeInSeconds }: StepCommand): void { - this.shape.randomOffset += deltaTimeInSeconds / 4; + this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed; this.shape.colorMixQ = this.ownership; - this.generatedPointElements.forEach((p) => { - vec2.add( - p.velocity, - p.velocity, - vec2.scale(vec2.create(), vec2.fromValues(0, 50), deltaTimeInSeconds), + if (this.flareIntensity > 0) { + this.flareIntensity = Math.max( + 0, + this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, ); - vec2.add( - p.position, - p.position, - vec2.scale(vec2.create(), p.velocity, deltaTimeInSeconds), - ); + if (this.flareLight) { + this.flareLight.intensity = + settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; + } - p.timeToLive -= deltaTimeInSeconds; - }); + if (this.flareIntensity === 0) { + this.releaseFlareSlot(); + } + } } - private generatedPointElements: Array = []; + private releaseFlareSlot(): void { + if (this.holdsFlareSlot) { + this.holdsFlareSlot = false; + FlareBudget.release(); + } + } private lastGeneratedPoint?: number; public generatedPoints(value: number) { this.lastGeneratedPoint = value; } + public onFlipped(team: CharacterTeam): void { + const color = settings.palette[settings.colorIndices[team]]; + + if (!this.flareLight) { + this.flareLight = new CircleLight(vec2.clone(this.center), vec3.clone(color), 0); + } else { + this.flareLight.color = vec3.clone(color); + } + + if (!this.holdsFlareSlot) { + if (!FlareBudget.tryAcquire()) { + return; + } + this.holdsFlareSlot = true; + } + + this.flareIntensity = 1; + } + private beforeDestroy(): void { this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress); - this.generatedPointElements.forEach((p) => - p.element.parentElement?.removeChild(p.element), - ); + this.releaseFlareSlot(); } private updateProperty({ propertyValue }: UpdatePropertyCommand): void { @@ -80,26 +132,6 @@ export class PlanetView extends PlanetBase { const screenPosition = renderer.worldToDisplayCoordinates(this.center); - this.generatedPointElements.forEach((p) => { - if (!p.addedToOverlay) { - overlay.appendChild(p.element); - } - - p.element.style.transform = `translateX(${ - screenPosition.x + p.position.x - }px) translateY(${screenPosition.y + p.position.y}px)`; - - if (p.timeToLive <= 0) { - p.element.parentElement?.removeChild(p.element); - } else { - p.element.style.opacity = Math.min(1, p.timeToLive).toString(); - } - }); - - this.generatedPointElements = this.generatedPointElements.filter( - (p) => p.timeToLive > 0, - ); - this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`; this.ownershipProgress.style.background = this.getGradient(); @@ -107,19 +139,23 @@ export class PlanetView extends PlanetBase { const element = document.createElement('div'); element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red'); element.innerText = '+' + this.lastGeneratedPoint; - this.generatedPointElements.push({ - element, - addedToOverlay: false, - timeToLive: Random.getRandomInRange(2, 3), - position: vec2.create(), - velocity: vec2.fromValues(Random.getRandomInRange(-30, 30), 0), - }); + element.style.left = `${screenPosition.x}px`; + element.style.top = `${screenPosition.y}px`; + overlay.appendChild(element); + setTimeout( + () => element.parentElement?.removeChild(element), + fallingPointLifetimeMs, + ); this.lastGeneratedPoint = undefined; } } renderer.addDrawable(this.shape); + + if (this.flareIntensity > 0 && this.flareLight) { + renderer.addDrawable(this.flareLight); + } } private getGradient(): string { diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 96a6275..f38bd81 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -6,39 +6,62 @@ const neutralColor = rgb255(82, 165, 64); const redColor = rgb255(209, 86, 82); const q = 2.5; const declaColorDim = rgb255(64 * q, 105 * q, 165 * q); -const redColorDim = rgb255(209 * q, 8 * q, 82 * q); +const redColorDim = rgb255(209 * q, 86 * q, 82 * q); const declaPlanetColor = declaColorDim; const redPlanetColor = redColorDim; export const settings = { lightCutoffDistance: 600, + lightOverlapReduction: 0.85, radiusSteps: 500, spawnDespawnTime: 0.7, - worldRadius: 10000, + worldRadius: 4000, objectsOnCircleLength: 0.002, updateMessageInterval: 1 / 25, planetEdgeCount: 7, - playerKillPoint: 10, - takeControlTimeInSeconds: 4, - loseControlTimeInSeconds: 24, + playerKillPoint: 25, + takeControlTimeInSeconds: 2.5, + loseControlTimeInSeconds: 16, planetPointGenerationInterval: 1.5, planetPointGenerationValue: 1, + planetSizePointMultiplierMax: 4, + captureFlipPointReward: 12, maxGravityDistance: 800, + minGravityDistance: 1, maxGravityQ: 5000, planetControlThreshold: 0.2, playerMaxHealth: 100, maxGravityStrength: 50000, + planetMinReferenceRadius: 150, + planetMaxReferenceRadius: 1200, maxAcceleration: 120000, playerMaxStrength: 80, endGameDeltaScaling: 2, playerDiedTimeout: 5, playerStrengthRegenerationPerSeconds: 80, + playerOutOfCombatDelaySeconds: 5, + playerHealthRegenerationPerSeconds: 8, + playerKillHealthReward: 25, + spawnInvulnerabilityExtraSeconds: 0.4, + spawnSafetyDistance: 2000, + touchAimRange: 1000, + postureHeadStiffness: 12, + postureFeetStiffness: 10, + planetDetachmentSeconds: 0.5, + planetDetachmentForceThreshold: 100, + climbDotThreshold: 0.8, + climbGravityScale: 0.35, projectileMaxStrength: 40, - projectileSpeed: 2500, projectileMaxBounceCount: 2, - projectileTimeout: 3, projectileFadeSpeed: 20, projectileCreationInterval: 0.1, + chargeShotFullHoldSeconds: 0.7, + chargeShotStrengthMin: 40, + chargeShotStrengthMax: 100, + chargeShotRadiusMin: 20, + chargeShotRadiusMax: 32, + chargeShotSpeedMin: 2500, + chargeShotSpeedMax: 3400, playerColorIndexOffset: 3, backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], declaColor, @@ -104,4 +127,14 @@ export const settings = { paletteDim: [declaColorDim, neutralColor, redColorDim], targetPhysicsDeltaTimeInSeconds: 1 / 200, inViewAreaSize: 1920 * 1080 * 4, + scoreboardHalfWidthPercent: 50, + scoreboardMinFillPercent: 1.5, + matchPointScoreRatio: 0.9, + lampLerpSeconds: 0.5, + lampMinLightness: 0.4, + lampMaxLightness: 1, + lampFlareIntensity: 1.2, + lampFlareDecaySeconds: 0.6, + maxConcurrentFlipFlares: 3, + announcementVisibleSeconds: 2, };