440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
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;
|
||
}
|