import { Container, Geometry, Mesh, MeshMaterial, MIPMAP_MODES, Point, Program, Texture, WRAP_MODES, } from 'pixi.js'; class ScrollingMeshMaterial extends MeshMaterial { constructor(texture: Texture) { const vertexSrc = ` precision mediump float; attribute vec2 aVertexPosition; attribute vec2 aTextureCoord; uniform mat3 translationMatrix; uniform mat3 projectionMatrix; varying vec2 vTextureCoord; void main(void) { gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); vTextureCoord = aTextureCoord; } `; const fragmentSrc = ` precision mediump float; varying vec2 vTextureCoord; uniform sampler2D uSampler; uniform float uTextureOffset; void main(void) { vec2 uv = vec2(vTextureCoord.x + uTextureOffset, vTextureCoord.y); gl_FragColor = texture2D(uSampler, uv); } `; const program = Program.from(vertexSrc, fragmentSrc); super(texture, { program, uniforms: { uTextureOffset: 0 }, }); // Важно: разрешить повтор, иначе при смещении по U появятся швы if (texture?.baseTexture) { texture.baseTexture.wrapMode = WRAP_MODES.REPEAT; texture.baseTexture.mipmap = MIPMAP_MODES.ON; } } } /** Extend this class and override getters: texture, filletRadius, lineThickness, duration. */ export default class WebGLWinLine extends Container { private readonly mesh: Mesh; private time: number = 0; public state: 'stopped' | 'animating' = 'stopped'; get texture(): Texture { return Texture.from('textures/win_line.png'); } get filletRadius() { return 30; } get lineThickness() { return 30; } /** duration in seconds */ get duration() { return 1; } constructor(points: number[][], slotWidth: number = 100, slotHeight: number = 100) { super(); const centers = points.map(([reel, row]) => new Point((reel * slotWidth) + (slotWidth / 2), (row * slotHeight) + (slotHeight / 2)) ); const centerCurveRaw = this.computeFilletCurve(centers, this.filletRadius); const centerCurve = dedupeAndColinearCull(centerCurveRaw); this.mesh = this.createThickLineMesh(centerCurve, this.lineThickness, this.texture); this.addChild(this.mesh); } private computeFilletCurve(points: Point[], radius: number): Point[] { // Без филета/на острых углах важно не плодить нулевые сегменты const result: Point[] = []; if (points.length < 2) return [...points]; if (points.length === 2) return [points[0], points[1]]; const sampleArc = (center: Point, startAngle: number, endAngle: number, segments: number): Point[] => { const arcPoints: Point[] = []; segments = Math.max(2, segments); const delta = (endAngle - startAngle) / (segments - 1); for (let i = 0; i < segments; i++) { const a = startAngle + (delta * i); arcPoints.push(new Point(center.x + (radius * Math.cos(a)), center.y + (radius * Math.sin(a)))); } return arcPoints; }; let prevT2: Point | null = null; // --- первый узел --- { const [p0, p1, p2] = [points[0], points[1], points[2]]; const d1 = normalizeVector(p0.x - p1.x, p0.y - p1.y); const d2 = normalizeVector(p2.x - p1.x, p2.y - p1.y); const dot = clamp((d1[0] * d2[0]) + (d1[1] * d2[1]), -1, 1); const theta = Math.acos(dot); // если угол ≈ 0 (прямая), не создаём псевдо-дугу if (theta < 1e-4) { result.push(p0, p1); prevT2 = p1; } else { const offset = radius / Math.tan(theta / 2); const T1 = new Point(p1.x + (d1[0] * offset), p1.y + (d1[1] * offset)); const T2 = new Point(p1.x + (d2[0] * offset), p1.y + (d2[1] * offset)); result.push(p0, T1); const bisector = new Point(d1[0] + d2[0], d1[1] + d2[1]); const bisectorLen = Math.hypot(bisector.x, bisector.y); if (bisectorLen > 0) { const bisectorNorm = new Point(bisector.x / bisectorLen, bisector.y / bisectorLen); const centerDistance = radius / Math.sin(theta / 2); const C = new Point(p1.x + (bisectorNorm.x * centerDistance), p1.y + (bisectorNorm.y * centerDistance)); const startAngle = Math.atan2(T1.y - C.y, T1.x - C.x); const arcSweep = Math.PI - theta; const endAngle = startAngle + (cross2(T1, T2, C) < 0 ? -arcSweep : arcSweep); const arcPoints = sampleArc(C, startAngle, endAngle, 10); result.push(...arcPoints.slice(1)); prevT2 = T2; } else { // почти 180°: просто соединяем через вершину result.push(p1); prevT2 = p1; } } } // --- внутренние узлы --- for (let i = 2; i < points.length - 1; i++) { const pPrev = points[i - 1]; const pCurr = points[i]; const pNext = points[i + 1]; const v1 = normalizeVector(pPrev.x - pCurr.x, pPrev.y - pCurr.y); const v2 = normalizeVector(pNext.x - pCurr.x, pNext.y - pCurr.y); const dot = clamp((v1[0] * v2[0]) + (v1[1] * v2[1]), -1, 1); const theta = Math.acos(dot); if (theta < 1e-4) { // прямая — не вставляем T1/T2 if (prevT2) result.push(pCurr); prevT2 = pCurr; continue; } const offset = radius / Math.tan(theta / 2); const T1 = new Point(pCurr.x + (v1[0] * offset), pCurr.y + (v1[1] * offset)); const T2 = new Point(pCurr.x + (v2[0] * offset), pCurr.y + (v2[1] * offset)); if (prevT2) result.push(T1); const bisector = new Point(v1[0] + v2[0], v1[1] + v2[1]); const bisectorLen = Math.hypot(bisector.x, bisector.y); if (bisectorLen > 0) { const bisectorNorm = new Point(bisector.x / bisectorLen, bisector.y / bisectorLen); const centerDistance = radius / Math.sin(theta / 2); const C = new Point(pCurr.x + (bisectorNorm.x * centerDistance), pCurr.y + (bisectorNorm.y * centerDistance)); const startAngle = Math.atan2(T1.y - C.y, T1.x - C.x); const arcSweep = Math.PI - theta; const endAngle = startAngle + (cross2(T1, T2, C) < 0 ? -arcSweep : arcSweep); const arcPoints = sampleArc(C, startAngle, endAngle, 10); result.push(...arcPoints.slice(1)); } else { result.push(pCurr); } prevT2 = T2; } // --- хвост --- const lastPoint = points[points.length - 1]; if (prevT2) { result.push(prevT2, lastPoint); } else { result.push(lastPoint); } return result; } /** * Создает Mesh c толщиной по центральной кривой. * Митер-нормали с ограничением устраняют «ломы» на острых углах без обязательного скругления. * @param curvePoints * @param thickness * @param texture */ private createThickLineMesh(curvePoints: Point[], thickness: number, texture: Texture): Mesh { const vertices: number[] = []; const uvs: number[] = []; const indices: number[] = []; const n = curvePoints.length; const halfThickness = thickness / 2; // накопленные длины для UV let totalLength = 0; const lengths: number[] = [0]; for (let i = 1; i < n; i++) { const dx = curvePoints[i].x - curvePoints[i - 1].x; const dy = curvePoints[i].y - curvePoints[i - 1].y; const seg = Math.hypot(dx, dy); totalLength += seg; lengths.push(totalLength); } // —— митер-нормали —— const MITER_LIMIT = 4; function unit(x: number, y: number) { const l = Math.hypot(x, y); return l > 0 ? { x: x / l, y: y / l } : { x: 0, y: 0 }; } function perp(v: {x: number;y: number}) { return { x: -v.y, y: v.x }; } function computeNormal(i: number): { x: number; y: number } { if (n === 1) return { x: 0, y: 0 }; if (i === 0) { const d = unit(curvePoints[1].x - curvePoints[0].x, curvePoints[1].y - curvePoints[0].y); return perp(d); } if (i === n - 1) { const d = unit(curvePoints[n - 1].x - curvePoints[n - 2].x, curvePoints[n - 1].y - curvePoints[n - 2].y); return perp(d); } const dPrev = unit(curvePoints[i].x - curvePoints[i - 1].x, curvePoints[i].y - curvePoints[i - 1].y); const dNext = unit(curvePoints[i + 1].x - curvePoints[i].x, curvePoints[i + 1].y - curvePoints[i].y); const sum = { x: dPrev.x + dNext.x, y: dPrev.y + dNext.y }; const sumLen = Math.hypot(sum.x, sum.y); if (sumLen < 1e-6) { // угол ~180° return perp(dNext); } const t = { x: sum.x / sumLen, y: sum.y / sumLen }; // усреднённая касательная const nAvg = perp(t); // miter scale = 1 / dot(nAvg, nNext) const nNext = perp(dNext); const denom = (nAvg.x * nNext.x) + (nAvg.y * nNext.y); let miterScale = 1 / Math.max(denom, 1e-4); miterScale = Math.min(miterScale, MITER_LIMIT); return { x: nAvg.x * miterScale, y: nAvg.y * miterScale }; } // вершины + UV for (let i = 0; i < n; i++) { const p = curvePoints[i]; const normal = computeNormal(i); vertices.push(p.x - (normal.x * halfThickness), p.y - (normal.y * halfThickness)); vertices.push(p.x + (normal.x * halfThickness), p.y + (normal.y * halfThickness)); const u = totalLength > 0 ? lengths[i] / totalLength : 0; uvs.push(u, 0); uvs.push(u, 1); } // индексы (два треугольника на сегмент) for (let i = 0; i < n - 1; i++) { const idx = i * 2; indices.push(idx, idx + 1, idx + 2); indices.push(idx + 1, idx + 3, idx + 2); } const geometry = new Geometry() .addAttribute('aVertexPosition', vertices, 2) .addAttribute('aTextureCoord', uvs, 2) .addIndex(indices); return new Mesh(geometry, new ScrollingMeshMaterial(texture)); } public update(dt: number) { this.time += dt * 0.001; if (this.state === 'stopped') { this.visible = false; return; } if (this.state === 'animating') { this.visible = true; const material = this.mesh.material as ScrollingMeshMaterial; material.uniforms.uTextureOffset = 1 - (2 * this.time / this.duration); if (material.uniforms.uTextureOffset <= -1) { material.uniforms.uTextureOffset = 1; this.state = 'stopped'; } } } public play() { this.state = 'animating'; this.time = 0; const material = this.mesh.material as ScrollingMeshMaterial; material.uniforms.uTextureOffset = 1; } } /* ---------- utils ---------- */ function normalizeVector(x: number, y: number): [number, number] { const length = Math.hypot(x, y); return length === 0 ? [0, 0] : [x / length, y / length]; } function clamp(v: number, a: number, b: number) { return Math.max(a, Math.min(b, v)); } function cross2(a: Point, b: Point, c: Point) { // cross( (A-C), (B-C) ) return ((a.x - c.x) * (b.y - c.y)) - ((a.y - c.y) * (b.x - c.x)); } /** * Удаляет дубликаты / почти-нулевые сегменты и промежуточные строго коллинеарные точки * @param points * @param eps */ function dedupeAndColinearCull(points: Point[], eps = 1e-4): Point[] { if (points.length <= 1) return [...points]; const out: Point[] = []; for (const p of points) { const last = out[out.length - 1]; if (!last || Math.hypot(p.x - last.x, p.y - last.y) > eps) out.push(p); } if (out.length < 3) return out; const pruned: Point[] = [out[0]]; for (let i = 1; i < out.length - 1; i++) { const a = out[i - 1]; const b = out[i]; const c = out[i + 1]; const abx = b.x - a.x; const aby = b.y - a.y; const bcx = c.x - b.x; const bcy = c.y - b.y; const cross = (abx * bcy) - (aby * bcx); if (Math.abs(cross) > eps) pruned.push(b); } pruned.push(out[out.length - 1]); return pruned; }