Files
slot-machines/src/entities/WebGLWinLine.ts
Andrey Sharshov dfa72178d5 initial
2025-11-16 18:45:28 +01:00

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