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,
};