initial
This commit is contained in:
439
src/entities/WebGLWinLine.ts
Normal file
439
src/entities/WebGLWinLine.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user