Add tests
This commit is contained in:
parent
d0265ad90e
commit
b6db7e8dc7
18 changed files with 494 additions and 109 deletions
|
|
@ -594,8 +594,8 @@ body {
|
|||
.hitmarker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
@include square(26px);
|
||||
opacity: 0.9;
|
||||
@include square(34px);
|
||||
opacity: 0.95;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
|
|
@ -603,10 +603,13 @@ body {
|
|||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
|
||||
box-shadow:
|
||||
0 0 6px rgba(255, 255, 255, 0.9),
|
||||
0 0 3px rgba(0, 0, 0, 0.9);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
|
|
@ -618,31 +621,58 @@ body {
|
|||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
animation: hitmarker-pop 250ms ease-out forwards;
|
||||
animation: hitmarker-pop 220ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
|
||||
|
||||
&.charged {
|
||||
@include square(44px);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
background-color: $accent;
|
||||
box-shadow:
|
||||
0 0 10px rgba($accent, 0.9),
|
||||
0 0 4px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kill-flash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at center,
|
||||
transparent 42%,
|
||||
rgba($accent, 0) 56%,
|
||||
rgba($accent, 0.55) 100%);
|
||||
animation: kill-flash 420ms ease-out forwards;
|
||||
|
||||
&.charged {
|
||||
background: radial-gradient(ellipse at center,
|
||||
transparent 36%,
|
||||
rgba($accent, 0) 50%,
|
||||
rgba($accent, 0.8) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.kill-popup {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.7rem;
|
||||
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;
|
||||
text-shadow:
|
||||
0 0 10px rgba(0, 0, 0, 0.9),
|
||||
0 0 18px rgba(255, 255, 255, 0.35);
|
||||
animation: kill-popup-rise 1200ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards;
|
||||
|
||||
&.charged {
|
||||
color: $accent;
|
||||
text-shadow:
|
||||
0 0 10px rgba(0, 0, 0, 0.9),
|
||||
0 0 18px rgba($accent, 0.6);
|
||||
}
|
||||
|
||||
.heal {
|
||||
|
|
@ -655,12 +685,14 @@ body {
|
|||
top: 33%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-weight: 700;
|
||||
font-size: 2.6rem;
|
||||
font-weight: 800;
|
||||
font-size: 3rem;
|
||||
color: $accent;
|
||||
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9);
|
||||
text-shadow:
|
||||
0 0 12px rgba(0, 0, 0, 0.9),
|
||||
0 0 24px rgba($accent, 0.7);
|
||||
white-space: nowrap;
|
||||
animation: streak-pop 1400ms ease-out forwards;
|
||||
animation: streak-pop 1400ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.killfeed {
|
||||
|
|
@ -748,46 +780,72 @@ body {
|
|||
@keyframes hitmarker-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.6);
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
}
|
||||
|
||||
25% {
|
||||
18% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(0.82);
|
||||
}
|
||||
|
||||
36% {
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kill-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
12% {
|
||||
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);
|
||||
transform: translate(-50%, -20%) scale(0.6);
|
||||
}
|
||||
|
||||
20% {
|
||||
12% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -60%) scale(1);
|
||||
transform: translate(-50%, -56%) scale(1.25);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: translate(-50%, -62%) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -160%) scale(1);
|
||||
transform: translate(-50%, -170%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.6);
|
||||
transform: translateX(-50%) scale(0.5);
|
||||
}
|
||||
|
||||
20% {
|
||||
16% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
transform: translateX(-50%) scale(1.25);
|
||||
}
|
||||
|
||||
32% {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
70% {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ export abstract class FeedbackHud {
|
|||
const { killfeed } = this.ensureRoot();
|
||||
const charged = charge >= settings.chargedHitThreshold;
|
||||
|
||||
// A quick crimson vignette pulse around the whole frame to punctuate the kill.
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'kill-flash' + (charged ? ' charged' : '');
|
||||
this.addTransient(flash, 420);
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'kill-entry';
|
||||
entry.innerHTML = `Eliminated <b>${this.escape(victimName ?? 'enemy')}</b>`;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { localCharacterPredictor } from './helper/prediction/local-character-pre
|
|||
import { Tutorial } from './tutorial';
|
||||
import { Scoreboard } from './scoreboard';
|
||||
import { Minimap } from './minimap';
|
||||
import { ScreenShake } from './screen-shake';
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
|
|
@ -80,6 +81,9 @@ export class Game extends CommandReceiver {
|
|||
this.socket?.close();
|
||||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
// Clear any leftover shake/zoom so a kill at the end of one match can't bleed
|
||||
// its camera impact into the next.
|
||||
ScreenShake.reset();
|
||||
this.gameObjects = new GameObjectContainer(this);
|
||||
this.overlay.innerHTML = '';
|
||||
this.keystoneArrow = undefined;
|
||||
|
|
@ -232,6 +236,11 @@ export class Game extends CommandReceiver {
|
|||
this.resolveStarted();
|
||||
deltaTime /= 1000;
|
||||
|
||||
// Decay the camera impact effects on raw wall-clock time, before any of the
|
||||
// end-game slow-motion scaling below. These only adjust the rendered view,
|
||||
// never the simulation, so they stay decoupled from prediction and netcode.
|
||||
ScreenShake.step(deltaTime);
|
||||
|
||||
// Stepped before the end-game time scaling on purpose: the slow motion is
|
||||
// already baked into the snapshots the server sends, so the playback
|
||||
// cursor itself must keep running on wall-clock time.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,12 @@ export class JoinFormHandler {
|
|||
private resolvePlayerDecision!: (d: PlayerDecision) => void;
|
||||
private pollServersTimer: any;
|
||||
private keyUpListener = (e: KeyboardEvent) => {
|
||||
if (e.key === 'enter') {
|
||||
this.form.submit();
|
||||
// KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase
|
||||
// comparison never matched, so pressing Enter silently did nothing.
|
||||
// requestSubmit() (unlike submit()) fires the form's onsubmit handler and
|
||||
// runs HTML5 validation, so Enter behaves exactly like clicking Join.
|
||||
if (e.key === 'Enter' && !this.joinButton.disabled) {
|
||||
this.form.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,18 @@ import {
|
|||
CircleLight,
|
||||
FilteringOptions,
|
||||
hsl,
|
||||
NoisyPolygonFactory,
|
||||
Renderer,
|
||||
renderNoise,
|
||||
runAnimation,
|
||||
WrapOptions,
|
||||
} from 'sdf-2d';
|
||||
import { settings, rgb, PlanetBase, Random } from 'shared';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
|
||||
const landingPageVertexCount = 9;
|
||||
const LandingPagePolygon = NoisyPolygonFactory(
|
||||
landingPageVertexCount,
|
||||
rgb(0.5, 0.4, 0.7),
|
||||
);
|
||||
// colorMixQ ends of PlanetShape's blue<->red gradient, so the two backdrop
|
||||
// planets read as the two in-game teams.
|
||||
const bluePlanet = 0;
|
||||
const redPlanet = 1;
|
||||
|
||||
export class LandingPageBackground {
|
||||
private isActive = true;
|
||||
|
|
@ -34,7 +33,7 @@ export class LandingPageBackground {
|
|||
canvas,
|
||||
[
|
||||
{
|
||||
...LandingPagePolygon.descriptor,
|
||||
...PlanetShape.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2],
|
||||
},
|
||||
{
|
||||
|
|
@ -74,34 +73,41 @@ export class LandingPageBackground {
|
|||
0.7 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const topPlanet = new LandingPagePolygon(
|
||||
const topPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
topPlanetPosition,
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 20),
|
||||
landingPageVertexCount,
|
||||
),
|
||||
redPlanet,
|
||||
);
|
||||
|
||||
(topPlanet as any).randomOffset = 0.5 + time / 3500;
|
||||
// Fixed terrain phase (no longer animated -> no pulsing); the planet spins
|
||||
// instead, the same way in-game planets do: PlanetShape's rotation uniform
|
||||
// turns the whole body, terrain and outline together, in its own frame.
|
||||
// Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and
|
||||
// counter-rotate so the two planets don't drift in lockstep.
|
||||
topPlanet.randomOffset = Random.getRandom();
|
||||
topPlanet.rotation = (time / 1000) * 0.09;
|
||||
|
||||
const bottomPlanetPosition = vec2.fromValues(
|
||||
0.3 * renderer.canvasSize.x,
|
||||
0.3 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const bottomPlanet = new LandingPagePolygon(
|
||||
const bottomPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
bottomPlanetPosition,
|
||||
Random.getRandomInRange(150, 800),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 40),
|
||||
landingPageVertexCount,
|
||||
),
|
||||
bluePlanet,
|
||||
);
|
||||
|
||||
(bottomPlanet as any).randomOffset = time / 2500;
|
||||
bottomPlanet.randomOffset = Random.getRandom();
|
||||
bottomPlanet.rotation = (time / 1000) * -0.06;
|
||||
|
||||
const planetDistance = vec2.subtract(
|
||||
vec2.create(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { Game } from '../../game';
|
||||
import { ScreenShake } from '../../screen-shake';
|
||||
|
||||
export class Camera extends CommandReceiver {
|
||||
public center: vec2 = vec2.create();
|
||||
|
|
@ -46,7 +47,16 @@ export class Camera extends CommandReceiver {
|
|||
this.game.aspectRatioChanged(canvasAspectRatio);
|
||||
}
|
||||
|
||||
const viewArea = calculateViewArea(this.center, canvasAspectRatio);
|
||||
// Shake displaces only the rendered view centre and the zoom-punch shrinks
|
||||
// only the rendered view area — neither touches the followed position, so
|
||||
// impacts jolt the frame without nudging the camera off the player. Passing
|
||||
// the zoom as oversizeRatio scales the area about the (shaken) centre.
|
||||
const shakenCenter = vec2.fromValues(
|
||||
this.center[0] + ScreenShake.offsetX,
|
||||
this.center[1] + ScreenShake.offsetY,
|
||||
);
|
||||
const scale = ScreenShake.viewScale;
|
||||
const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale);
|
||||
renderer.setViewArea(viewArea.topLeft, viewArea.size);
|
||||
|
||||
renderer.setRuntimeSettings({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { CircleLight, Renderer } from 'sdf-2d';
|
||||
|
||||
import {
|
||||
|
|
@ -23,10 +23,18 @@ import { CharacterShape } from '../../shapes/character-shape';
|
|||
import { SoundHandler, Sounds } from '../../sound-handler';
|
||||
import { VibrationHandler } from '../../vibration-handler';
|
||||
import { FeedbackHud } from '../../feedback-hud';
|
||||
import { ScreenShake } from '../../screen-shake';
|
||||
|
||||
const muzzleFlashDecaySeconds = 0.12;
|
||||
const hitFlashDecaySeconds = 0.15;
|
||||
|
||||
// A white-hot pop of light thrown at the spot a character dies, seen by everyone
|
||||
// who can see the body. No radius knob on a CircleLight, so the burst is sold by
|
||||
// a bright (HDR) colour with a fast-decaying intensity envelope.
|
||||
const deathBurstDecaySeconds = 0.42;
|
||||
const deathBurstMaxIntensity = 1.7;
|
||||
const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1);
|
||||
|
||||
const killIcon =
|
||||
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
||||
'<path d="M6.2,2.44L18.1,14.34L20.22,12.22L21.63,13.63L19.16,16.1L22.34,19.28C22.73,19.67 22.73,20.3 22.34,20.69L21.63,21.4C21.24,21.79 20.61,21.79 20.22,21.4L17,18.23L14.56,20.7L13.15,19.29L15.27,17.17L3.37,5.27V2.44H6.2M15.89,10L20.63,5.26V2.44H17.8L13.06,7.18L15.89,10M10.94,15L8.11,12.13L5.9,14.34L3.78,12.22L2.37,13.63L4.84,16.1L1.66,19.28C1.27,19.67 1.27,20.3 1.66,20.69L2.37,21.4C2.76,21.79 3.39,21.79 3.78,21.4L7,18.23L9.42,20.7L10.83,19.29L8.71,17.17L10.94,15Z"/></svg>';
|
||||
|
|
@ -39,6 +47,8 @@ export class CharacterView extends CharacterBase {
|
|||
private muzzleFlash: CircleLight;
|
||||
private muzzleFlashIntensity = 0;
|
||||
private hitFlashIntensity = 0;
|
||||
private deathBurst: CircleLight;
|
||||
private deathBurstIntensity = 0;
|
||||
private strength = settings.playerMaxStrength;
|
||||
private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength);
|
||||
private nameElement: HTMLElement = document.createElement('div');
|
||||
|
|
@ -79,6 +89,7 @@ export class CharacterView extends CharacterBase {
|
|||
settings.paletteDim[settings.colorIndices[team]],
|
||||
0,
|
||||
);
|
||||
this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0);
|
||||
|
||||
this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!);
|
||||
this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!);
|
||||
|
|
@ -166,6 +177,9 @@ export class CharacterView extends CharacterBase {
|
|||
|
||||
if (this.isMainCharacter) {
|
||||
VibrationHandler.vibrate(Math.min(200, damage * 4));
|
||||
// Getting hit jolts the frame too, so taking fire has weight, not just
|
||||
// dealing it.
|
||||
ScreenShake.add(clamp01(0.12 + (0.5 * damage) / settings.playerMaxStrength));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,17 +188,23 @@ export class CharacterView extends CharacterBase {
|
|||
if (this.isMainCharacter) {
|
||||
VibrationHandler.vibrate(150);
|
||||
}
|
||||
// Visible to everyone who can see the body: a white-hot flash plus a
|
||||
// full-body whiteout, so a kill reads as a violent burst rather than the
|
||||
// character quietly blinking out.
|
||||
this.deathBurstIntensity = 1;
|
||||
this.hitFlashIntensity = 1;
|
||||
}
|
||||
|
||||
public onHitConfirmed(charge = 0) {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
// A charged hit lands lower and harder than a panic tap.
|
||||
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge));
|
||||
if (charge >= settings.chargedHitThreshold) {
|
||||
VibrationHandler.vibrate(25);
|
||||
}
|
||||
// Layer a meaty thud under the crisp confirmation tick; a charged hit lands
|
||||
// lower and harder than a panic tap.
|
||||
SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge));
|
||||
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge));
|
||||
ScreenShake.add(mix(0.22, 0.5, charge));
|
||||
VibrationHandler.vibrate(Math.round(mix(12, 35, charge)));
|
||||
FeedbackHud.hitMarker(charge);
|
||||
}
|
||||
|
||||
|
|
@ -192,8 +212,14 @@ export class CharacterView extends CharacterBase {
|
|||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge));
|
||||
VibrationHandler.vibrate(mix(60, 110, charge));
|
||||
// A heavy low thud for the kill with a brighter confirmation over the top,
|
||||
// a hard frame jolt, a zoom-punch toward the action for weight, and a
|
||||
// double-thump rumble.
|
||||
SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge));
|
||||
SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge));
|
||||
ScreenShake.add(mix(0.75, 1, charge));
|
||||
ScreenShake.addPunch(mix(0.7, 1, charge));
|
||||
VibrationHandler.vibrate([45, 35, Math.round(mix(80, 130, charge))]);
|
||||
FeedbackHud.killConfirmed(victimName, streak, charge);
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +256,18 @@ export class CharacterView extends CharacterBase {
|
|||
this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.deathBurstIntensity > 0) {
|
||||
this.deathBurstIntensity = Math.max(
|
||||
0,
|
||||
this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds,
|
||||
);
|
||||
this.deathBurst.center = this.bodyCenter;
|
||||
// Square the envelope so the flash blooms then drops off sharply rather
|
||||
// than fading out in a flat ramp.
|
||||
this.deathBurst.intensity =
|
||||
deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
public onShoot(strength: number) {
|
||||
|
|
@ -273,6 +311,10 @@ export class CharacterView extends CharacterBase {
|
|||
if (this.muzzleFlashIntensity > 0) {
|
||||
renderer.addDrawable(this.muzzleFlash);
|
||||
}
|
||||
|
||||
if (this.deathBurstIntensity > 0) {
|
||||
renderer.addDrawable(this.deathBurst);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateGazeTarget(renderer: Renderer): vec2 {
|
||||
|
|
|
|||
84
frontend/src/scripts/screen-shake.ts
Normal file
84
frontend/src/scripts/screen-shake.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Camera impact effects fed by combat hits, sampled by the camera at render
|
||||
// time. Kept as a process-wide singleton so any view can feed it without
|
||||
// threading a camera reference through the deserialized object graph. Both
|
||||
// effects touch only the *rendered* view (centre offset + view-area zoom), never
|
||||
// the followed position or the simulation clock — so they can't drift the camera
|
||||
// off the player, and they're completely decoupled from prediction/netcode.
|
||||
//
|
||||
// Two effects:
|
||||
// - shake: a trauma model (Eiserloh) — the felt shake is trauma SQUARED so
|
||||
// small taps stay subtle while a kill punches hard; it bleeds off linearly.
|
||||
// - punch: a brief zoom-in on a kill that snaps back fast, for "weight".
|
||||
//
|
||||
// Honours prefers-reduced-motion: callers can fire freely and it simply no-ops
|
||||
// for players who have asked the OS to minimise motion.
|
||||
export abstract class ScreenShake {
|
||||
private static trauma = 0;
|
||||
private static punch = 0;
|
||||
|
||||
// Peak translation, in world units, at full trauma. The visible world is
|
||||
// ~3800 units wide, so this tops out at a few percent of the screen.
|
||||
private static readonly maxTranslation = 150;
|
||||
// Fraction the view zooms in at full punch (smaller view area = zoomed in).
|
||||
private static readonly maxZoom = 0.07;
|
||||
private static readonly traumaDecayPerSecond = 1.7;
|
||||
// Snaps back quickly so the punch reads as a sharp bite, not a slow drift.
|
||||
private static readonly punchDecayPerSecond = 6;
|
||||
|
||||
private static offsetXValue = 0;
|
||||
private static offsetYValue = 0;
|
||||
|
||||
private static get reducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
public static add(trauma: number): void {
|
||||
if (this.reducedMotion) {
|
||||
return;
|
||||
}
|
||||
this.trauma = Math.min(1, this.trauma + trauma);
|
||||
}
|
||||
|
||||
public static addPunch(amount: number): void {
|
||||
if (this.reducedMotion) {
|
||||
return;
|
||||
}
|
||||
this.punch = Math.min(1, this.punch + amount);
|
||||
}
|
||||
|
||||
public static step(deltaTimeInSeconds: number): void {
|
||||
this.trauma = Math.max(
|
||||
0,
|
||||
this.trauma - this.traumaDecayPerSecond * deltaTimeInSeconds,
|
||||
);
|
||||
this.punch = Math.max(0, this.punch - this.punchDecayPerSecond * deltaTimeInSeconds);
|
||||
const shake = this.trauma * this.trauma;
|
||||
this.offsetXValue = this.maxTranslation * shake * (Math.random() * 2 - 1);
|
||||
this.offsetYValue = this.maxTranslation * shake * (Math.random() * 2 - 1);
|
||||
}
|
||||
|
||||
public static get offsetX(): number {
|
||||
return this.offsetXValue;
|
||||
}
|
||||
|
||||
public static get offsetY(): number {
|
||||
return this.offsetYValue;
|
||||
}
|
||||
|
||||
// Multiplier for the view-area size: <1 zooms in. Squared so the punch bites
|
||||
// sharply near its peak rather than ramping linearly.
|
||||
public static get viewScale(): number {
|
||||
return 1 - this.maxZoom * this.punch * this.punch;
|
||||
}
|
||||
|
||||
public static reset(): void {
|
||||
this.trauma = 0;
|
||||
this.punch = 0;
|
||||
this.offsetXValue = 0;
|
||||
this.offsetYValue = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { OptionsHandler } from './options-handler';
|
||||
|
||||
export abstract class VibrationHandler {
|
||||
public static vibrate(time: number): void {
|
||||
// Accepts either a single duration or an on/off pattern (e.g. a double-thump
|
||||
// [40, 30, 90] for a kill).
|
||||
public static vibrate(pattern: number | number[]): void {
|
||||
if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) {
|
||||
navigator.vibrate(time);
|
||||
navigator.vibrate(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue