flow-animation.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. /**
  2. * wuxing-sandbox-helpers/flow-animation.js
  3. * 五行能量流动粒子动画
  4. *
  5. * 相生循环:沿外圆路径,能量依次转化(土→金→水→木→火)
  6. * 身(土)→智(金)→富(水)→行(木)→心(火)→身(土)
  7. * 路径:outer[i] → outer[(i+1)%5] (弧线)
  8. * 颜色随路径从源维度过渡到目标维度
  9. *
  10. * 相克循环:沿五角星边路径,能量抑制
  11. * 身(土)→富(水), 富(水)→心(火), 心(火)→智(金), 智(金)→行(木), 行(木)→身(土)
  12. * 路径:outer[i] → inner[(i+1)%5] → outer[(i+2)%5] (星边直线)
  13. * 粒子保持源维度颜色
  14. */
  15. var WUXING_COLORS = [
  16. { r: 141, g: 110, b: 99 }, // 0 土·身 (body)
  17. { r: 255, g: 107, b: 53 }, // 1 火·心 (mind)
  18. { r: 255, g: 215, b: 0 }, // 2 金·智 (wisdom)
  19. { r: 76, g: 175, b: 80 }, // 3 木·行 (action)
  20. { r: 66, g: 165, b: 245 } // 4 水·富 (wealth)
  21. ]
  22. var DIM_KEYS = ['body', 'mind', 'wisdom', 'action', 'wealth']
  23. /**
  24. * 维度代码 → 颜色
  25. */
  26. function colorForDimCode(code) {
  27. var idx = DIM_KEYS.indexOf(code)
  28. if (idx < 0) return { r: 200, g: 200, b: 200 }
  29. return WUXING_COLORS[idx]
  30. }
  31. /**
  32. * 计算两点间的弧线路径(圆心(cx,cy),顺时针方向)
  33. * @param {{x:number,y:number}} src
  34. * @param {{x:number,y:number}} dst
  35. * @param {number} cx
  36. * @param {number} cy
  37. * @param {number} numSegments - 分段数
  38. * @returns {Array<{x:number,y:number}>}
  39. */
  40. function arcWaypoints(src, dst, cx, cy, numSegments) {
  41. numSegments = numSegments || 8
  42. var angleSrc = Math.atan2(src.y - cy, src.x - cx)
  43. var angleDst = Math.atan2(dst.y - cy, dst.x - cx)
  44. var r = Math.sqrt(Math.pow(src.x - cx, 2) + Math.pow(src.y - cy, 2))
  45. // 顺时针方向角度差
  46. var diff = angleDst - angleSrc
  47. if (diff < 0) diff += Math.PI * 2
  48. var points = []
  49. for (var i = 0; i <= numSegments; i++) {
  50. var t = i / numSegments
  51. var angle = angleSrc + diff * t
  52. points.push({
  53. x: cx + r * Math.cos(angle),
  54. y: cy + r * Math.sin(angle)
  55. })
  56. }
  57. return points
  58. }
  59. /**
  60. * 粒子对象
  61. */
  62. function Particle(path, srcColor, dstColor, speed, baseSize) {
  63. this.path = path // [{x,y},...] 路径控制点
  64. this.srcColor = srcColor // {r,g,b}
  65. this.dstColor = dstColor // {r,g,b}(相克 与 srcColor 相同)
  66. this.progress = 0 // 0→1
  67. this.speed = speed
  68. this.baseSize = baseSize || 3
  69. this.x = path[0].x
  70. this.y = path[0].y
  71. this.currentColor = { r: srcColor.r, g: srcColor.g, b: srcColor.b }
  72. this.size = this.baseSize
  73. this.alive = true
  74. }
  75. Particle.prototype.update = function () {
  76. this.progress += this.speed
  77. if (this.progress >= 1) {
  78. this.alive = false
  79. return
  80. }
  81. var t = this.progress
  82. var totalSegs = this.path.length - 1
  83. var segIdx = Math.min(Math.floor(t * totalSegs), totalSegs - 1)
  84. var segFrac = (t * totalSegs) - segIdx
  85. var p0 = this.path[segIdx]
  86. var p1 = this.path[segIdx + 1]
  87. this.x = p0.x + (p1.x - p0.x) * segFrac
  88. this.y = p0.y + (p1.y - p0.y) * segFrac
  89. // 颜色插值
  90. this.currentColor = {
  91. r: Math.round(this.srcColor.r + (this.dstColor.r - this.srcColor.r) * t),
  92. g: Math.round(this.srcColor.g + (this.dstColor.g - this.srcColor.g) * t),
  93. b: Math.round(this.srcColor.b + (this.dstColor.b - this.srcColor.b) * t)
  94. }
  95. // 粒子衰减
  96. this.size = this.baseSize * (1 - t * 0.3)
  97. }
  98. /**
  99. * FlowAnimation - 五行能量粒子流动画控制器
  100. * 使用独立 Canvas 覆盖层绘制粒子
  101. *
  102. * @param {CanvasRenderingContext2D} ctx
  103. * @param {number} w - Canvas 宽度(逻辑像素)
  104. * @param {number} h - Canvas 高度(逻辑像素)
  105. * @param {Object} opts
  106. * @param {Array<{x:number,y:number}>} opts.outer - calcStarVertices 外顶点数组
  107. * @param {Array<{x:number,y:number}>} opts.inner - calcStarVertices 内顶点数组
  108. * @param {Array<string>} opts.dimOrder - sortedDimensions 的 code 数组
  109. */
  110. export function FlowAnimation(ctx, w, h, opts) {
  111. this._ctx = ctx
  112. this._w = w
  113. this._h = h
  114. this._cx = w / 2
  115. this._cy = h / 2
  116. this._outer = opts.outer
  117. this._inner = opts.inner
  118. this._dimOrder = opts.dimOrder
  119. this._particles = []
  120. this._timer = null
  121. this._running = false
  122. this._frameCount = 0
  123. this._buildShengPaths()
  124. this._buildKePaths()
  125. }
  126. /**
  127. * 构建相生路径(外圆弧线)
  128. * 顶点顺序 0→1→2→3→4→0
  129. */
  130. FlowAnimation.prototype._buildShengPaths = function () {
  131. this._shengPaths = []
  132. for (var i = 0; i < 5; i++) {
  133. var src = this._outer[i]
  134. var dst = this._outer[(i + 1) % 5]
  135. this._shengPaths.push({
  136. waypoints: arcWaypoints(src, dst, this._cx, this._cy, 6),
  137. srcColor: colorForDimCode(this._dimOrder[i]),
  138. dstColor: colorForDimCode(this._dimOrder[(i + 1) % 5])
  139. })
  140. }
  141. }
  142. /**
  143. * 构建相克路径(五角星边)
  144. * 相克索引对:0→2, 2→4, 4→1, 1→3, 3→0
  145. * 路径:outer[s] → inner[(s+1)%5] → outer[e]
  146. */
  147. FlowAnimation.prototype._buildKePaths = function () {
  148. this._kePaths = []
  149. var segments = [[0, 2], [2, 4], [4, 1], [1, 3], [3, 0]]
  150. for (var i = 0; i < segments.length; i++) {
  151. var s = segments[i][0]
  152. var e = segments[i][1]
  153. var srcColor = colorForDimCode(this._dimOrder[e])
  154. this._kePaths.push({
  155. waypoints: [
  156. this._outer[e],
  157. this._inner[(s + 1) % 5],
  158. this._outer[s]
  159. ],
  160. srcColor: srcColor,
  161. dstColor: srcColor // 相克粒子保持被克方颜色(能量被吸收)
  162. })
  163. }
  164. }
  165. /**
  166. * 启动连续动画循环
  167. */
  168. FlowAnimation.prototype.start = function () {
  169. if (this._running) return
  170. this._running = true
  171. this._loop()
  172. }
  173. /**
  174. * 停止动画循环
  175. */
  176. FlowAnimation.prototype.stop = function () {
  177. this._running = false
  178. if (this._timer) {
  179. clearTimeout(this._timer)
  180. this._timer = null
  181. }
  182. }
  183. /**
  184. * 刷新路径(当 Canvas 尺寸或维度排序发生变化时调用)
  185. */
  186. FlowAnimation.prototype.refreshPaths = function (opts) {
  187. if (opts) {
  188. if (opts.outer) this._outer = opts.outer
  189. if (opts.inner) this._inner = opts.inner
  190. if (opts.dimOrder) this._dimOrder = opts.dimOrder
  191. if (opts.w) {
  192. this._w = opts.w
  193. this._cx = opts.w / 2
  194. }
  195. if (opts.h) {
  196. this._h = opts.h
  197. this._cy = opts.h / 2
  198. }
  199. }
  200. this._buildShengPaths()
  201. this._buildKePaths()
  202. }
  203. FlowAnimation.prototype._loop = function () {
  204. if (!this._running) return
  205. var self = this
  206. this._frameCount++
  207. // 周期性发射粒子
  208. if (this._frameCount % 3 === 0) {
  209. this._emitSheng()
  210. }
  211. if (this._frameCount % 5 === 0) {
  212. this._emitKe()
  213. }
  214. // 更新所有粒子
  215. for (var i = this._particles.length - 1; i >= 0; i--) {
  216. this._particles[i].update()
  217. if (!this._particles[i].alive) {
  218. this._particles.splice(i, 1)
  219. }
  220. }
  221. // 绘制
  222. this._render()
  223. this._timer = setTimeout(function () {
  224. self._loop()
  225. }, 33) // ~30fps
  226. }
  227. /**
  228. * 发射一个相生粒子(随机选择段)
  229. */
  230. FlowAnimation.prototype._emitSheng = function () {
  231. if (this._shengPaths.length === 0) return
  232. var idx = Math.floor(Math.random() * this._shengPaths.length)
  233. var seg = this._shengPaths[idx]
  234. this._particles.push(new Particle(
  235. seg.waypoints,
  236. seg.srcColor,
  237. seg.dstColor,
  238. 0.012 + Math.random() * 0.01,
  239. 2.5 + Math.random() * 1.5
  240. ))
  241. }
  242. /**
  243. * 发射一个相克粒子(随机选择段)
  244. */
  245. FlowAnimation.prototype._emitKe = function () {
  246. if (this._kePaths.length === 0) return
  247. var idx = Math.floor(Math.random() * this._kePaths.length)
  248. var seg = this._kePaths[idx]
  249. this._particles.push(new Particle(
  250. seg.waypoints,
  251. seg.srcColor,
  252. seg.srcColor,
  253. 0.01 + Math.random() * 0.008,
  254. 3 + Math.random() * 1.5
  255. ))
  256. }
  257. /**
  258. * 渲染所有粒子到 Canvas
  259. */
  260. FlowAnimation.prototype._render = function () {
  261. var ctx = this._ctx
  262. ctx.clearRect(0, 0, this._w, this._h)
  263. for (var i = 0; i < this._particles.length; i++) {
  264. var p = this._particles[i]
  265. var c = p.currentColor
  266. var alpha = 1 - p.progress * 0.25
  267. var r = Math.max(p.size, 1.5)
  268. // 外层辉光
  269. ctx.beginPath()
  270. ctx.arc(p.x, p.y, r * 3.5, 0, Math.PI * 2)
  271. ctx.closePath()
  272. ctx.fillStyle = 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + (alpha * 0.1) + ')'
  273. ctx.fill()
  274. // 内层辉光
  275. ctx.beginPath()
  276. ctx.arc(p.x, p.y, r * 1.8, 0, Math.PI * 2)
  277. ctx.closePath()
  278. ctx.fillStyle = 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + (alpha * 0.35) + ')'
  279. ctx.fill()
  280. // 核心圆点
  281. ctx.beginPath()
  282. ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
  283. ctx.closePath()
  284. ctx.fillStyle = 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + alpha + ')'
  285. ctx.fill()
  286. }
  287. }