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[] = []; // 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; // (1−t) 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); } }