This commit is contained in:
Andrey Sharshov
2025-11-16 18:45:28 +01:00
commit dfa72178d5
187 changed files with 39934 additions and 0 deletions

View 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;
}