Files
js-dev-onboarding-24-play/src/controllers/LootC.ts
T
24Play-Mykyta-Slobodianiuk 0399551038 feat: update npmrc
2026-06-09 16:59:49 +03:00

321 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Sprite, SpriteMaterial, Texture, TextureLoader, SRGBColorSpace, Vector3 } from "three";
import * as TWEEN from "@tweenjs/tween.js";
import { CameraC_internal } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { TestSceneC } from "./TestSceneC"; // for groundY (ground level)
import { GameEvents } from "../core/GameEvents";
import { GameLoop } from "../core/GameLoop";
import { images } from "../resources/resources";
import { worldToScreen } from "../utils/screen";
// Tunables — tweak here
const PIECES_MIN = 3; // min pieces per drop
const PIECES_MAX = 6; // max pieces per drop
const SCATTER_MIN = 0.6; // near landing radius
const SCATTER_MAX = 1.4; // far landing radius (wider scatter)
const ANGLE_JITTER = 0.3; // fraction of the sector used for jitter (smaller = more even, fewer overlaps)
const PIECE_SIZE = 0.6; // size of the falling loot (bigger than UI → shrinks in flight)
const ARC_HEIGHT = 1.2; // height of the first (main) flight — the tallest hop
const FLIGHT_MS = 520; // duration of the first flight
const BOUNCES = 2; // how many bounces after landing
const BOUNCE_HEIGHT = 0.4; // each bounce = this fraction of the previous height
const BOUNCE_TIME = 0.6; // each bounce is shorter in time
const BOUNCE_FORWARD = 0.5; // each bounce covers this fraction of the previous horizontal step
const FLIGHT_STRETCH = 0.22; // vertical stretch in flight (softer = smoother, less rubbery)
const LAND_SQUASH = 0.72; // squash on the final landing (gentler)
const LAND_POP_MS = 180; // duration of the final "pop"
// Collect (#8): when/where the wood flies to the UI.
const COLLECT_LEAD_MS = 170; // start the collect this long BEFORE the bounces finish,
// so the shrink+flight flow out of the last bounce
const COLLECT_STAGGER_MS = 70; // extra per-piece delay so they stream in, not all at once
const UI_ICON_SIZE = 28; // wood arrival size (px) — about the wood plank on the panel
const UI_WOOD_X_FRAC = 0.82; // where the wood art sits across the panel (right-side plank)
const FLY_MS = 950; // flight duration — slower, calmer travel to the UI (size tracks it)
const FLY_ARC_PX = 90; // how high the flight bows upward (curved path, not a straight line)
const BLINK_MS = 120; // ramp-up of the white glint
const _spriteTop = new Vector3(); // scratch: a sprite's top point, for measuring its screen size
export class LootC {
static pieces: Sprite[] = [];
static balance = 0; // collected wood the player can spend
private static texture: Texture | null = null;
private static uiIcon: HTMLImageElement | null = null;
private static countEl: HTMLElement | null = null; // the "×N" label next to the icon
// Our tween group, pumped each frame (tween.js v25 needs an explicit group).
private static tweens = new TWEEN.Group();
static init() {
this.texture = new TextureLoader().load(images.woodIconUrl);
this.texture.colorSpace = SRGBColorSpace; // correct color
// The wood icon/count live in the HUD (built by HudC). We just reference
// them: read the icon's screen position as the fly target, write the count.
this.uiIcon = document.getElementById("wood-ui") as HTMLImageElement | null;
this.countEl = document.getElementById("wood-count");
this.renderCount();
// Listen for loot spawns (from crate breaks or state changes)
GameEvents.onLootSpawn.addDelegate(({ position, count }) => this.spawn(position, count));
// ⚠️ Key: pump our group every frame, otherwise the tweens don't advance.
GameLoop.register(() => this.tweens.update());
}
/** Current spendable wood. */
static getBalance(): number {
return this.balance;
}
/** Spend up to `amount` wood; returns how much was actually taken (clamped to balance). */
static spend(amount: number): number {
const taken = Math.min(amount, this.balance);
this.balance -= taken;
this.renderCount();
if (taken > 0) {
GameEvents.onWoodSpent.Invoke({ amount: taken, balance: this.balance });
}
return taken;
}
/** Screen-pixel center of the UI wood icon (used as a fly source/target). */
static uiIconScreenCenter(): { x: number; y: number } {
return this.uiIconCenter();
}
private static renderCount() {
if (this.countEl) this.countEl.textContent = `${this.balance}`;
}
/** Spawn loot at a point. If count is omitted → random PIECES_MIN..PIECES_MAX. */
static spawn(origin: Vector3, count?: number) {
const pieceCount = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1)));
const sectorAngle = (Math.PI * 2) / pieceCount; // each piece gets its own slice of the circle
for (let i = 0; i < pieceCount; i++) {
const piece = this.createPiece();
piece.position.copy(origin);
// Even sector + a little jitter → pieces spread out and don't clump.
const angle = i * sectorAngle + (Math.random() - 0.5) * sectorAngle * ANGLE_JITTER;
const radius = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN);
const landing = new Vector3(
origin.x + Math.cos(angle) * radius,
TestSceneC.groundY + PIECE_SIZE / 2, // sprite center above ground → its bottom touches the ground
origin.z + Math.sin(angle) * radius,
);
this.animatePiece(piece, origin.clone(), landing, i);
this.pieces.push(piece);
}
}
/** Flat piece: a Sprite (billboard — always faces the camera) with the wood texture. */
private static createPiece(): Sprite {
const material = new SpriteMaterial({ map: this.texture, transparent: true });
const piece = new Sprite(material);
piece.scale.set(PIECE_SIZE, PIECE_SIZE, 1);
ThreeC.addToScene(piece);
return piece;
}
/** A piece flies in an arc, bounces a couple times, then pops on landing. */
private static animatePiece(piece: Sprite, from: Vector3, to: Vector3, index = 0) {
const restY = to.y; // sprite center at rest
// One hop: a parabolic arc from (fromX,fromZ) to (toX,toZ), stretched while
// moving fast (the "squash & stretch" that sells the speed).
const hop = (fromX: number, fromZ: number, toX: number, toZ: number, peakHeight: number, durationMs: number) =>
new TWEEN.Tween({ t: 0 }, this.tweens)
.to({ t: 1 }, durationMs)
.easing(TWEEN.Easing.Linear.None)
.onUpdate(({ t }) => {
piece.position.x = fromX + (toX - fromX) * t;
piece.position.z = fromZ + (toZ - fromZ) * t;
piece.position.y = restY + peakHeight * 4 * t * (1 - t); // parabolic arc
// |1-2t|: fast on the way up/down → stretch; at the peak → normal.
// Scale the stretch by hop height (small bounces stretch less).
const stretch = 1 + FLIGHT_STRETCH * (peakHeight / ARC_HEIGHT) * Math.abs(1 - 2 * t);
piece.scale.set(PIECE_SIZE / stretch, PIECE_SIZE * stretch, 1);
});
// Horizontal throw direction (target = the final resting spot).
const deltaX = to.x - from.x, deltaZ = to.z - from.z;
const totalDist = Math.hypot(deltaX, deltaZ) || 1e-4;
const dirX = deltaX / totalDist, dirZ = deltaZ / totalDist;
// Share the horizontal distance across the flight + bounces (so the piece
// also moves forward on each bounce, not just up). Each hop covers
// BOUNCE_FORWARD× the previous one, so the steps form a geometric series
// whose sum we divide the total distance by to get the first step.
const hopCount = BOUNCES + 1;
const forwardSum = (1 - Math.pow(BOUNCE_FORWARD, hopCount)) / (1 - BOUNCE_FORWARD);
let stepDist = totalDist / forwardSum;
let curX = from.x, curZ = from.z;
let peakHeight = ARC_HEIGHT, durationMs = FLIGHT_MS;
let firstHop: TWEEN.Tween<{ t: number }> | null = null;
let prevHop: TWEEN.Tween<{ t: number }> | null = null;
const allTweens: TWEEN.Tween<any>[] = []; // every hop/pop tween (so we can stop them early)
let bouncesMs = 0; // total duration of all the hop arcs
for (let i = 0; i < hopCount; i++) {
const nextX = curX + dirX * stepDist, nextZ = curZ + dirZ * stepDist;
const hopTween = hop(curX, curZ, nextX, nextZ, peakHeight, durationMs);
allTweens.push(hopTween);
if (!firstHop) firstHop = hopTween; else prevHop!.chain(hopTween);
prevHop = hopTween;
bouncesMs += durationMs;
curX = nextX; curZ = nextZ;
stepDist *= BOUNCE_FORWARD; peakHeight *= BOUNCE_HEIGHT; durationMs *= BOUNCE_TIME;
}
// Landing pop: squash on the ground, then spring back. Only seen if the piece
// somehow isn't collected first (the collect normally lifts off before this).
const groundY = restY - PIECE_SIZE / 2;
const pop = new TWEEN.Tween({ t: 0 }, this.tweens)
.to({ t: 1 }, LAND_POP_MS)
.easing(TWEEN.Easing.Back.Out)
.onUpdate(({ t }) => {
const squash = LAND_SQUASH + (1 - LAND_SQUASH) * t; // 0.72 → 1 (with a slight overshoot)
piece.scale.set(PIECE_SIZE / squash, PIECE_SIZE * squash, 1);
piece.position.y = groundY + (PIECE_SIZE * squash) / 2; // bottom stays on the ground
});
allTweens.push(pop);
prevHop!.chain(pop);
firstHop!.start();
// Lift off into the UI a bit BEFORE the bounces finish, so the shrink + flight
// flow straight out of the last bounce (no "settle, pause, then fly"). Stop the
// remaining bounce/pop on this piece and hand straight over to the collect.
const liftOffDelayMs = Math.max(FLIGHT_MS * 0.7, bouncesMs - COLLECT_LEAD_MS) + index * COLLECT_STAGGER_MS;
setTimeout(() => {
allTweens.forEach(tween => tween.stop());
this.collect(piece);
}, liftOffDelayMs);
}
/**
* Collect: swap the 3D sprite for an HTML image at the same screen spot, then
* shrink it and fly it into the top-right UI counter.
*/
private static collect(piece: Sprite) {
if (!this.pieces.includes(piece)) return; // already collected/removed
const cam = CameraC_internal.camera;
const canvas = document.querySelector("canvas");
if (!cam || !canvas) return;
const rect = canvas.getBoundingClientRect();
// Sprite center + size in screen pixels.
const center = worldToScreen(piece.position, cam, rect);
_spriteTop.copy(piece.position); _spriteTop.y += piece.scale.y / 2;
const sizePx = Math.max(8, Math.abs(center.y - worldToScreen(_spriteTop, cam, rect).y) * 2);
// Drop the 3D sprite; the HTML image takes over from the same spot.
this.remove(piece);
const flier = document.createElement("img");
flier.src = images.woodIconUrl;
flier.style.cssText =
`position:fixed; left:0; top:0; width:${sizePx}px; height:${sizePx}px;` +
// above #hud (z-index 9999) so the wood clearly flies on top of, and into, the icon
`z-index:10000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top,width,height;`;
document.body.appendChild(flier);
// White "glint" copy that rides on top of the flier and fades out as it moves.
const flash = document.createElement("img");
flash.src = images.woodIconUrl;
flash.style.cssText = flier.style.cssText;
flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette
flash.style.zIndex = "10001";
document.body.appendChild(flash);
const target = this.uiIconCenter();
// Curved flight path (quadratic Bézier): start → a lifted control point → UI.
// The upward bow makes the wood swoop in an arc instead of a flat diagonal,
// which reads much smoother.
const startX = center.x, startY = center.y;
const endX = target.x, endY = target.y;
const ctrlX = (startX + endX) / 2;
const ctrlY = Math.min(startY, endY) - FLY_ARC_PX;
// Shared animation state: progress along the path (0→1), current size, and
// the white glint's opacity. All three tweens below mutate this one object.
const anim = { progress: 0, size: sizePx, glow: 0 };
const place = (el: HTMLElement) => {
const inv = 1 - anim.progress; // (1t) term of the Bézier
const x = inv * inv * startX + 2 * inv * anim.progress * ctrlX + anim.progress * anim.progress * endX;
const y = inv * inv * startY + 2 * inv * anim.progress * ctrlY + anim.progress * anim.progress * endY;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.width = `${anim.size}px`;
el.style.height = `${anim.size}px`;
};
const apply = () => { place(flier); place(flash); flash.style.opacity = `${anim.glow}`; };
apply();
// Flight along the curve — owns the cleanup. Ease in AND out so it starts and
// arrives gently.
const fly = new TWEEN.Tween(anim, this.tweens)
.to({ progress: 1 }, FLY_MS)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(apply)
.onComplete(() => {
flier.remove();
flash.remove();
this.balance++;
this.renderCount();
this.pulseUiIcon();
GameEvents.onWoodCollected.Invoke({ amount: 1, balance: this.balance });
});
// Shrink — SAME duration as the flight, so the two start AND finish together
// (no "shrink first"); Sinusoidal makes the size change extra smooth.
const shrink = new TWEEN.Tween(anim, this.tweens)
.to({ size: UI_ICON_SIZE }, FLY_MS)
.easing(TWEEN.Easing.Sinusoidal.InOut)
.onUpdate(apply);
// Blink — a quick glint that overlaps the start of the motion.
const flashIn = new TWEEN.Tween(anim, this.tweens)
.to({ glow: 1 }, BLINK_MS)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(apply);
const flashOut = new TWEEN.Tween(anim, this.tweens)
.to({ glow: 0 }, BLINK_MS * 1.6)
.easing(TWEEN.Easing.Quadratic.In)
.onUpdate(apply);
flashIn.chain(flashOut);
// Kick them off together → one blended, fluid collect.
fly.start();
shrink.start();
flashIn.start();
}
private static uiIconCenter() {
const r = this.uiIcon?.getBoundingClientRect();
// Aim at the wood plank on the right of the panel, not the panel's center,
// so the loot lands on the actual wood art.
return r ? { x: r.left + r.width * UI_WOOD_X_FRAC, y: r.top + r.height / 2 } : { x: 0, y: 0 };
}
/** A small "pulse" of the UI icon when a piece arrives. */
private static pulseUiIcon() {
const el = this.uiIcon;
if (!el) return;
el.style.transform = "scale(1.25)";
setTimeout(() => { if (this.uiIcon) this.uiIcon.style.transform = "scale(1)"; }, 120);
}
/** Remove a piece from the scene and free its material (the texture is shared,
* so it's loaded once in init() and never disposed per-piece). */
static remove(piece: Sprite) {
ThreeC.removeFromScene(piece);
(piece.material as SpriteMaterial).dispose();
const i = this.pieces.indexOf(piece);
if (i >= 0) this.pieces.splice(i, 1);
}
}