535 lines
13 KiB
Go
535 lines
13 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// ── Стили и цвета логотипа ───────────────────────────────────────────────────
|
|
|
|
var (
|
|
logoColorGray = lipgloss.Color("#444444")
|
|
logoColorRed = lipgloss.Color("#ff4444")
|
|
logoColorYellow = lipgloss.Color("#ffff44")
|
|
logoColorGreen = lipgloss.Color("#44ff44")
|
|
logoColorWhite = lipgloss.Color("#ffffff")
|
|
)
|
|
|
|
var (
|
|
logoStyleIdle = lipgloss.NewStyle().Foreground(logoColorGray)
|
|
logoStyleDownloading = lipgloss.NewStyle().Foreground(logoColorYellow)
|
|
logoStyleVerifying = lipgloss.NewStyle().Foreground(logoColorYellow)
|
|
logoStyleCompleted = lipgloss.NewStyle().Foreground(logoColorGreen)
|
|
)
|
|
|
|
// ── Состояние узла ───────────────────────────────────────────────────────────
|
|
|
|
type logoNodeState int
|
|
|
|
const (
|
|
logoStateIdle logoNodeState = iota
|
|
logoStateDownloading
|
|
logoStateVerifying
|
|
logoStateCompleted
|
|
logoStateFading
|
|
logoStateOutline
|
|
)
|
|
|
|
type logoNode struct {
|
|
ID int
|
|
X, Y float64
|
|
State logoNodeState
|
|
StateTime time.Time
|
|
TargetDuration time.Duration
|
|
IsOutline bool
|
|
}
|
|
|
|
func NewLogoNode(id int, x, y float64) *logoNode {
|
|
return &logoNode{
|
|
ID: id,
|
|
X: x,
|
|
Y: y,
|
|
State: logoStateIdle,
|
|
StateTime: time.Now(),
|
|
IsOutline: false,
|
|
}
|
|
}
|
|
|
|
func (n *logoNode) setState(state logoNodeState, now time.Time) {
|
|
n.State = state
|
|
n.StateTime = now
|
|
|
|
switch state {
|
|
case logoStateVerifying:
|
|
n.TargetDuration = 3 * time.Second
|
|
case logoStateCompleted:
|
|
n.TargetDuration = 1 * time.Second
|
|
case logoStateFading:
|
|
n.TargetDuration = 1 * time.Second
|
|
default:
|
|
n.TargetDuration = 0
|
|
}
|
|
}
|
|
|
|
func (n *logoNode) Render(now time.Time) (string, lipgloss.Style) {
|
|
char := "•"
|
|
style := logoStyleIdle
|
|
|
|
switch n.State {
|
|
case logoStateIdle:
|
|
style = logoStyleIdle
|
|
case logoStateDownloading:
|
|
style = logoStyleDownloading
|
|
case logoStateVerifying:
|
|
// Плавный переход от желтого (255, 255, 68) к зеленому (68, 255, 68)
|
|
t := float64(now.Sub(n.StateTime)) / float64(n.TargetDuration)
|
|
if t > 1.0 {
|
|
t = 1.0
|
|
}
|
|
r := int(255.0 - (255.0-68.0)*t)
|
|
g := 255
|
|
b := 68
|
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
|
case logoStateCompleted:
|
|
style = logoStyleCompleted
|
|
case logoStateFading:
|
|
t := float64(now.Sub(n.StateTime)) / float64(n.TargetDuration)
|
|
if t > 1.0 {
|
|
t = 1.0
|
|
}
|
|
r := 68
|
|
g := int(255.0 - (255.0-68.0)*t)
|
|
b := 68
|
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
|
case logoStateOutline:
|
|
style = lipgloss.NewStyle().Foreground(logoColorWhite)
|
|
}
|
|
|
|
return char, style
|
|
}
|
|
|
|
// ── Частицы ──────────────────────────────────────────────────────────────────
|
|
|
|
type logoParticleType int
|
|
|
|
const (
|
|
logoTypeDownload logoParticleType = iota
|
|
logoTypeUpload
|
|
)
|
|
|
|
type logoParticle struct {
|
|
Type logoParticleType
|
|
X, Y float64
|
|
StartX float64
|
|
StartY float64
|
|
TargetX float64
|
|
TargetY float64
|
|
Speed float64
|
|
Progress float64 // 0.0 до 1.0
|
|
Angle float64 // Для дрифта
|
|
Active bool
|
|
TargetID int // ID целевого узла (для частиц загрузки)
|
|
}
|
|
|
|
func NewLogoParticle(pType logoParticleType, startX, startY, targetX, targetY float64, targetID int) *logoParticle {
|
|
var speed float64
|
|
if pType == logoTypeDownload {
|
|
speed = 0.5 + rand.Float64()*0.5
|
|
} else {
|
|
speed = 0.25 + rand.Float64()*0.25
|
|
}
|
|
|
|
return &logoParticle{
|
|
Type: pType,
|
|
X: startX,
|
|
Y: startY,
|
|
StartX: startX,
|
|
StartY: startY,
|
|
TargetX: targetX,
|
|
TargetY: targetY,
|
|
Speed: speed,
|
|
Progress: 0.0,
|
|
Angle: rand.Float64() * math.Pi * 2,
|
|
Active: true,
|
|
TargetID: targetID,
|
|
}
|
|
}
|
|
|
|
func (p *logoParticle) Update(dt float64) {
|
|
if !p.Active {
|
|
return
|
|
}
|
|
|
|
p.Progress += p.Speed * dt
|
|
if p.Progress >= 1.0 {
|
|
p.Progress = 1.0
|
|
p.Active = false
|
|
p.X = p.TargetX
|
|
p.Y = p.TargetY
|
|
return
|
|
}
|
|
|
|
var eased float64
|
|
if p.Type == logoTypeDownload {
|
|
t := p.Progress
|
|
eased = 1.0 - math.Pow(1.0-t, 3.0)
|
|
} else {
|
|
t := p.Progress
|
|
eased = math.Pow(t, 2.0)
|
|
}
|
|
|
|
baseX := p.StartX + (p.TargetX-p.StartX)*eased
|
|
baseY := p.StartY + (p.TargetY-p.StartY)*eased
|
|
|
|
p.X = baseX
|
|
p.Y = baseY
|
|
}
|
|
|
|
func (p *logoParticle) Render() (string, lipgloss.Style) {
|
|
char := "·"
|
|
if p.Type == logoTypeUpload {
|
|
r := int(170.0 - (170.0-51.0)*p.Progress)
|
|
g := int(255.0 - (255.0-51.0)*p.Progress)
|
|
b := int(170.0 - (170.0-51.0)*p.Progress)
|
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
return char, lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
|
}
|
|
return char, lipgloss.NewStyle().Foreground(logoColorRed)
|
|
}
|
|
|
|
// ── Движок логотипа ──────────────────────────────────────────────────────────
|
|
|
|
type logoEnginePhase int
|
|
|
|
const (
|
|
logoPhaseAssemble logoEnginePhase = iota
|
|
logoPhaseVerify
|
|
logoPhaseDisperse
|
|
logoPhaseWait
|
|
)
|
|
|
|
type LogoEngine struct {
|
|
Nodes []*logoNode
|
|
Particles []*logoParticle
|
|
Width int
|
|
Height int
|
|
LastUpdate time.Time
|
|
Phase logoEnginePhase
|
|
PhaseTime float64
|
|
TargetNodeQueue []int
|
|
SpawnAccumulator float64
|
|
Grid [][]string
|
|
|
|
// Pre-baked frames
|
|
frames []string
|
|
frameIndex int
|
|
}
|
|
|
|
func (e *LogoEngine) ensureGrid(w, h int) {
|
|
if len(e.Grid) < h {
|
|
newGrid := make([][]string, h)
|
|
copy(newGrid, e.Grid)
|
|
e.Grid = newGrid
|
|
}
|
|
for y := 0; y < h; y++ {
|
|
if len(e.Grid[y]) < w {
|
|
newRow := make([]string, w)
|
|
copy(newRow, e.Grid[y])
|
|
e.Grid[y] = newRow
|
|
}
|
|
}
|
|
}
|
|
|
|
func NewLogoEngine(width, height int, offsetX, offsetY float64) *LogoEngine {
|
|
e := &LogoEngine{
|
|
Nodes: generateZNodes(offsetX, offsetY),
|
|
Particles: make([]*logoParticle, 0),
|
|
Width: width,
|
|
Height: height,
|
|
LastUpdate: time.Now(),
|
|
}
|
|
e.startAssemblePhase(e.LastUpdate)
|
|
e.bakeFrames()
|
|
return e
|
|
}
|
|
|
|
const logoBakeFrames = 180 // ~18 секунд полного цикла при 10 FPS
|
|
|
|
func (e *LogoEngine) bakeFrames() {
|
|
now := time.Unix(0, 0)
|
|
e.frames = make([]string, logoBakeFrames)
|
|
for f := 0; f < logoBakeFrames; f++ {
|
|
e.simulateStep(now, 0.1)
|
|
now = now.Add(100 * time.Millisecond)
|
|
e.frames[f] = e.renderNow(now)
|
|
}
|
|
}
|
|
|
|
func (e *LogoEngine) simulateStep(now time.Time, dt float64) {
|
|
e.PhaseTime += dt
|
|
switch e.Phase {
|
|
case logoPhaseAssemble:
|
|
spawnRate := float64(len(e.Nodes)) / 2.0
|
|
e.SpawnAccumulator += spawnRate * dt
|
|
spawnCount := int(e.SpawnAccumulator)
|
|
e.SpawnAccumulator -= float64(spawnCount)
|
|
for i := 0; i < spawnCount && len(e.TargetNodeQueue) > 0; i++ {
|
|
targetID := e.TargetNodeQueue[0]
|
|
e.TargetNodeQueue = e.TargetNodeQueue[1:]
|
|
e.spawnDownloadParticleTo(e.Nodes[targetID])
|
|
}
|
|
activeDL := 0
|
|
for _, p := range e.Particles {
|
|
if p.Type == logoTypeDownload && p.Active {
|
|
activeDL++
|
|
}
|
|
}
|
|
if len(e.TargetNodeQueue) == 0 && activeDL == 0 {
|
|
e.Phase = logoPhaseVerify
|
|
e.PhaseTime = 0
|
|
for _, n := range e.Nodes {
|
|
if !n.IsOutline {
|
|
n.setState(logoStateVerifying, now)
|
|
}
|
|
}
|
|
}
|
|
case logoPhaseVerify:
|
|
if e.PhaseTime >= 3.0 {
|
|
e.Phase = logoPhaseDisperse
|
|
e.PhaseTime = 0
|
|
for _, n := range e.Nodes {
|
|
if !n.IsOutline {
|
|
n.setState(logoStateCompleted, now)
|
|
e.spawnUploadParticle(n)
|
|
}
|
|
}
|
|
}
|
|
case logoPhaseDisperse:
|
|
if e.PhaseTime >= 1.0 {
|
|
for _, n := range e.Nodes {
|
|
if !n.IsOutline {
|
|
n.setState(logoStateFading, now)
|
|
}
|
|
}
|
|
e.Phase = logoPhaseWait
|
|
e.PhaseTime = 0
|
|
}
|
|
case logoPhaseWait:
|
|
if e.PhaseTime >= 2.0 {
|
|
e.startAssemblePhase(now)
|
|
}
|
|
}
|
|
activeParticles := e.Particles[:0]
|
|
for _, p := range e.Particles {
|
|
p.Update(dt)
|
|
if !p.Active && p.Type == logoTypeDownload {
|
|
if p.TargetID >= 0 && p.TargetID < len(e.Nodes) {
|
|
node := e.Nodes[p.TargetID]
|
|
if node.State == logoStateIdle {
|
|
node.setState(logoStateDownloading, now)
|
|
}
|
|
}
|
|
}
|
|
if p.Active {
|
|
activeParticles = append(activeParticles, p)
|
|
}
|
|
}
|
|
e.Particles = activeParticles
|
|
}
|
|
|
|
func (e *LogoEngine) renderNow(now time.Time) string {
|
|
e.ensureGrid(e.Width, e.Height)
|
|
for y := 0; y < e.Height; y++ {
|
|
for x := 0; x < e.Width; x++ {
|
|
e.Grid[y][x] = " "
|
|
}
|
|
}
|
|
for _, n := range e.Nodes {
|
|
if !n.IsOutline {
|
|
x, y := int(n.X+0.5), int(n.Y+0.5)
|
|
if x >= 0 && x < e.Width && y >= 0 && y < e.Height {
|
|
char, style := n.Render(now)
|
|
e.Grid[y][x] = style.Render(char)
|
|
}
|
|
}
|
|
}
|
|
for _, p := range e.Particles {
|
|
x, y := int(p.X+0.5), int(p.Y+0.5)
|
|
if x >= 0 && x < e.Width && y >= 0 && y < e.Height {
|
|
char, style := p.Render()
|
|
e.Grid[y][x] = style.Render(char)
|
|
}
|
|
}
|
|
for _, n := range e.Nodes {
|
|
if n.IsOutline {
|
|
x, y := int(n.X+0.5), int(n.Y+0.5)
|
|
if x >= 0 && x < e.Width && y >= 0 && y < e.Height {
|
|
char, style := n.Render(now)
|
|
e.Grid[y][x] = style.Render(char)
|
|
}
|
|
}
|
|
}
|
|
var sb strings.Builder
|
|
sb.Grow(e.Height * e.Width * 5)
|
|
for y := 0; y < e.Height; y++ {
|
|
sb.WriteString(strings.Join(e.Grid[y][:e.Width], ""))
|
|
if y < e.Height-1 {
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
|
|
func (e *LogoEngine) startAssemblePhase(now time.Time) {
|
|
e.Phase = logoPhaseAssemble
|
|
e.PhaseTime = 0
|
|
e.SpawnAccumulator = 0
|
|
e.TargetNodeQueue = nil
|
|
|
|
for _, n := range e.Nodes {
|
|
if !n.IsOutline {
|
|
n.setState(logoStateIdle, now)
|
|
e.TargetNodeQueue = append(e.TargetNodeQueue, n.ID)
|
|
}
|
|
}
|
|
|
|
rand.Shuffle(len(e.TargetNodeQueue), func(i, j int) {
|
|
e.TargetNodeQueue[i], e.TargetNodeQueue[j] = e.TargetNodeQueue[j], e.TargetNodeQueue[i]
|
|
})
|
|
}
|
|
|
|
// Update переключает кадр — нулевой CPU.
|
|
func (e *LogoEngine) Update(now time.Time) {
|
|
if len(e.frames) > 0 {
|
|
e.frameIndex = (e.frameIndex + 1) % len(e.frames)
|
|
}
|
|
}
|
|
|
|
func (e *LogoEngine) spawnDownloadParticleTo(target *logoNode) {
|
|
cx := float64(e.Width) / 2.0
|
|
cy := float64(e.Height) / 2.0
|
|
radius := 18.0 + rand.Float64()*5.0
|
|
angle := rand.Float64() * math.Pi * 2
|
|
|
|
startX := cx + math.Cos(angle)*radius
|
|
startY := cy + math.Sin(angle)*radius
|
|
|
|
e.Particles = append(e.Particles, NewLogoParticle(logoTypeDownload, startX, startY, target.X, target.Y, target.ID))
|
|
}
|
|
|
|
func (e *LogoEngine) spawnUploadParticle(n *logoNode) {
|
|
var targetX, targetY float64
|
|
angle := rand.Float64() * math.Pi * 2
|
|
dist := 8.0 + rand.Float64()*4.0
|
|
targetX = n.X + math.Cos(angle)*dist
|
|
targetY = n.Y + math.Sin(angle)*dist
|
|
|
|
e.Particles = append(e.Particles, NewLogoParticle(logoTypeUpload, n.X, n.Y, targetX, targetY, -1))
|
|
}
|
|
|
|
// Render возвращает pre-baked кадр — нулевой CPU.
|
|
func (e *LogoEngine) Render(now time.Time) string {
|
|
if len(e.frames) == 0 {
|
|
return ""
|
|
}
|
|
return e.frames[e.frameIndex]
|
|
}
|
|
|
|
// generateZNodes генерирует координаты для Z-образного логотипа.
|
|
func generateZNodes(offsetX, offsetY float64) []*logoNode {
|
|
var nodes []*logoNode
|
|
id := 0
|
|
|
|
startX, endX := 0.0, 20.0
|
|
startY, endY := 0.0, 10.0
|
|
|
|
addNode := func(x, y float64) {
|
|
nodes = append(nodes, NewLogoNode(id, x+offsetX, y+offsetY))
|
|
id++
|
|
}
|
|
|
|
thicknessY := 2.0
|
|
thicknessX := 3.0
|
|
|
|
// Верхняя полоса
|
|
for y := startY; y <= startY+thicknessY; y += 1.0 {
|
|
for x := startX; x <= endX; x += 0.7 {
|
|
addNode(x, y)
|
|
}
|
|
}
|
|
|
|
// Диагональ
|
|
steps := 50
|
|
for i := 0; i <= steps; i++ {
|
|
progress := float64(i) / float64(steps)
|
|
baseX := endX - progress*(endX-startX)
|
|
baseY := startY + progress*(endY-startY)
|
|
|
|
for ox := -thicknessX; ox <= thicknessX; ox += 0.7 {
|
|
addNode(baseX+ox, baseY)
|
|
}
|
|
}
|
|
|
|
// Нижняя полоса
|
|
for y := endY - thicknessY; y <= endY; y += 1.0 {
|
|
for x := startX; x <= endX; x += 0.7 {
|
|
addNode(x, y)
|
|
}
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
var uniqueNodes []*logoNode
|
|
for _, n := range nodes {
|
|
cx, cy := int(n.X+0.5), int(n.Y+0.5)
|
|
key := fmt.Sprintf("%d,%d", cx, cy)
|
|
if !seen[key] {
|
|
seen[key] = true
|
|
uniqueNodes = append(uniqueNodes, n)
|
|
}
|
|
}
|
|
nodes = uniqueNodes
|
|
|
|
for idx, n := range nodes {
|
|
n.ID = idx
|
|
}
|
|
|
|
grid := make(map[string]bool)
|
|
for _, n := range nodes {
|
|
cx, cy := int(n.X+0.5), int(n.Y+0.5)
|
|
grid[fmt.Sprintf("%d,%d", cx, cy)] = true
|
|
}
|
|
|
|
for _, n := range nodes {
|
|
cx, cy := int(n.X+0.5), int(n.Y+0.5)
|
|
isEdge := false
|
|
for dy := -1; dy <= 1; dy++ {
|
|
for dx := -1; dx <= 1; dx++ {
|
|
if dx == 0 && dy == 0 {
|
|
continue
|
|
}
|
|
neighborKey := fmt.Sprintf("%d,%d", cx+dx, cy+dy)
|
|
if !grid[neighborKey] {
|
|
isEdge = true
|
|
break
|
|
}
|
|
}
|
|
if isEdge {
|
|
break
|
|
}
|
|
}
|
|
if isEdge {
|
|
n.IsOutline = true
|
|
n.State = logoStateOutline
|
|
}
|
|
}
|
|
|
|
return nodes
|
|
}
|