提交web原型存档
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
Shader "UI/CRTOverlayEffect"
|
||||
{
|
||||
Properties
|
||||
{
|
||||
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
|
||||
_Color ("Tint", Color) = (1,1,1,1)
|
||||
|
||||
[Header(Scanlines)]
|
||||
_ScanlineIntensity ("Scanline Intensity", Range(0, 1)) = 0.3
|
||||
_ScanlineCount ("Scanline Count", Range(50, 800)) = 300
|
||||
_ScanlineSpeed ("Scanline Speed", Range(0, 5)) = 0.5
|
||||
|
||||
[Header(Noise)]
|
||||
_NoiseIntensity ("Noise Intensity", Range(0, 1)) = 0.15
|
||||
_NoiseSpeed ("Noise Speed", Range(0, 50)) = 15
|
||||
_NoiseScale ("Noise Scale", Range(1, 100)) = 50
|
||||
|
||||
[Header(Chromatic Aberration)]
|
||||
_ChromaticAberration ("Chromatic Aberration", Range(0, 0.02)) = 0.003
|
||||
|
||||
[Header(Vignette)]
|
||||
_VignetteIntensity ("Vignette Intensity", Range(0, 1)) = 0.3
|
||||
_VignetteSmoothness ("Vignette Smoothness", Range(0.01, 1)) = 0.4
|
||||
|
||||
[Header(Screen Curvature)]
|
||||
_CurvatureAmount ("Curvature Amount", Range(0, 0.1)) = 0.02
|
||||
|
||||
[Header(Flicker)]
|
||||
_FlickerIntensity ("Flicker Intensity", Range(0, 0.1)) = 0.02
|
||||
_FlickerSpeed ("Flicker Speed", Range(0, 30)) = 8
|
||||
|
||||
[Header(Glow)]
|
||||
_GlowIntensity ("Glow Intensity", Range(0, 1)) = 0.1
|
||||
_GlowColor ("Glow Color", Color) = (0.2, 0.8, 1, 1)
|
||||
|
||||
// 用于控制 stencil 等 UI 功能
|
||||
_StencilComp ("Stencil Comparison", Float) = 8
|
||||
_Stencil ("Stencil ID", Float) = 0
|
||||
_StencilOp ("Stencil Operation", Float) = 0
|
||||
_StencilWriteMask ("Stencil Write Mask", Float) = 255
|
||||
_StencilReadMask ("Stencil Read Mask", Float) = 255
|
||||
_ColorMask ("Color Mask", Float) = 15
|
||||
}
|
||||
|
||||
SubShader
|
||||
{
|
||||
Tags
|
||||
{
|
||||
"Queue"="Transparent"
|
||||
"IgnoreProjector"="True"
|
||||
"RenderType"="Transparent"
|
||||
"PreviewType"="Plane"
|
||||
"CanUseSpriteAtlas"="True"
|
||||
}
|
||||
|
||||
Stencil
|
||||
{
|
||||
Ref [_Stencil]
|
||||
Comp [_StencilComp]
|
||||
Pass [_StencilOp]
|
||||
ReadMask [_StencilReadMask]
|
||||
WriteMask [_StencilWriteMask]
|
||||
}
|
||||
|
||||
Cull Off
|
||||
Lighting Off
|
||||
ZWrite Off
|
||||
ZTest [unity_GUIZTestMode]
|
||||
Blend SrcAlpha OneMinusSrcAlpha
|
||||
ColorMask [_ColorMask]
|
||||
|
||||
Pass
|
||||
{
|
||||
Name "CRTOverlay"
|
||||
|
||||
CGPROGRAM
|
||||
#pragma vertex vert
|
||||
#pragma fragment frag
|
||||
#pragma target 3.0
|
||||
|
||||
#include "UnityCG.cginc"
|
||||
#include "UnityUI.cginc"
|
||||
|
||||
struct appdata
|
||||
{
|
||||
float4 vertex : POSITION;
|
||||
float4 color : COLOR;
|
||||
float2 uv : TEXCOORD0;
|
||||
UNITY_VERTEX_INPUT_INSTANCE_ID
|
||||
};
|
||||
|
||||
struct v2f
|
||||
{
|
||||
float4 vertex : SV_POSITION;
|
||||
fixed4 color : COLOR;
|
||||
float2 uv : TEXCOORD0;
|
||||
float4 worldPosition : TEXCOORD1;
|
||||
UNITY_VERTEX_OUTPUT_STEREO
|
||||
};
|
||||
|
||||
sampler2D _MainTex;
|
||||
float4 _MainTex_ST;
|
||||
fixed4 _Color;
|
||||
float4 _ClipRect;
|
||||
|
||||
// Effect parameters
|
||||
float _ScanlineIntensity;
|
||||
float _ScanlineCount;
|
||||
float _ScanlineSpeed;
|
||||
float _NoiseIntensity;
|
||||
float _NoiseSpeed;
|
||||
float _NoiseScale;
|
||||
float _ChromaticAberration;
|
||||
float _VignetteIntensity;
|
||||
float _VignetteSmoothness;
|
||||
float _CurvatureAmount;
|
||||
float _FlickerIntensity;
|
||||
float _FlickerSpeed;
|
||||
float _GlowIntensity;
|
||||
fixed4 _GlowColor;
|
||||
|
||||
// 简单的噪点函数
|
||||
float random(float2 st)
|
||||
{
|
||||
return frac(sin(dot(st.xy, float2(12.9898, 78.233))) * 43758.5453123);
|
||||
}
|
||||
|
||||
// 2D噪点
|
||||
float noise(float2 st)
|
||||
{
|
||||
float2 i = floor(st);
|
||||
float2 f = frac(st);
|
||||
|
||||
float a = random(i);
|
||||
float b = random(i + float2(1.0, 0.0));
|
||||
float c = random(i + float2(0.0, 1.0));
|
||||
float d = random(i + float2(1.0, 1.0));
|
||||
|
||||
float2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
return lerp(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
|
||||
}
|
||||
|
||||
v2f vert(appdata v)
|
||||
{
|
||||
v2f o;
|
||||
UNITY_SETUP_INSTANCE_ID(v);
|
||||
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
|
||||
|
||||
o.worldPosition = v.vertex;
|
||||
o.vertex = UnityObjectToClipPos(v.vertex);
|
||||
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
|
||||
o.color = v.color * _Color;
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
fixed4 frag(v2f i) : SV_Target
|
||||
{
|
||||
float2 uv = i.uv;
|
||||
float time = _Time.y;
|
||||
|
||||
// 1. 屏幕弯曲效果
|
||||
float2 curvedUV = uv;
|
||||
if (_CurvatureAmount > 0)
|
||||
{
|
||||
float2 centered = uv - 0.5;
|
||||
float dist = dot(centered, centered);
|
||||
curvedUV = uv + centered * dist * _CurvatureAmount;
|
||||
}
|
||||
|
||||
// 检查是否超出边界
|
||||
if (curvedUV.x < 0 || curvedUV.x > 1 || curvedUV.y < 0 || curvedUV.y > 1)
|
||||
{
|
||||
return fixed4(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
// 2. 采样基础颜色 (带色差)
|
||||
fixed4 col = fixed4(0, 0, 0, 0);
|
||||
|
||||
if (_ChromaticAberration > 0)
|
||||
{
|
||||
float2 dir = normalize(curvedUV - 0.5);
|
||||
float dist = length(curvedUV - 0.5);
|
||||
float aberration = _ChromaticAberration * dist;
|
||||
|
||||
col.r = tex2D(_MainTex, curvedUV + dir * aberration).r;
|
||||
col.g = tex2D(_MainTex, curvedUV).g;
|
||||
col.b = tex2D(_MainTex, curvedUV - dir * aberration).b;
|
||||
col.a = tex2D(_MainTex, curvedUV).a;
|
||||
}
|
||||
else
|
||||
{
|
||||
col = tex2D(_MainTex, curvedUV);
|
||||
}
|
||||
|
||||
col *= i.color;
|
||||
|
||||
// 3. 扫描线
|
||||
if (_ScanlineIntensity > 0)
|
||||
{
|
||||
float scanline = sin((curvedUV.y + time * _ScanlineSpeed * 0.1) * _ScanlineCount * 3.14159) * 0.5 + 0.5;
|
||||
scanline = pow(scanline, 1.5);
|
||||
col.rgb *= 1.0 - (_ScanlineIntensity * (1.0 - scanline));
|
||||
|
||||
// 添加水平扫描线移动效果
|
||||
float movingScanline = sin((curvedUV.y - time * 0.2) * 20.0) * 0.5 + 0.5;
|
||||
movingScanline = step(0.99, movingScanline);
|
||||
col.rgb += movingScanline * 0.03;
|
||||
}
|
||||
|
||||
// 4. 噪点
|
||||
if (_NoiseIntensity > 0)
|
||||
{
|
||||
float2 noiseUV = curvedUV * _NoiseScale;
|
||||
float n = noise(noiseUV + time * _NoiseSpeed);
|
||||
n = n * 2.0 - 1.0; // 转换到 -1 到 1 范围
|
||||
col.rgb += n * _NoiseIntensity;
|
||||
|
||||
// 添加一些随机的小噪点/颗粒
|
||||
float grain = random(curvedUV * 1000.0 + time * 100.0);
|
||||
grain = (grain - 0.5) * _NoiseIntensity * 0.5;
|
||||
col.rgb += grain;
|
||||
}
|
||||
|
||||
// 5. 闪烁
|
||||
if (_FlickerIntensity > 0)
|
||||
{
|
||||
float flicker = 1.0 + sin(time * _FlickerSpeed) * _FlickerIntensity;
|
||||
flicker *= 1.0 + random(float2(time * 0.1, 0)) * _FlickerIntensity * 0.5;
|
||||
col.rgb *= flicker;
|
||||
}
|
||||
|
||||
// 6. 暗角
|
||||
if (_VignetteIntensity > 0)
|
||||
{
|
||||
float2 vignetteUV = curvedUV * (1.0 - curvedUV);
|
||||
float vignette = vignetteUV.x * vignetteUV.y * 15.0;
|
||||
vignette = pow(vignette, _VignetteSmoothness);
|
||||
vignette = saturate(vignette);
|
||||
col.rgb *= lerp(1.0 - _VignetteIntensity, 1.0, vignette);
|
||||
}
|
||||
|
||||
// 7. 发光效果
|
||||
if (_GlowIntensity > 0)
|
||||
{
|
||||
col.rgb += _GlowColor.rgb * _GlowIntensity * col.a;
|
||||
}
|
||||
|
||||
// 8. 添加一些RGB分离的条纹效果 (模拟CRT的RGB像素)
|
||||
float rgbStripe = frac(curvedUV.x * 300.0);
|
||||
float3 rgbMask = float3(
|
||||
step(rgbStripe, 0.33),
|
||||
step(0.33, rgbStripe) * step(rgbStripe, 0.66),
|
||||
step(0.66, rgbStripe)
|
||||
);
|
||||
// 轻微的RGB条纹效果
|
||||
col.rgb = lerp(col.rgb, col.rgb * (0.8 + rgbMask * 0.4), 0.05);
|
||||
|
||||
// 应用 UI 裁剪
|
||||
col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);
|
||||
|
||||
#ifdef UNITY_UI_ALPHACLIP
|
||||
clip(col.a - 0.001);
|
||||
#endif
|
||||
|
||||
return col;
|
||||
}
|
||||
ENDCG
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0b799d3e593ac644b1b02efa5a6fa14
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -97,9 +97,10 @@ position: 205,446
|
||||
colorID: 1
|
||||
---
|
||||
//每次进入维修界面就自动保存
|
||||
<<switch_fix_system_to "Clinic">>
|
||||
<<fade_out 1>>
|
||||
<<switch_fix_system_to "Expression">>
|
||||
<<switch_fix_system_to Expression>>
|
||||
<<open_expression>>
|
||||
<<start_expression "哈哈|嘿嘿|呵呵" "我很害怕" "LOG1梳理完成">>
|
||||
// <<switch_fix_system_to "AnalysisMode">>
|
||||
// <<start_analysis "AnalysisLevel01" >>
|
||||
===
|
||||
|
||||
@@ -15,6 +15,7 @@ position: 272,-1156
|
||||
// - 低沉的波形被压缩成尖锐的波形
|
||||
// - 修改过程要有震撼感
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
<<fade_out 1>>
|
||||
<<switch_fix_system_to "BodyModule" "Head">>
|
||||
<<show_task_panel>>
|
||||
<<DropCable>>
|
||||
|
||||
@@ -15,6 +15,7 @@ position: 272,-1156
|
||||
// - LOG2: 6字,中等,关于英里
|
||||
// - LOG3: 8字,困难,自责与释放
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
<<fade_out 1>>
|
||||
<<play_clinic_cut>>
|
||||
<<fade_in 1>>
|
||||
<<switch_fix_system_to "BodyModule" "Head">>
|
||||
|
||||
@@ -21,6 +21,12 @@ MonoBehaviour:
|
||||
waveHeight: 0.381
|
||||
waveSpeed: 1
|
||||
frequency: 4.68
|
||||
harmonicA1: 0.55
|
||||
harmonicA2: 0.28
|
||||
harmonicA3: 0.12
|
||||
harmonicPhase2: 0.3
|
||||
harmonicPhase3: 0.5
|
||||
verticalOffset: -0.08
|
||||
logicWaveform:
|
||||
waveformId: logic_square
|
||||
displayName: "\u903B\u8F91\u6A21\u5757"
|
||||
@@ -30,6 +36,12 @@ MonoBehaviour:
|
||||
waveHeight: 0.563
|
||||
waveSpeed: 1
|
||||
frequency: 4.1
|
||||
harmonicA1: 0.55
|
||||
harmonicA2: 0.28
|
||||
harmonicA3: 0.12
|
||||
harmonicPhase2: 0.3
|
||||
harmonicPhase3: 0.5
|
||||
verticalOffset: -0.08
|
||||
jokeWaveform:
|
||||
waveformId: joke
|
||||
displayName: "\u7B11\u8BDD\u4FE1\u53F7"
|
||||
@@ -39,6 +51,12 @@ MonoBehaviour:
|
||||
waveHeight: 0.468
|
||||
waveSpeed: 1
|
||||
frequency: 2.2
|
||||
harmonicA1: 0.55
|
||||
harmonicA2: 0.28
|
||||
harmonicA3: 0.12
|
||||
harmonicPhase2: 0.3
|
||||
harmonicPhase3: 0.5
|
||||
verticalOffset: -0.08
|
||||
filterOutputWaveform:
|
||||
waveformId: filter_output
|
||||
displayName: "\u6EE4\u6CE2\u5668\u8F93\u51FA"
|
||||
@@ -48,6 +66,12 @@ MonoBehaviour:
|
||||
waveHeight: 0.424
|
||||
waveSpeed: 2
|
||||
frequency: 4.64
|
||||
harmonicA1: 0.55
|
||||
harmonicA2: 0.28
|
||||
harmonicA3: 0.12
|
||||
harmonicPhase2: 0.3
|
||||
harmonicPhase3: 0.5
|
||||
verticalOffset: -0.08
|
||||
logicSynthWeight: 0.3
|
||||
jokeSynthWeight: 0.85
|
||||
normalOutputWaveform:
|
||||
@@ -59,3 +83,9 @@ MonoBehaviour:
|
||||
waveHeight: 0.8
|
||||
waveSpeed: 1.5
|
||||
frequency: 1.5
|
||||
harmonicA1: 0.55
|
||||
harmonicA2: 0.28
|
||||
harmonicA3: 0.12
|
||||
harmonicPhase2: 0.3
|
||||
harmonicPhase3: 0.5
|
||||
verticalOffset: -0.08
|
||||
|
||||
+6314
-1665
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using DG.Tweening;
|
||||
|
||||
namespace AibisDream.MiniGame.Language
|
||||
{
|
||||
/// <summary>
|
||||
/// CRT复古显示器效果控制器
|
||||
/// 放在 LanguageParticleSystem 的最顶层 Image 上
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Image))]
|
||||
public class CRTOverlayController : MonoBehaviour
|
||||
{
|
||||
[Header("效果强度预设")]
|
||||
[SerializeField] private CRTPreset currentPreset = CRTPreset.Retro;
|
||||
|
||||
[Header("自定义参数")]
|
||||
[SerializeField, Range(0f, 1f)] private float scanlineIntensity = 0.3f;
|
||||
[SerializeField, Range(50f, 800f)] private float scanlineCount = 300f;
|
||||
[SerializeField, Range(0f, 5f)] private float scanlineSpeed = 0.5f;
|
||||
|
||||
[SerializeField, Range(0f, 1f)] private float noiseIntensity = 0.15f;
|
||||
[SerializeField, Range(0f, 50f)] private float noiseSpeed = 15f;
|
||||
[SerializeField, Range(1f, 100f)] private float noiseScale = 50f;
|
||||
|
||||
[SerializeField, Range(0f, 0.02f)] private float chromaticAberration = 0.003f;
|
||||
|
||||
[SerializeField, Range(0f, 1f)] private float vignetteIntensity = 0.3f;
|
||||
[SerializeField, Range(0.01f, 1f)] private float vignetteSmoothness = 0.4f;
|
||||
|
||||
[SerializeField, Range(0f, 0.1f)] private float curvatureAmount = 0.02f;
|
||||
|
||||
[SerializeField, Range(0f, 0.1f)] private float flickerIntensity = 0.02f;
|
||||
[SerializeField, Range(0f, 30f)] private float flickerSpeed = 8f;
|
||||
|
||||
[SerializeField, Range(0f, 1f)] private float glowIntensity = 0.1f;
|
||||
[SerializeField] private Color glowColor = new Color(0.2f, 0.8f, 1f, 1f);
|
||||
|
||||
[Header("动态效果")]
|
||||
[SerializeField] private bool enableDynamicNoise = true;
|
||||
[SerializeField] private float noiseVariation = 0.05f;
|
||||
[SerializeField] private float noiseVariationSpeed = 2f;
|
||||
|
||||
private Image overlayImage;
|
||||
private Material material;
|
||||
private Material instanceMaterial;
|
||||
|
||||
// Shader 属性 ID(缓存以提高性能)
|
||||
private static readonly int ScanlineIntensityID = Shader.PropertyToID("_ScanlineIntensity");
|
||||
private static readonly int ScanlineCountID = Shader.PropertyToID("_ScanlineCount");
|
||||
private static readonly int ScanlineSpeedID = Shader.PropertyToID("_ScanlineSpeed");
|
||||
private static readonly int NoiseIntensityID = Shader.PropertyToID("_NoiseIntensity");
|
||||
private static readonly int NoiseSpeedID = Shader.PropertyToID("_NoiseSpeed");
|
||||
private static readonly int NoiseScaleID = Shader.PropertyToID("_NoiseScale");
|
||||
private static readonly int ChromaticAberrationID = Shader.PropertyToID("_ChromaticAberration");
|
||||
private static readonly int VignetteIntensityID = Shader.PropertyToID("_VignetteIntensity");
|
||||
private static readonly int VignetteSmoothnessID = Shader.PropertyToID("_VignetteSmoothness");
|
||||
private static readonly int CurvatureAmountID = Shader.PropertyToID("_CurvatureAmount");
|
||||
private static readonly int FlickerIntensityID = Shader.PropertyToID("_FlickerIntensity");
|
||||
private static readonly int FlickerSpeedID = Shader.PropertyToID("_FlickerSpeed");
|
||||
private static readonly int GlowIntensityID = Shader.PropertyToID("_GlowIntensity");
|
||||
private static readonly int GlowColorID = Shader.PropertyToID("_GlowColor");
|
||||
|
||||
public enum CRTPreset
|
||||
{
|
||||
Custom, // 使用自定义参数
|
||||
Subtle, // 轻微效果
|
||||
Retro, // 复古效果(默认)
|
||||
Damaged, // 受损/故障效果
|
||||
Hologram // 全息投影效果
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
overlayImage = GetComponent<Image>();
|
||||
SetupMaterial();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
ApplyPreset(currentPreset);
|
||||
}
|
||||
|
||||
private void SetupMaterial()
|
||||
{
|
||||
// 查找 CRT Shader
|
||||
Shader crtShader = Shader.Find("UI/CRTOverlayEffect");
|
||||
if (crtShader == null)
|
||||
{
|
||||
Debug.LogError("CRTOverlayController: 找不到 UI/CRTOverlayEffect shader!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建材质实例
|
||||
instanceMaterial = new Material(crtShader);
|
||||
instanceMaterial.name = "CRTOverlay_Instance";
|
||||
overlayImage.material = instanceMaterial;
|
||||
material = instanceMaterial;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (material == null) return;
|
||||
|
||||
// 动态噪点变化
|
||||
if (enableDynamicNoise)
|
||||
{
|
||||
float dynamicNoise = noiseIntensity +
|
||||
Mathf.Sin(Time.time * noiseVariationSpeed) * noiseVariation;
|
||||
material.SetFloat(NoiseIntensityID, dynamicNoise);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (Application.isPlaying && material != null)
|
||||
{
|
||||
if (currentPreset == CRTPreset.Custom)
|
||||
{
|
||||
ApplyCustomParameters();
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyPreset(currentPreset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用预设效果
|
||||
/// </summary>
|
||||
public void ApplyPreset(CRTPreset preset)
|
||||
{
|
||||
currentPreset = preset;
|
||||
|
||||
switch (preset)
|
||||
{
|
||||
case CRTPreset.Subtle:
|
||||
SetSubtlePreset();
|
||||
break;
|
||||
case CRTPreset.Retro:
|
||||
SetRetroPreset();
|
||||
break;
|
||||
case CRTPreset.Damaged:
|
||||
SetDamagedPreset();
|
||||
break;
|
||||
case CRTPreset.Hologram:
|
||||
SetHologramPreset();
|
||||
break;
|
||||
case CRTPreset.Custom:
|
||||
ApplyCustomParameters();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetSubtlePreset()
|
||||
{
|
||||
scanlineIntensity = 0.15f;
|
||||
scanlineCount = 200f;
|
||||
scanlineSpeed = 0.3f;
|
||||
noiseIntensity = 0.08f;
|
||||
noiseSpeed = 10f;
|
||||
noiseScale = 40f;
|
||||
chromaticAberration = 0.001f;
|
||||
vignetteIntensity = 0.2f;
|
||||
vignetteSmoothness = 0.5f;
|
||||
curvatureAmount = 0.01f;
|
||||
flickerIntensity = 0.01f;
|
||||
flickerSpeed = 5f;
|
||||
glowIntensity = 0.05f;
|
||||
glowColor = new Color(0.3f, 0.9f, 1f, 1f);
|
||||
|
||||
ApplyCustomParameters();
|
||||
}
|
||||
|
||||
private void SetRetroPreset()
|
||||
{
|
||||
// 参考用户提供的图片 - 典型的复古CRT效果
|
||||
scanlineIntensity = 0.35f;
|
||||
scanlineCount = 300f;
|
||||
scanlineSpeed = 0.5f;
|
||||
noiseIntensity = 0.18f;
|
||||
noiseSpeed = 15f;
|
||||
noiseScale = 50f;
|
||||
chromaticAberration = 0.004f;
|
||||
vignetteIntensity = 0.35f;
|
||||
vignetteSmoothness = 0.4f;
|
||||
curvatureAmount = 0.025f;
|
||||
flickerIntensity = 0.025f;
|
||||
flickerSpeed = 8f;
|
||||
glowIntensity = 0.12f;
|
||||
glowColor = new Color(0.2f, 0.8f, 1f, 1f);
|
||||
|
||||
ApplyCustomParameters();
|
||||
}
|
||||
|
||||
private void SetDamagedPreset()
|
||||
{
|
||||
scanlineIntensity = 0.5f;
|
||||
scanlineCount = 250f;
|
||||
scanlineSpeed = 1.5f;
|
||||
noiseIntensity = 0.3f;
|
||||
noiseSpeed = 25f;
|
||||
noiseScale = 60f;
|
||||
chromaticAberration = 0.01f;
|
||||
vignetteIntensity = 0.5f;
|
||||
vignetteSmoothness = 0.3f;
|
||||
curvatureAmount = 0.04f;
|
||||
flickerIntensity = 0.06f;
|
||||
flickerSpeed = 15f;
|
||||
glowIntensity = 0.08f;
|
||||
glowColor = new Color(1f, 0.5f, 0.3f, 1f);
|
||||
|
||||
ApplyCustomParameters();
|
||||
}
|
||||
|
||||
private void SetHologramPreset()
|
||||
{
|
||||
scanlineIntensity = 0.4f;
|
||||
scanlineCount = 400f;
|
||||
scanlineSpeed = 2f;
|
||||
noiseIntensity = 0.12f;
|
||||
noiseSpeed = 20f;
|
||||
noiseScale = 30f;
|
||||
chromaticAberration = 0.008f;
|
||||
vignetteIntensity = 0.15f;
|
||||
vignetteSmoothness = 0.6f;
|
||||
curvatureAmount = 0f;
|
||||
flickerIntensity = 0.04f;
|
||||
flickerSpeed = 12f;
|
||||
glowIntensity = 0.25f;
|
||||
glowColor = new Color(0.3f, 1f, 0.8f, 1f);
|
||||
|
||||
ApplyCustomParameters();
|
||||
}
|
||||
|
||||
private void ApplyCustomParameters()
|
||||
{
|
||||
if (material == null) return;
|
||||
|
||||
material.SetFloat(ScanlineIntensityID, scanlineIntensity);
|
||||
material.SetFloat(ScanlineCountID, scanlineCount);
|
||||
material.SetFloat(ScanlineSpeedID, scanlineSpeed);
|
||||
material.SetFloat(NoiseIntensityID, noiseIntensity);
|
||||
material.SetFloat(NoiseSpeedID, noiseSpeed);
|
||||
material.SetFloat(NoiseScaleID, noiseScale);
|
||||
material.SetFloat(ChromaticAberrationID, chromaticAberration);
|
||||
material.SetFloat(VignetteIntensityID, vignetteIntensity);
|
||||
material.SetFloat(VignetteSmoothnessID, vignetteSmoothness);
|
||||
material.SetFloat(CurvatureAmountID, curvatureAmount);
|
||||
material.SetFloat(FlickerIntensityID, flickerIntensity);
|
||||
material.SetFloat(FlickerSpeedID, flickerSpeed);
|
||||
material.SetFloat(GlowIntensityID, glowIntensity);
|
||||
material.SetColor(GlowColorID, glowColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置效果强度(0-1,整体缩放所有效果)
|
||||
/// </summary>
|
||||
public void SetEffectIntensity(float intensity)
|
||||
{
|
||||
intensity = Mathf.Clamp01(intensity);
|
||||
|
||||
if (material == null) return;
|
||||
|
||||
// 根据 intensity 缩放所有效果
|
||||
material.SetFloat(ScanlineIntensityID, scanlineIntensity * intensity);
|
||||
material.SetFloat(NoiseIntensityID, noiseIntensity * intensity);
|
||||
material.SetFloat(ChromaticAberrationID, chromaticAberration * intensity);
|
||||
material.SetFloat(VignetteIntensityID, vignetteIntensity * intensity);
|
||||
material.SetFloat(CurvatureAmountID, curvatureAmount * intensity);
|
||||
material.SetFloat(FlickerIntensityID, flickerIntensity * intensity);
|
||||
material.SetFloat(GlowIntensityID, glowIntensity * intensity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 动画过渡到指定强度
|
||||
/// </summary>
|
||||
public Tween TweenEffectIntensity(float targetIntensity, float duration)
|
||||
{
|
||||
float currentIntensity = 1f;
|
||||
return DOTween.To(
|
||||
() => currentIntensity,
|
||||
x => {
|
||||
currentIntensity = x;
|
||||
SetEffectIntensity(x);
|
||||
},
|
||||
targetIntensity,
|
||||
duration
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 触发故障效果(短暂的强烈干扰)
|
||||
/// </summary>
|
||||
public void TriggerGlitch(float duration = 0.3f)
|
||||
{
|
||||
if (material == null) return;
|
||||
|
||||
// 保存当前值
|
||||
float originalNoise = noiseIntensity;
|
||||
float originalChroma = chromaticAberration;
|
||||
float originalFlicker = flickerIntensity;
|
||||
|
||||
// 设置故障效果
|
||||
material.SetFloat(NoiseIntensityID, 0.5f);
|
||||
material.SetFloat(ChromaticAberrationID, 0.015f);
|
||||
material.SetFloat(FlickerIntensityID, 0.1f);
|
||||
|
||||
// 恢复原始值
|
||||
DOVirtual.DelayedCall(duration, () =>
|
||||
{
|
||||
if (material != null)
|
||||
{
|
||||
material.SetFloat(NoiseIntensityID, originalNoise);
|
||||
material.SetFloat(ChromaticAberrationID, originalChroma);
|
||||
material.SetFloat(FlickerIntensityID, originalFlicker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置噪点强度
|
||||
/// </summary>
|
||||
public void SetNoiseIntensity(float intensity)
|
||||
{
|
||||
noiseIntensity = Mathf.Clamp01(intensity);
|
||||
if (material != null)
|
||||
{
|
||||
material.SetFloat(NoiseIntensityID, noiseIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置扫描线强度
|
||||
/// </summary>
|
||||
public void SetScanlineIntensity(float intensity)
|
||||
{
|
||||
scanlineIntensity = Mathf.Clamp01(intensity);
|
||||
if (material != null)
|
||||
{
|
||||
material.SetFloat(ScanlineIntensityID, scanlineIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置发光颜色
|
||||
/// </summary>
|
||||
public void SetGlowColor(Color color)
|
||||
{
|
||||
glowColor = color;
|
||||
if (material != null)
|
||||
{
|
||||
material.SetColor(GlowColorID, glowColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// 清理材质实例
|
||||
if (instanceMaterial != null)
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
Destroy(instanceMaterial);
|
||||
}
|
||||
else
|
||||
{
|
||||
DestroyImmediate(instanceMaterial);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 415679945cacc8c4ca1164e043aff0b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,252 @@
|
||||
using UnityEngine;
|
||||
using Shapes;
|
||||
|
||||
namespace AibisDream.MiniGame.Language
|
||||
{
|
||||
/// <summary>
|
||||
/// 复古噪点叠加渲染器 - 在指定区域上方绘制复古显示器效果
|
||||
/// 使用 Shapes 库绘制,与粒子系统使用相同的渲染方式
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
public class NoiseOverlayRenderer : ImmediateModeShapeDrawer
|
||||
{
|
||||
[Header("区域设置")]
|
||||
[Tooltip("效果覆盖的世界空间区域中心")]
|
||||
public Vector3 areaCenter = Vector3.zero;
|
||||
[Tooltip("效果覆盖的区域大小")]
|
||||
public Vector2 areaSize = new Vector2(10f, 10f);
|
||||
|
||||
[Header("噪点效果")]
|
||||
[Tooltip("启用噪点效果")]
|
||||
public bool enableNoise = true;
|
||||
[Range(0f, 1f)]
|
||||
[Tooltip("噪点强度")]
|
||||
public float noiseIntensity = 0.15f;
|
||||
[Range(1, 100)]
|
||||
[Tooltip("噪点密度(点数)")]
|
||||
public int noiseDensity = 50;
|
||||
[Tooltip("噪点颜色")]
|
||||
public Color noiseColor = new Color(1f, 1f, 1f, 0.3f);
|
||||
[Range(0.001f, 0.05f)]
|
||||
[Tooltip("噪点大小")]
|
||||
public float noiseSize = 0.02f;
|
||||
|
||||
[Header("扫描线效果")]
|
||||
[Tooltip("启用扫描线")]
|
||||
public bool enableScanlines = true;
|
||||
[Range(0f, 1f)]
|
||||
[Tooltip("扫描线强度")]
|
||||
public float scanlineIntensity = 0.1f;
|
||||
[Range(10, 200)]
|
||||
[Tooltip("扫描线数量")]
|
||||
public int scanlineCount = 60;
|
||||
[Tooltip("扫描线颜色")]
|
||||
public Color scanlineColor = new Color(0f, 0f, 0f, 0.15f);
|
||||
[Range(0.001f, 0.02f)]
|
||||
[Tooltip("扫描线厚度")]
|
||||
public float scanlineThickness = 0.005f;
|
||||
|
||||
[Header("闪烁效果")]
|
||||
[Tooltip("启用闪烁")]
|
||||
public bool enableFlicker = true;
|
||||
[Range(0f, 0.5f)]
|
||||
[Tooltip("闪烁强度")]
|
||||
public float flickerIntensity = 0.05f;
|
||||
[Range(1f, 30f)]
|
||||
[Tooltip("闪烁频率")]
|
||||
public float flickerFrequency = 10f;
|
||||
|
||||
[Header("边框暗角")]
|
||||
[Tooltip("启用边框暗角")]
|
||||
public bool enableVignette = true;
|
||||
[Range(0f, 1f)]
|
||||
[Tooltip("暗角强度")]
|
||||
public float vignetteIntensity = 0.3f;
|
||||
[Tooltip("暗角颜色")]
|
||||
public Color vignetteColor = new Color(0f, 0f, 0f, 0.5f);
|
||||
|
||||
[Header("颜色失真")]
|
||||
[Tooltip("启用色差")]
|
||||
public bool enableChromaticAberration = false;
|
||||
[Range(0f, 0.05f)]
|
||||
[Tooltip("色差偏移量")]
|
||||
public float chromaticOffset = 0.01f;
|
||||
|
||||
// 内部状态
|
||||
private float noiseTimer = 0f;
|
||||
private float flickerValue = 1f;
|
||||
private System.Random noiseRandom;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
noiseRandom = new System.Random(42);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 更新噪点动画
|
||||
noiseTimer += Time.deltaTime * 20f;
|
||||
if (noiseTimer > 1000f) noiseTimer = 0f;
|
||||
|
||||
// 更新闪烁
|
||||
if (enableFlicker)
|
||||
{
|
||||
flickerValue = 1f - flickerIntensity * Mathf.PerlinNoise(Time.time * flickerFrequency, 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
flickerValue = 1f;
|
||||
}
|
||||
|
||||
// 重新生成噪点种子
|
||||
noiseRandom = new System.Random((int)(Time.time * 60f) % 10000);
|
||||
}
|
||||
|
||||
public override void DrawShapes(Camera cam)
|
||||
{
|
||||
if (!enabled) return;
|
||||
|
||||
using (Draw.Command(cam))
|
||||
{
|
||||
Draw.BlendMode = ShapesBlendMode.Transparent;
|
||||
Draw.ZTest = UnityEngine.Rendering.CompareFunction.Always;
|
||||
|
||||
// 绘制暗角
|
||||
if (enableVignette)
|
||||
{
|
||||
DrawVignette();
|
||||
}
|
||||
|
||||
// 绘制扫描线
|
||||
if (enableScanlines)
|
||||
{
|
||||
DrawScanlines();
|
||||
}
|
||||
|
||||
// 绘制噪点
|
||||
if (enableNoise)
|
||||
{
|
||||
DrawNoise();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawNoise()
|
||||
{
|
||||
float halfWidth = areaSize.x / 2f;
|
||||
float halfHeight = areaSize.y / 2f;
|
||||
|
||||
// 根据闪烁调整噪点数量
|
||||
int actualDensity = Mathf.RoundToInt(noiseDensity * noiseIntensity * flickerValue);
|
||||
|
||||
for (int i = 0; i < actualDensity; i++)
|
||||
{
|
||||
// 使用随机数生成器获取位置
|
||||
float x = areaCenter.x + (float)(noiseRandom.NextDouble() * 2 - 1) * halfWidth;
|
||||
float y = areaCenter.y + (float)(noiseRandom.NextDouble() * 2 - 1) * halfHeight;
|
||||
float z = areaCenter.z;
|
||||
|
||||
// 随机亮度
|
||||
float brightness = (float)noiseRandom.NextDouble();
|
||||
Color pointColor = noiseColor;
|
||||
pointColor.a = noiseColor.a * brightness * flickerValue;
|
||||
|
||||
// 随机大小
|
||||
float size = noiseSize * (0.5f + (float)noiseRandom.NextDouble());
|
||||
|
||||
Draw.Disc(new Vector3(x, y, z), size, pointColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawScanlines()
|
||||
{
|
||||
float halfWidth = areaSize.x / 2f;
|
||||
float halfHeight = areaSize.y / 2f;
|
||||
|
||||
// 计算扫描线间距
|
||||
float spacing = areaSize.y / scanlineCount;
|
||||
|
||||
Draw.LineGeometry = LineGeometry.Flat2D;
|
||||
Draw.Thickness = scanlineThickness;
|
||||
|
||||
Color lineColor = scanlineColor;
|
||||
lineColor.a = scanlineColor.a * scanlineIntensity * flickerValue;
|
||||
|
||||
for (int i = 0; i < scanlineCount; i++)
|
||||
{
|
||||
float y = areaCenter.y - halfHeight + spacing * i + spacing * 0.5f;
|
||||
|
||||
// 添加轻微的闪烁变化
|
||||
float lineAlpha = lineColor.a * (0.8f + 0.2f * Mathf.Sin(y * 10f + Time.time * 5f));
|
||||
Color finalColor = lineColor;
|
||||
finalColor.a = lineAlpha;
|
||||
|
||||
Vector3 start = new Vector3(areaCenter.x - halfWidth, y, areaCenter.z);
|
||||
Vector3 end = new Vector3(areaCenter.x + halfWidth, y, areaCenter.z);
|
||||
|
||||
Draw.Line(start, end, finalColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawVignette()
|
||||
{
|
||||
float halfWidth = areaSize.x / 2f;
|
||||
float halfHeight = areaSize.y / 2f;
|
||||
|
||||
// 绘制四个角落的暗角渐变(使用多个半透明矩形模拟)
|
||||
int steps = 8;
|
||||
for (int i = 0; i < steps; i++)
|
||||
{
|
||||
float t = (float)i / steps;
|
||||
float alpha = vignetteColor.a * vignetteIntensity * (1f - t) * flickerValue;
|
||||
Color cornerColor = vignetteColor;
|
||||
cornerColor.a = alpha * 0.3f;
|
||||
|
||||
float cornerSize = Mathf.Lerp(areaSize.x * 0.3f, 0.1f, t);
|
||||
float offset = cornerSize * 0.5f;
|
||||
|
||||
// 四个角落
|
||||
Vector3[] corners = new Vector3[]
|
||||
{
|
||||
new Vector3(areaCenter.x - halfWidth + offset, areaCenter.y + halfHeight - offset, areaCenter.z),
|
||||
new Vector3(areaCenter.x + halfWidth - offset, areaCenter.y + halfHeight - offset, areaCenter.z),
|
||||
new Vector3(areaCenter.x - halfWidth + offset, areaCenter.y - halfHeight + offset, areaCenter.z),
|
||||
new Vector3(areaCenter.x + halfWidth - offset, areaCenter.y - halfHeight + offset, areaCenter.z)
|
||||
};
|
||||
|
||||
foreach (var corner in corners)
|
||||
{
|
||||
Draw.Disc(corner, cornerSize, cornerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置覆盖区域(可以从 LanguageParticleManager 获取 Canvas 区域)
|
||||
/// </summary>
|
||||
public void SetArea(Vector3 center, Vector2 size)
|
||||
{
|
||||
areaCenter = center;
|
||||
areaSize = size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置效果强度(0-1)
|
||||
/// </summary>
|
||||
public void SetIntensity(float intensity)
|
||||
{
|
||||
intensity = Mathf.Clamp01(intensity);
|
||||
noiseIntensity = intensity * 0.15f;
|
||||
scanlineIntensity = intensity * 0.1f;
|
||||
flickerIntensity = intensity * 0.05f;
|
||||
vignetteIntensity = intensity * 0.3f;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 在编辑器中显示效果区域
|
||||
Gizmos.color = new Color(0f, 1f, 0f, 0.3f);
|
||||
Gizmos.DrawWireCube(areaCenter, new Vector3(areaSize.x, areaSize.y, 0.01f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57d1019031cb5934a915778ed48d82f9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
public class WaveformConfig : ScriptableObject
|
||||
{
|
||||
[Header("情绪模块波形 - 悲伤/焦虑/恐惧")]
|
||||
[Tooltip("低频、缓慢起伏、带有不安的颤抖感")]
|
||||
[Tooltip("低频、缓慢起伏、通过谐波叠加表达情绪")]
|
||||
public WaveformSettings emotionWaveform = new WaveformSettings
|
||||
{
|
||||
waveformId = "emotion",
|
||||
@@ -20,7 +20,14 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
secondaryColor = new Color(0.5f, 0.3f, 0.7f, 1f), // 紫色(焦虑)
|
||||
waveHeight = 0.8f,
|
||||
waveSpeed = 0.8f, // 慢速,沉重感
|
||||
frequency = 1f
|
||||
frequency = 1f,
|
||||
// 谐波配置:基波为主,二次/三次谐波辅助
|
||||
harmonicA1 = 0.55f,
|
||||
harmonicA2 = 0.28f,
|
||||
harmonicA3 = 0.12f,
|
||||
harmonicPhase2 = 0.3f,
|
||||
harmonicPhase3 = 0.5f,
|
||||
verticalOffset = -0.08f
|
||||
};
|
||||
|
||||
[Header("逻辑模块波形 - 简单方波(低幅度)")]
|
||||
@@ -148,6 +155,31 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
[Tooltip("波形频率")]
|
||||
[Range(0.5f, 10f)]
|
||||
public float frequency = 1f;
|
||||
|
||||
[Header("谐波配置(用于情绪波形的傅里叶合成)")]
|
||||
[Tooltip("基波振幅(主导波形)")]
|
||||
[Range(0.1f, 1f)]
|
||||
public float harmonicA1 = 0.55f;
|
||||
|
||||
[Tooltip("二次谐波振幅")]
|
||||
[Range(0f, 0.5f)]
|
||||
public float harmonicA2 = 0.28f;
|
||||
|
||||
[Tooltip("三次谐波振幅")]
|
||||
[Range(0f, 0.3f)]
|
||||
public float harmonicA3 = 0.12f;
|
||||
|
||||
[Tooltip("二次谐波相位偏移")]
|
||||
[Range(0f, 1f)]
|
||||
public float harmonicPhase2 = 0.3f;
|
||||
|
||||
[Tooltip("三次谐波相位偏移")]
|
||||
[Range(0f, 1f)]
|
||||
public float harmonicPhase3 = 0.5f;
|
||||
|
||||
[Tooltip("整体偏移(负值表示下沉,用于表达压抑感)")]
|
||||
[Range(-0.3f, 0.3f)]
|
||||
public float verticalOffset = -0.08f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -119,6 +119,14 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
private float _jokeWeight = 0.85f;
|
||||
private float _synthesisProgress = 1f;
|
||||
|
||||
// 情绪波形谐波参数(可通过配置调整)
|
||||
private float _harmonicA1 = 0.55f; // 基波振幅
|
||||
private float _harmonicA2 = 0.28f; // 二次谐波振幅
|
||||
private float _harmonicA3 = 0.12f; // 三次谐波振幅
|
||||
private float _harmonicPhase2 = 0.3f; // 二次谐波相位
|
||||
private float _harmonicPhase3 = 0.5f; // 三次谐波相位
|
||||
private float _verticalOffset = -0.08f; // 整体偏移
|
||||
|
||||
public WaveformType WaveType => waveType;
|
||||
public bool IsTransforming => _isTransforming;
|
||||
public float Clarity => clarity;
|
||||
@@ -549,35 +557,27 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
#region 波形生成
|
||||
|
||||
/// <summary>
|
||||
/// 情绪波形 - 低沉、缓慢、压抑、忧伤
|
||||
/// 特征:非常低的频率,缓慢的起伏,像深深的叹息
|
||||
/// 情绪波形 - 基于谐波叠加的简洁实现
|
||||
/// 采用傅里叶合成:基波 + 二次谐波 + 三次谐波
|
||||
/// 特征:低频、缓慢、通过谐波叠加表达悲伤/压抑情绪
|
||||
/// 谐波参数可通过 WaveformSettings 配置
|
||||
/// </summary>
|
||||
private float GenerateEmotionWave(float time, float normalizedX)
|
||||
{
|
||||
// 使用更慢的时间流逝,让波形看起来更低沉
|
||||
float slowTime = time * 0.3f; // 慢3倍
|
||||
float t = slowTime % 1f;
|
||||
if (t < 0) t += 1f;
|
||||
// 慢速时间,体现低沉感
|
||||
float slowTime = time * 0.4f;
|
||||
float t = slowTime * Mathf.PI * 2f; // 基频角度
|
||||
|
||||
// 主波:非常缓慢的正弦波(深沉的悲伤)
|
||||
float sadnessBase = Mathf.Sin(t * Mathf.PI * 2f) * 0.5f;
|
||||
// 傅里叶合成:基波 + 二次谐波 + 三次谐波(使用可配置参数)
|
||||
float fundamental = Mathf.Sin(t) * _harmonicA1; // 基波
|
||||
float harmonic2 = Mathf.Sin(2f * t + _harmonicPhase2) * _harmonicA2; // 二次谐波
|
||||
float harmonic3 = Mathf.Sin(3f * t + _harmonicPhase3) * _harmonicA3; // 三次谐波
|
||||
|
||||
// 第二层:更慢的起伏(压抑感)
|
||||
float depression = Mathf.Sin(t * Mathf.PI * 0.8f) * 0.25f;
|
||||
// 叠加得到最终波形
|
||||
float result = fundamental + harmonic2 + harmonic3;
|
||||
|
||||
// 微弱的焦虑颤抖(但很轻微)
|
||||
float anxiety = Mathf.Sin(t * Mathf.PI * 4f + normalizedX) * 0.1f;
|
||||
anxiety *= (0.5f + Mathf.Sin(t * Mathf.PI) * 0.3f);
|
||||
|
||||
// 组合,整体偏向负值(下沉感)
|
||||
float result = sadnessBase + depression + anxiety - 0.15f;
|
||||
|
||||
// 偶尔的深深叹息
|
||||
if (t > 0.6f && t < 0.8f)
|
||||
{
|
||||
float sigh = -Mathf.Sin((t - 0.6f) / 0.2f * Mathf.PI) * 0.4f;
|
||||
result += sigh;
|
||||
}
|
||||
// 整体偏移(负值表示下沉,体现压抑感)
|
||||
result += _verticalOffset;
|
||||
|
||||
return Mathf.Clamp(result, -1f, 1f);
|
||||
}
|
||||
@@ -733,6 +733,15 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
frequency = settings.frequency;
|
||||
primaryColor = settings.primaryColor;
|
||||
secondaryColor = settings.secondaryColor;
|
||||
|
||||
// 应用谐波配置(用于情绪波形的傅里叶合成)
|
||||
_harmonicA1 = settings.harmonicA1;
|
||||
_harmonicA2 = settings.harmonicA2;
|
||||
_harmonicA3 = settings.harmonicA3;
|
||||
_harmonicPhase2 = settings.harmonicPhase2;
|
||||
_harmonicPhase3 = settings.harmonicPhase3;
|
||||
_verticalOffset = settings.verticalOffset;
|
||||
|
||||
UpdateColors();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,655 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>火山修复流程 - 机械面板原型</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f12;
|
||||
--panel: #141b20;
|
||||
--panel-2: #0f151a;
|
||||
--brass: #b58b4a;
|
||||
--steel: #7d8b97;
|
||||
--glow: #2fe1ff;
|
||||
--warn: #ff8b5a;
|
||||
--ok: #6bf7a9;
|
||||
--text: #cbd5df;
|
||||
--dim: #7a8794;
|
||||
--grid: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(circle at 20% 10%, #111822, #090c10 60%);
|
||||
color: var(--text);
|
||||
font-family: "Consolas", "Courier New", monospace;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: 70px 1fr 160px;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(90deg, #111822, #0f151b);
|
||||
border: 1px solid #1f2a33;
|
||||
box-shadow: inset 0 0 25px rgba(0,0,0,0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
header .title {
|
||||
font-size: 18px;
|
||||
color: var(--glow);
|
||||
text-shadow: 0 0 12px rgba(47,225,255,0.5);
|
||||
}
|
||||
|
||||
header .status {
|
||||
font-size: 13px;
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid #1d2a32;
|
||||
box-shadow: inset 0 0 40px rgba(0,0,0,0.5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-panel {
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 1 / 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background:
|
||||
linear-gradient(0deg, var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
|
||||
radial-gradient(circle at 15% 20%, rgba(255,255,255,0.06), transparent 55%),
|
||||
#0b1116;
|
||||
background-size: 32px 32px, 32px 32px, 100% 100%;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 2 / 3;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meter {
|
||||
padding: 12px;
|
||||
border: 1px solid #25343f;
|
||||
background: var(--panel-2);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meter .label {
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.meter .value {
|
||||
font-size: 20px;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #0c1216;
|
||||
border: 1px solid #24313b;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #2fddff, #6bf7a9);
|
||||
box-shadow: 0 0 10px rgba(47,221,255,0.5);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: linear-gradient(180deg, #2b3741, #1a232b);
|
||||
color: var(--text);
|
||||
border: 1px solid #364450;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.controls button.active {
|
||||
border-color: var(--glow);
|
||||
box-shadow: 0 0 10px rgba(47,225,255,0.5);
|
||||
color: var(--glow);
|
||||
}
|
||||
|
||||
.controls .hint {
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3 / 4;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
background: #0b1116;
|
||||
border: 1px solid #1c2831;
|
||||
}
|
||||
|
||||
.log-line { margin-bottom: 4px; }
|
||||
.log-warn { color: var(--warn); }
|
||||
.log-ok { color: var(--ok); }
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #2f3f4a;
|
||||
background: rgba(9, 13, 16, 0.8);
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.focus-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(9, 12, 15, 0.6);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--glow);
|
||||
font-size: 22px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="frame">
|
||||
<header>
|
||||
<div class="title">VOLCANO REPAIR CONSOLE / LANGUAGE SEPARATION</div>
|
||||
<div class="status" id="systemStatus">SYSTEM: STANDBY</div>
|
||||
</header>
|
||||
|
||||
<section class="panel canvas-panel">
|
||||
<div class="badge">左键:排斥 | 右键:吸引 | 拖动影响粒子</div>
|
||||
<canvas id="scene"></canvas>
|
||||
<div class="focus-overlay" id="focusOverlay">FOCUS SEQUENCE ACTIVE</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel side-panel">
|
||||
<div class="meter">
|
||||
<div class="label">语言整合完成度</div>
|
||||
<div class="value" id="integrationValue">0%</div>
|
||||
<div class="progress-bar"><span id="integrationBar"></span></div>
|
||||
</div>
|
||||
<div class="meter">
|
||||
<div class="label">异常思绪干扰</div>
|
||||
<div class="value" id="interferenceValue">0 处</div>
|
||||
<div class="progress-bar"><span id="interferenceBar"></span></div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="toggleAuto" class="active">自动扩散:开启</button>
|
||||
<button id="focusBtn" disabled>启动聚焦收敛</button>
|
||||
<div class="hint">
|
||||
将红色目标粒子连接并与蓝色粒子分离。<br>
|
||||
达成后启动聚焦流程,释放真实表达。
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="log-panel panel" id="logPanel"></section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById("scene");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const logPanel = document.getElementById("logPanel");
|
||||
const statusEl = document.getElementById("systemStatus");
|
||||
const focusBtn = document.getElementById("focusBtn");
|
||||
const focusOverlay = document.getElementById("focusOverlay");
|
||||
const toggleAuto = document.getElementById("toggleAuto");
|
||||
const integrationValue = document.getElementById("integrationValue");
|
||||
const integrationBar = document.getElementById("integrationBar");
|
||||
const interferenceValue = document.getElementById("interferenceValue");
|
||||
const interferenceBar = document.getElementById("interferenceBar");
|
||||
|
||||
const config = {
|
||||
floatingCount: 40,
|
||||
candidateCount: 60,
|
||||
targetSentence: "我一直用笑话包装害怕,但我想说真话",
|
||||
targetColor: "#ff6c6c",
|
||||
nonTargetColor: "#3ad2ff",
|
||||
propagationColor: "#ffb45a",
|
||||
connectionDistance: 90,
|
||||
minParticleSpacing: 24,
|
||||
separationForce: 1.4,
|
||||
repulsionForce: 120,
|
||||
attractionForce: 80,
|
||||
mouseRadius: 120,
|
||||
autoPropagationInterval: 2.2,
|
||||
autoPropagationCount: 1,
|
||||
propagationDuration: 1.0
|
||||
};
|
||||
|
||||
let particles = [];
|
||||
let floating = [];
|
||||
let targetParticles = [];
|
||||
let effects = [];
|
||||
let allowInput = true;
|
||||
let isCompleted = false;
|
||||
let focusActive = false;
|
||||
let autoPropagation = true;
|
||||
let autoTimer = 0;
|
||||
let lastTime = performance.now();
|
||||
let mouse = { x: 0, y: 0, down: false, button: 0 };
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = canvas.clientHeight * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
function log(text, cls) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "log-line" + (cls ? " " + cls : "");
|
||||
line.textContent = text;
|
||||
logPanel.appendChild(line);
|
||||
logPanel.scrollTop = logPanel.scrollHeight;
|
||||
}
|
||||
|
||||
function randomChar() {
|
||||
const pool = "修复诊断过滤表达恐惧幽默真实焦虑释放";
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
function initParticles() {
|
||||
particles = [];
|
||||
floating = [];
|
||||
targetParticles = [];
|
||||
const targetChars = Array.from(config.targetSentence);
|
||||
const targetCount = Math.min(targetChars.length, config.candidateCount);
|
||||
|
||||
for (let i = 0; i < config.floatingCount; i++) {
|
||||
floating.push({
|
||||
x: Math.random() * canvas.clientWidth,
|
||||
y: Math.random() * canvas.clientHeight,
|
||||
vx: (Math.random() - 0.5) * 20,
|
||||
vy: (Math.random() - 0.5) * 20,
|
||||
text: randomChar(),
|
||||
alpha: 0.15
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < config.candidateCount; i++) {
|
||||
const isTarget = i < targetCount;
|
||||
const text = isTarget ? targetChars[i] : randomChar();
|
||||
const particle = {
|
||||
x: Math.random() * canvas.clientWidth,
|
||||
y: Math.random() * canvas.clientHeight,
|
||||
vx: (Math.random() - 0.5) * 80,
|
||||
vy: (Math.random() - 0.5) * 80,
|
||||
text,
|
||||
isTarget,
|
||||
isStatic: false,
|
||||
calm: false,
|
||||
alpha: 1,
|
||||
connections: []
|
||||
};
|
||||
particles.push(particle);
|
||||
if (isTarget) targetParticles.push(particle);
|
||||
}
|
||||
|
||||
log(">> 系统启动:语言分离模块就绪。");
|
||||
statusEl.textContent = "SYSTEM: RUNNING";
|
||||
}
|
||||
|
||||
function applySeparation(dt) {
|
||||
const minSqr = config.minParticleSpacing * config.minParticleSpacing;
|
||||
for (let i = 0; i < particles.length - 1; i++) {
|
||||
const a = particles[i];
|
||||
if (a.isStatic) continue;
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const b = particles[j];
|
||||
if (b.isStatic) continue;
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const distSqr = dx * dx + dy * dy;
|
||||
if (distSqr > 0.0001 && distSqr < minSqr) {
|
||||
const dist = Math.sqrt(distSqr);
|
||||
const push = (1 - dist / config.minParticleSpacing) * config.separationForce;
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
a.vx += nx * push * dt * 60;
|
||||
a.vy += ny * push * dt * 60;
|
||||
b.vx -= nx * push * dt * 60;
|
||||
b.vy -= ny * push * dt * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyMouseForce(dt) {
|
||||
if (!allowInput || !mouse.down) return;
|
||||
const radius = config.mouseRadius;
|
||||
for (const p of particles) {
|
||||
const dx = p.x - mouse.x;
|
||||
const dy = p.y - mouse.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > 0 && dist < radius) {
|
||||
const strength = (1 - dist / radius);
|
||||
const force = (mouse.button === 2 ? -config.attractionForce : config.repulsionForce);
|
||||
p.vx += (dx / dist) * force * strength * dt;
|
||||
p.vy += (dy / dist) * force * strength * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnections() {
|
||||
particles.forEach(p => p.connections = []);
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const a = particles[i];
|
||||
const b = particles[j];
|
||||
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
if (dist < config.connectionDistance) {
|
||||
a.connections.push(b);
|
||||
b.connections.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function triggerPropagation(source) {
|
||||
effects.push({
|
||||
x: source.x,
|
||||
y: source.y,
|
||||
radius: 0,
|
||||
life: 0
|
||||
});
|
||||
}
|
||||
|
||||
function updateEffects(dt) {
|
||||
effects = effects.filter(effect => {
|
||||
effect.life += dt;
|
||||
effect.radius = 20 + effect.life * 160;
|
||||
return effect.life < config.propagationDuration;
|
||||
});
|
||||
}
|
||||
|
||||
function updateAutoPropagation(dt) {
|
||||
if (!autoPropagation || targetParticles.length === 0) return;
|
||||
autoTimer += dt;
|
||||
if (autoTimer >= config.autoPropagationInterval) {
|
||||
autoTimer = 0;
|
||||
const pick = targetParticles[Math.floor(Math.random() * targetParticles.length)];
|
||||
triggerPropagation(pick);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const ratio = calculateIntegrationRatio();
|
||||
const percent = Math.round(ratio * 100);
|
||||
integrationValue.textContent = percent + "%";
|
||||
integrationBar.style.width = percent + "%";
|
||||
|
||||
const interferenceCount = countInterference();
|
||||
interferenceValue.textContent = interferenceCount + " 处";
|
||||
interferenceBar.style.width = Math.min(100, interferenceCount * 6) + "%";
|
||||
}
|
||||
|
||||
function calculateIntegrationRatio() {
|
||||
if (targetParticles.length === 0) return 0;
|
||||
const visited = new Set();
|
||||
let largest = 0;
|
||||
for (const start of targetParticles) {
|
||||
if (visited.has(start)) continue;
|
||||
let size = 0;
|
||||
const queue = [start];
|
||||
visited.add(start);
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
size++;
|
||||
for (const n of current.connections) {
|
||||
if (!n.isTarget || visited.has(n)) continue;
|
||||
visited.add(n);
|
||||
queue.push(n);
|
||||
}
|
||||
}
|
||||
largest = Math.max(largest, size);
|
||||
}
|
||||
return largest / targetParticles.length;
|
||||
}
|
||||
|
||||
function countInterference() {
|
||||
const interfering = new Set();
|
||||
for (const t of targetParticles) {
|
||||
for (const n of t.connections) {
|
||||
if (!n.isTarget) interfering.add(n);
|
||||
}
|
||||
}
|
||||
return interfering.size;
|
||||
}
|
||||
|
||||
function checkCompletion() {
|
||||
if (isCompleted) return true;
|
||||
if (targetParticles.length === 0) return false;
|
||||
const connected = calculateIntegrationRatio() >= 1;
|
||||
if (!connected) return false;
|
||||
for (const t of targetParticles) {
|
||||
for (const p of particles) {
|
||||
if (p.isTarget) continue;
|
||||
const dist = Math.hypot(t.x - p.x, t.y - p.y);
|
||||
if (dist < config.connectionDistance) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
isCompleted = true;
|
||||
focusBtn.disabled = false;
|
||||
log(">> 目标粒子已连通,干扰隔离完成。", "log-ok");
|
||||
log(">> 可以启动聚焦流程,释放真实表达。", "log-ok");
|
||||
return true;
|
||||
}
|
||||
|
||||
function startFocus() {
|
||||
if (!isCompleted || focusActive) return;
|
||||
focusActive = true;
|
||||
allowInput = false;
|
||||
focusOverlay.style.display = "flex";
|
||||
statusEl.textContent = "SYSTEM: FOCUSING";
|
||||
log(">> 聚焦流程启动:整理真实表达...", "log-ok");
|
||||
|
||||
const centerX = canvas.clientWidth / 2;
|
||||
const centerY = canvas.clientHeight / 2;
|
||||
const spacing = 24;
|
||||
const startX = centerX - (targetParticles.length - 1) * spacing / 2;
|
||||
|
||||
targetParticles.forEach((p, idx) => {
|
||||
p.tx = startX + idx * spacing;
|
||||
p.ty = centerY;
|
||||
p.isStatic = false;
|
||||
});
|
||||
|
||||
particles.forEach(p => {
|
||||
if (!p.isTarget) {
|
||||
p.calm = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFocus(dt) {
|
||||
if (!focusActive) return;
|
||||
let allNear = true;
|
||||
for (const p of targetParticles) {
|
||||
const dx = p.tx - p.x;
|
||||
const dy = p.ty - p.y;
|
||||
p.vx += dx * 0.02;
|
||||
p.vy += dy * 0.02;
|
||||
if (Math.hypot(dx, dy) > 2) allNear = false;
|
||||
}
|
||||
if (allNear) {
|
||||
targetParticles.forEach(p => {
|
||||
p.isStatic = true;
|
||||
p.vx = 0;
|
||||
p.vy = 0;
|
||||
});
|
||||
focusOverlay.style.display = "none";
|
||||
statusEl.textContent = "SYSTEM: FINISHED";
|
||||
log(">> 真实表达已稳定输出。", "log-ok");
|
||||
focusActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
applyMouseForce(dt);
|
||||
applySeparation(dt);
|
||||
updateConnections();
|
||||
updateAutoPropagation(dt);
|
||||
updateEffects(dt);
|
||||
updateStatus();
|
||||
checkCompletion();
|
||||
updateFocus(dt);
|
||||
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
for (const p of particles) {
|
||||
if (p.isStatic) continue;
|
||||
const damping = p.calm ? 0.92 : 0.98;
|
||||
p.vx *= damping;
|
||||
p.vy *= damping;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
|
||||
if (p.x < 10 || p.x > w - 10) p.vx *= -1;
|
||||
if (p.y < 10 || p.y > h - 10) p.vy *= -1;
|
||||
p.x = Math.max(10, Math.min(w - 10, p.x));
|
||||
p.y = Math.max(10, Math.min(h - 10, p.y));
|
||||
}
|
||||
|
||||
for (const f of floating) {
|
||||
f.x += f.vx * dt;
|
||||
f.y += f.vy * dt;
|
||||
if (f.x < 0 || f.x > w) f.vx *= -1;
|
||||
if (f.y < 0 || f.y > h) f.vy *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||
|
||||
// Floating background text
|
||||
ctx.font = "12px Consolas";
|
||||
ctx.fillStyle = "rgba(70, 130, 160, 0.18)";
|
||||
for (const f of floating) {
|
||||
ctx.fillText(f.text, f.x, f.y);
|
||||
}
|
||||
|
||||
// Connection lines
|
||||
for (const p of particles) {
|
||||
for (const n of p.connections) {
|
||||
if (p === n) continue;
|
||||
const isTargetLink = p.isTarget && n.isTarget;
|
||||
ctx.strokeStyle = isTargetLink ? "rgba(255,108,108,0.6)" : "rgba(58,210,255,0.15)";
|
||||
ctx.lineWidth = isTargetLink ? 1.4 : 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(n.x, n.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Propagation rings
|
||||
for (const e of effects) {
|
||||
ctx.strokeStyle = "rgba(255,180,90," + (1 - e.life / config.propagationDuration) + ")";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Particles
|
||||
for (const p of particles) {
|
||||
ctx.fillStyle = p.isTarget ? config.targetColor : config.nonTargetColor;
|
||||
ctx.globalAlpha = p.calm ? 0.25 : 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.isTarget ? 6 : 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = "#e8f2ff";
|
||||
ctx.font = p.isTarget ? "bold 14px Consolas" : "12px Consolas";
|
||||
ctx.fillText(p.text, p.x + 8, p.y - 8);
|
||||
}
|
||||
}
|
||||
|
||||
function loop(now) {
|
||||
const dt = Math.min(0.033, (now - lastTime) / 1000);
|
||||
lastTime = now;
|
||||
update(dt);
|
||||
render();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
canvas.addEventListener("contextmenu", e => e.preventDefault());
|
||||
canvas.addEventListener("mousedown", e => {
|
||||
mouse.down = true;
|
||||
mouse.button = e.button;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
});
|
||||
canvas.addEventListener("mouseup", () => { mouse.down = false; });
|
||||
canvas.addEventListener("mousemove", e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
});
|
||||
|
||||
focusBtn.addEventListener("click", startFocus);
|
||||
toggleAuto.addEventListener("click", () => {
|
||||
autoPropagation = !autoPropagation;
|
||||
toggleAuto.classList.toggle("active", autoPropagation);
|
||||
toggleAuto.textContent = "自动扩散:" + (autoPropagation ? "开启" : "关闭");
|
||||
});
|
||||
|
||||
resize();
|
||||
initParticles();
|
||||
requestAnimationFrame(loop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>情绪可视化 - 选择版本</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #06090d;
|
||||
--fg0: #d7fbe9;
|
||||
--fg1: #99d7b6;
|
||||
--muted: #6f8c7d;
|
||||
--border: rgba(153,215,182,.22);
|
||||
--panel: #0b121a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(1200px 800px at 30% 20%, #0d1a24 0%, var(--bg0) 55%, #04070a 100%);
|
||||
color: var(--fg0);
|
||||
font: 16px/1.6 system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.3em;
|
||||
background: linear-gradient(135deg, #d7fbe9, #99d7b6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
margin-bottom: 3em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(15,26,36,.75), rgba(9,13,18,.6));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--fg1);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--fg0);
|
||||
}
|
||||
.card .tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
margin-bottom: 1em;
|
||||
background: rgba(153,215,182,.15);
|
||||
color: var(--fg1);
|
||||
}
|
||||
.card p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.5em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.card ul {
|
||||
list-style: none;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
.card ul li {
|
||||
padding: 6px 0;
|
||||
color: var(--fg1);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.card ul li::before {
|
||||
content: "→ ";
|
||||
color: var(--muted);
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(80,255,154,0.2), rgba(120,255,190,0.15));
|
||||
border: 1px solid rgba(80,255,154,0.4);
|
||||
color: var(--fg0);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: linear-gradient(135deg, rgba(80,255,154,0.35), rgba(120,255,190,0.25));
|
||||
border-color: rgba(80,255,154,0.7);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
padding-top: 2em;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.footer a {
|
||||
color: var(--fg1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>情绪可视化原型</h1>
|
||||
<p class="subtitle">单滑条驱动 2D 情绪空间(Valence × Arousal),实时渲染情绪变化</p>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<h2>原版:波形示波器</h2>
|
||||
<span class="tag">简洁清晰</span>
|
||||
<p>传统示波器风格,单一波形线条随情绪参数变化。性能极佳,适合快速原型和参数调试。</p>
|
||||
<ul>
|
||||
<li>横向正弦波实时滚动</li>
|
||||
<li>参数化合成(谐波+噪声+尖峰)</li>
|
||||
<li>余辉、扫描线、发光效果</li>
|
||||
<li>RBF 参数混合</li>
|
||||
</ul>
|
||||
<a href="./index.html" class="btn">打开原版</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>电磁场版:形态变换</h2>
|
||||
<span class="tag">视觉冲击</span>
|
||||
<p>四种物理场图案平滑变形,从正弦波演化为环形磁场、磁偶极、涡旋场。更具科技感和情绪表现力。</p>
|
||||
<ul>
|
||||
<li>四种电磁场图案(Wave/Toroidal/Dipole/Vortex)</li>
|
||||
<li>形态 Morphing 混合</li>
|
||||
<li>粒子流动动画</li>
|
||||
<li>情绪色彩映射(蓝→绿→橙→红)</li>
|
||||
</ul>
|
||||
<a href="./index_em.html" class="btn">打开电磁场版</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
<a href="./README.md">原版说明</a> ·
|
||||
<a href="./README_EM.md">电磁场版说明</a> ·
|
||||
<a href="./CHANGELOG.md">更新日志</a> ·
|
||||
<a href="./UNITY_MIGRATION.md">Unity 迁移指南</a>
|
||||
</p>
|
||||
<p style="margin-top: 1em;">两个版本的情绪模型和参数系统完全兼容</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
# 更新日志
|
||||
|
||||
## 电磁场版本(index_em.html)- 2026-02-05
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### 🎨 四种物理场图案
|
||||
|
||||
完全重写渲染系统,实现四种电磁场可视化:
|
||||
|
||||
1. **横向波(HorizontalWave)** - 放松情绪
|
||||
- 保留原版的正弦波基础
|
||||
- 表现平静、规律的状态
|
||||
|
||||
2. **环形磁场(ToroidalField)** - 开心情绪
|
||||
- 粒子沿圆环(toroidal)流动
|
||||
- 对称、有序、富有节奏感
|
||||
- 多层环形叠加产生深度感
|
||||
|
||||
3. **磁偶极场(DipoleField)** - 紧张情绪
|
||||
- 模拟南北两极的磁力线
|
||||
- 不对称、拉扯感、张力
|
||||
- 场线密集交织
|
||||
|
||||
4. **涡旋场(VortexField)** - 压力情绪
|
||||
- 螺旋吸入/喷出的流场
|
||||
- 多层反向涡旋叠加
|
||||
- 混乱、暴烈、高能量
|
||||
|
||||
#### 🔄 形态变换(Morphing)系统
|
||||
|
||||
- **RBF权重混合**:四种图案根据情绪空间 `(valence, arousal)` 动态调整权重
|
||||
- **平滑过渡**:不是"切换"而是"混合",确保拖动滑条时无跳变
|
||||
- **参数低通滤波**:一阶低通(τ=0.15s)消除快速拖动时的突变
|
||||
|
||||
#### 🎨 情绪色彩映射
|
||||
|
||||
- **色相随情绪变化**:
|
||||
- 放松:蓝绿(180°)
|
||||
- 开心:绿青(120-180°)
|
||||
- 紧张:黄橙(40-80°)
|
||||
- 压力:红(0-20°)
|
||||
|
||||
- **亮度随场强变化**:噪声/湍流区域更亮,模拟能量密度
|
||||
|
||||
#### 📊 实时HUD显示
|
||||
|
||||
- 新增"主导模式"指示器,显示当前权重最大的图案名称
|
||||
- 保留原有的 valence、arousal、emotion 显示
|
||||
|
||||
### 技术改进
|
||||
|
||||
- **粒子系统架构**:统一接口设计,易于扩展新图案
|
||||
- **性能优化**:粒子数量随画布尺寸动态调整(300-600点)
|
||||
- **模块化设计**:四个图案生成器完全独立,可单独调试/替换
|
||||
|
||||
### 参数调优
|
||||
|
||||
精心调整四种图案的参数预设,确保:
|
||||
- 放松→开心:从简单到丰富,保持有序感
|
||||
- 开心→紧张:对称性破缺,开始出现不规则
|
||||
- 紧张→压力:从张力到混乱,密度和速度剧增
|
||||
|
||||
### 文件说明
|
||||
|
||||
```
|
||||
prototype/emotion_scope/
|
||||
├── index.html # 原版(单一波形线条)
|
||||
├── index_em.html # 电磁场版本(多图案粒子系统)✨ 新增
|
||||
├── style.css # 共用样式
|
||||
├── README.md # 原版说明
|
||||
├── README_EM.md # 电磁场版本说明 ✨ 新增
|
||||
├── CHANGELOG.md # 本文件 ✨ 新增
|
||||
└── UNITY_MIGRATION.md # Unity 迁移指南
|
||||
```
|
||||
|
||||
## 原版(index.html)- 2026-02-05 初版
|
||||
|
||||
### 核心功能
|
||||
|
||||
- 2D 情绪空间(Valence × Arousal)
|
||||
- 单滑条沿 Catmull-Rom 路径驱动
|
||||
- 参数化波形合成(基波+谐波+噪声+尖峰)
|
||||
- Canvas 渲染(余辉、扫描线、发光描边)
|
||||
- RBF 参数混合
|
||||
|
||||
## 如何选择版本?
|
||||
|
||||
### 使用 `index.html`(原版)如果你:
|
||||
- 需要极简、清晰的波形展示
|
||||
- 优先考虑性能(适合低端设备)
|
||||
- 想要更"传统示波器"的感觉
|
||||
- 需要快速原型验证参数调优
|
||||
|
||||
### 使用 `index_em.html`(电磁场版)如果你:
|
||||
- 想要更酷炫、有视觉冲击力的效果
|
||||
- 需要"物理感"/"科技感"的情绪表达
|
||||
- 愿意接受稍高的计算开销
|
||||
- 希望图案形态差异更直观(不只是波形参数变化)
|
||||
|
||||
## 迁移注意事项
|
||||
|
||||
两个版本的**情绪模型和参数系统完全兼容**,可以:
|
||||
- 在原版里调好参数,直接复制到电磁场版
|
||||
- 反之亦然
|
||||
|
||||
核心接口保持一致:
|
||||
- `emotionPathVA(t)` → `(valence, arousal)`
|
||||
- `rbfWeights(va, sigma)` → 权重数组
|
||||
- 参数向量 `P` 的字段名完全相同
|
||||
@@ -0,0 +1,31 @@
|
||||
# 情绪示波器(Web 原型)
|
||||
|
||||
这个目录是一个**独立的 Web 原型**:用一个滑条驱动 2D 情绪空间(Valence×Arousal),再把情绪映射成“示波器波形”的参数,最终在 Canvas 上渲染出类似示波器的线条,并在放松/开心/紧张/压力之间平滑过渡。
|
||||
|
||||
## 运行方式
|
||||
|
||||
**直接双击 `index.html` 即可在浏览器打开**(JS 已内联,无 CORS 限制)。
|
||||
|
||||
或者用本地静态服务器:
|
||||
|
||||
```bash
|
||||
cd prototype/emotion_scope
|
||||
python -m http.server 5173
|
||||
```
|
||||
|
||||
然后在浏览器打开 `http://localhost:5173/`。
|
||||
|
||||
## 操作
|
||||
|
||||
- 拖动上方 **进度** 滑条:观察波形在放松→开心→紧张→压力之间平滑变形。
|
||||
- 勾选/取消:
|
||||
- **2D RBF 参数混合**:用 (valence, arousal) 对四个锚点参数做 2D 平滑权重混合(更“2D 正统”)。
|
||||
- **余辉**:模拟示波器荧光粉拖影。
|
||||
- **扫描线**:增加一点 CRT/示波器质感。
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `index.html`:UI + Canvas
|
||||
- `style.css`:外观样式
|
||||
- `main.js`:情绪路径、参数混合、波形合成、Canvas 渲染
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 情绪电磁场可视化(Electromagnetic Emotion Field)
|
||||
|
||||
这是电磁场版本的情绪可视化原型,实现了四种物理场图案的平滑变形(morphing)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
**直接双击 `index_em.html` 即可在浏览器打开**(所有代码已内联)。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 四种电磁场图案
|
||||
|
||||
| 图案 | 对应情绪 | 视觉特征 |
|
||||
|------|---------|---------|
|
||||
| **HorizontalWave(横向波)** | 放松 | 传统示波器正弦波,缓慢滚动 |
|
||||
| **ToroidalField(环形磁场)** | 开心 | 粒子沿圆环流动,对称有序 |
|
||||
| **DipoleField(磁偶极场)** | 紧张 | 磁力线交织,不对称张力 |
|
||||
| **VortexField(涡旋场)** | 压力 | 螺旋吸入,混乱暴烈 |
|
||||
|
||||
### 形态变换时间线
|
||||
|
||||
拖动进度条观察:
|
||||
|
||||
- **t = 0.00**(放松):纯正弦波,稀疏平静
|
||||
- **t = 0.20**:波形开始"卷曲",出现圆形轨迹
|
||||
- **t = 0.40**(开心):明显的环形磁场,粒子有序流动
|
||||
- **t = 0.60**:环形拉伸为偶极,出现南北两极
|
||||
- **t = 0.70**(紧张):磁力线密集,不对称抖动
|
||||
- **t = 0.85**:中心形成"黑洞",粒子螺旋吸入
|
||||
- **t = 1.00**(压力):多层涡旋,频繁爆发,极度混乱
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
情绪滑条 t ∈ [0,1]
|
||||
↓
|
||||
情绪空间 (valence, arousal)
|
||||
↓
|
||||
RBF 权重计算 → 四个图案权重 [w₀, w₁, w₂, w₃]
|
||||
↓
|
||||
参数混合 → 统一参数向量 P
|
||||
↓
|
||||
四个图案生成器并行运行
|
||||
↓
|
||||
加权粒子叠加 → 最终渲染
|
||||
```
|
||||
|
||||
### 图案生成器接口
|
||||
|
||||
每个生成器实现统一接口:
|
||||
|
||||
```javascript
|
||||
class Pattern {
|
||||
generate(time, params, width, height) {
|
||||
// 返回粒子数组:[{ x, y, alpha, hue, brightness }, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 权重混合(RBF径向基函数)
|
||||
|
||||
```javascript
|
||||
w_i = exp(-||va - va_i||² / (2σ²))
|
||||
归一化 → Σw_i = 1
|
||||
```
|
||||
|
||||
每个粒子的最终 alpha = 原始alpha × 权重
|
||||
|
||||
### 颜色映射
|
||||
|
||||
- **色相(Hue)**:
|
||||
- Wave: 180°(蓝绿)
|
||||
- Toroidal: 120-180°(绿→青)
|
||||
- Dipole: 40-80°(黄→橙)
|
||||
- Vortex: 0-20°(红→深红)
|
||||
|
||||
- **亮度(Brightness)**:由局部场强/噪声动态控制
|
||||
|
||||
## 与原版对比
|
||||
|
||||
| 特性 | 原版(index.html) | 电磁场版(index_em.html) |
|
||||
|------|-------------------|------------------------|
|
||||
| 渲染方式 | 单一波形线条 | 多图案粒子系统 |
|
||||
| 情绪表达 | 波形参数变化 | 物理场形态变换 |
|
||||
| 视觉复杂度 | 简洁 | 丰富、有层次感 |
|
||||
| 性能 | 极快 | 较快(粒子数可调) |
|
||||
|
||||
## 性能调优
|
||||
|
||||
如需调整性能,修改各生成器内的粒子数量:
|
||||
|
||||
```javascript
|
||||
// HorizontalWave
|
||||
const N = Math.min(600, Math.max(300, Math.floor(width * 0.5)));
|
||||
|
||||
// ToroidalField
|
||||
const numRings = Math.floor(8 + params.cycles * 1.5);
|
||||
const ptsPerRing = Math.floor(40 + params.harmonics * 60);
|
||||
|
||||
// ...依此类推
|
||||
```
|
||||
|
||||
## 后续扩展方向
|
||||
|
||||
- [ ] 加入音频输入驱动(麦克风)
|
||||
- [ ] 支持 2D 手柄/摇杆直接控制 (valence, arousal)
|
||||
- [ ] 可交互:点击画布添加"电荷/磁极"
|
||||
- [ ] WebGL 加速(支持更多粒子)
|
||||
- [ ] 录制/导出为视频
|
||||
|
||||
## Unity 迁移
|
||||
|
||||
核心逻辑(情绪路径、参数混合、图案生成算法)可直接移植到 Unity C#。
|
||||
|
||||
渲染层替换:
|
||||
- 粒子系统 → Unity ParticleSystem 或 VFX Graph
|
||||
- 或用 LineRenderer/Shapes 直接绘制轨迹
|
||||
|
||||
详见 `UNITY_MIGRATION.md`。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 迁移到 Unity 的清单(LineRenderer / Shapes)
|
||||
|
||||
这个 Web 原型的核心分两层:**情绪→参数** 和 **参数→波形点列**。迁移到 Unity 时建议保持这两层不变,仅替换渲染实现。
|
||||
|
||||
## 1) 需要复用的核心逻辑(推荐原样移植)
|
||||
|
||||
- **情绪路径**:单滑条 \(t\in[0,1]\) 输出 `(valence, arousal)`(`emotionPathVA`)
|
||||
- 使用 3 段 Catmull-Rom(端点重复)+ `smootherstep` 缓动
|
||||
- 输出建议 clamp:`valence∈[-1,1]`,`arousal∈[0,1]`
|
||||
- **参数向量 `Params`**:`amp, cycles, harmonics, noise, jitter, fm, spikeRate, spikeAmp, baselineWander, drive`
|
||||
- **2D 混合(可选)**:
|
||||
- **RBF 权重**:`w_i = exp(-||x-x_i||^2 / (2*sigma^2))` 再归一化
|
||||
- 或沿情绪序列分段 `lerp`(实现更简单,视觉也足够)
|
||||
- **波形采样**:`sampleWave(timeSec, params, windowSeconds)`
|
||||
- 基波 + 谐波
|
||||
- AM/FM(用连续噪声/低频 LFO)
|
||||
- 相位抖动(jitter)
|
||||
- 尖峰事件(确定性伪泊松 + 指数衰减)
|
||||
- 软限幅(`tanh` waveshaping)
|
||||
- **连续噪声**:Web 里用 1D value-noise + fbm(不依赖第三方库)
|
||||
- Unity 里可直接移植同样的 hash+插值;或改用 `Mathf.PerlinNoise` 做近似(注意它是 2D)
|
||||
|
||||
## 2) Unity 渲染层替换(LineRenderer)
|
||||
|
||||
### 点列生成(每帧或固定步长)
|
||||
|
||||
- 设定时间窗:`windowSeconds = 2.0f`
|
||||
- 设定采样点数:`N = 800`(也可随屏幕宽度动态变化)
|
||||
- 对每个点 \(x01=i/(N-1)\):
|
||||
- `t = Time.time - windowSeconds * (1 - x01)`
|
||||
- `y = sampleWave(t, params, windowSeconds)`
|
||||
- 映射到世界/本地坐标:`pos = origin + right * (x01 * width) + up * (y * ampPx)`
|
||||
|
||||
### LineRenderer 设置建议
|
||||
|
||||
- `lineRenderer.positionCount = N`
|
||||
- `lineRenderer.useWorldSpace = false`(更易布局在 UI/面板里)
|
||||
- `lineRenderer.numCapVertices = 6`、`numCornerVertices = 6`(更圆润)
|
||||
- **发光/余辉**(两种常用方法):
|
||||
- **URP/HDRP Bloom**:材质开 Emission + 后处理 Bloom(最省事)
|
||||
- **多条叠加**:用 2-3 个 LineRenderer(不同宽度/alpha)模拟 Web 里的 “glow strokes”
|
||||
|
||||
## 3) Unity 渲染层替换(Shapes)
|
||||
|
||||
- 用 Polyline/BezierPolyline(或你项目里 `Shapes` 的折线 API)喂同一组点
|
||||
- 同样可做 “多次描边”(不同宽度/透明度)实现发光层
|
||||
|
||||
## 4) 参数调优建议(让四种情绪差异更直觉)
|
||||
|
||||
- 放松:低 `cycles`、低 `amp`、低 `noise/jitter/spikeRate`
|
||||
- 开心:略高 `cycles` + 更高 `harmonics`(更“弹”更有律动,但仍规则)
|
||||
- 紧张:提高 `jitter/fm` + 适度 `spikeRate`
|
||||
- 压力:最高 `noise/jitter/fm/spikeRate` + 更高 `drive`(软限幅产生压迫感)
|
||||
|
||||
## 5) 需要注意的坑
|
||||
|
||||
- 不要把 `Random.value` 直接加到 y(会变成“毛线团”);用**连续噪声**驱动相位/频率更像仪器信号。
|
||||
- 过渡要对整个 `Params` 向量做平滑:
|
||||
- `params = Lerp(params, target, 1 - exp(-dt/tau))`
|
||||
- Catmull-Rom 可能过冲:建议对 arousal clamp 到 `[0,1]`。
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>情绪示波器原型</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<div class="title__main">情绪示波器</div>
|
||||
<div class="title__sub">单滑条沿 2D 情绪路径(Valence × Arousal)驱动波形变形</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label class="control">
|
||||
<div class="control__label">进度</div>
|
||||
<input id="progress" type="range" min="0" max="1" step="0.0005" value="0" />
|
||||
</label>
|
||||
|
||||
<label class="control control--checkbox">
|
||||
<input id="rbf" type="checkbox" checked />
|
||||
<span>2D RBF 参数混合</span>
|
||||
</label>
|
||||
|
||||
<label class="control control--checkbox">
|
||||
<input id="phosphor" type="checkbox" checked />
|
||||
<span>余辉</span>
|
||||
</label>
|
||||
|
||||
<label class="control control--checkbox">
|
||||
<input id="scanlines" type="checkbox" checked />
|
||||
<span>扫描线</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="scope" class="scope"></canvas>
|
||||
<div class="hud">
|
||||
<div class="hud__row">
|
||||
<div class="hud__kv"><span class="k">t</span><span id="hudT" class="v">0.000</span></div>
|
||||
<div class="hud__kv"><span class="k">valence</span><span id="hudV" class="v">+0.000</span></div>
|
||||
<div class="hud__kv"><span class="k">arousal</span><span id="hudA" class="v">0.000</span></div>
|
||||
<div class="hud__kv"><span class="k">emotion</span><span id="hudE" class="v">放松</span></div>
|
||||
</div>
|
||||
<div class="hud__row hud__row--small">
|
||||
<div class="hud__hint">
|
||||
提示:拖动滑条观察图案从正弦波→环形磁场→磁偶极→涡旋场平滑变形;电磁场流动表达情绪变化。
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud__row">
|
||||
<div class="hud__kv"><span class="k">模式</span><span id="hudPattern" class="v">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
const canvas = $("#scope");
|
||||
const progressEl = $("#progress");
|
||||
const rbfEl = $("#rbf");
|
||||
const phosphorEl = $("#phosphor");
|
||||
const scanlinesEl = $("#scanlines");
|
||||
|
||||
const hudT = $("#hudT");
|
||||
const hudV = $("#hudV");
|
||||
const hudA = $("#hudA");
|
||||
const hudE = $("#hudE");
|
||||
const hudPattern = $("#hudPattern");
|
||||
|
||||
const clamp01 = (t) => Math.min(1, Math.max(0, t));
|
||||
const clamp = (lo, hi, v) => Math.min(hi, Math.max(lo, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const invLerp = (a, b, v) => (v - a) / (b - a);
|
||||
const smootherstep01 = (t) => {
|
||||
t = clamp01(t);
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
};
|
||||
|
||||
const vec2 = (x, y) => ({ x, y });
|
||||
const v2Add = (a, b) => vec2(a.x + b.x, a.y + b.y);
|
||||
const v2Sub = (a, b) => vec2(a.x - b.x, a.y - b.y);
|
||||
const v2Mul = (a, s) => vec2(a.x * s, a.y * s);
|
||||
const v2Len2 = (a) => a.x * a.x + a.y * a.y;
|
||||
|
||||
function catmullRom(p0, p1, p2, p3, t) {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
const a = v2Mul(p1, 2);
|
||||
const b = v2Mul(v2Sub(p2, p0), t);
|
||||
const c = v2Mul(v2Add(v2Sub(v2Mul(p0, 2), v2Mul(p1, 5)), v2Sub(v2Mul(p2, 4), p3)), t2);
|
||||
const d = v2Mul(v2Add(v2Sub(v2Mul(p1, 3), p0), v2Sub(p3, v2Mul(p2, 3))), t3);
|
||||
return v2Mul(v2Add(v2Add(v2Add(a, b), c), d), 0.5);
|
||||
}
|
||||
|
||||
const EmotionAnchors = [
|
||||
{ name: "放松", va: vec2(+0.7, 0.2) },
|
||||
{ name: "开心", va: vec2(+0.8, 0.75) },
|
||||
{ name: "紧张", va: vec2(-0.4, 0.7) },
|
||||
{ name: "压力", va: vec2(-0.7, 0.95) },
|
||||
];
|
||||
|
||||
function emotionPathVA(t01) {
|
||||
const t = smootherstep01(t01);
|
||||
const p = EmotionAnchors.map((e) => e.va);
|
||||
|
||||
const segCount = 3;
|
||||
const s = clamp01(t) * segCount;
|
||||
const seg = Math.min(segCount - 1, Math.floor(s));
|
||||
const u = s - seg;
|
||||
|
||||
let q;
|
||||
if (seg === 0) q = catmullRom(p[0], p[0], p[1], p[2], u);
|
||||
else if (seg === 1) q = catmullRom(p[0], p[1], p[2], p[3], u);
|
||||
else q = catmullRom(p[1], p[2], p[3], p[3], u);
|
||||
|
||||
return vec2(clamp(-1, 1, q.x), clamp01(q.y));
|
||||
}
|
||||
|
||||
function nearestEmotionName(va) {
|
||||
let best = EmotionAnchors[0].name;
|
||||
let bestD2 = Infinity;
|
||||
for (const e of EmotionAnchors) {
|
||||
const d2 = v2Len2(v2Sub(va, e.va));
|
||||
if (d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
best = e.name;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const ParamAnchors = [
|
||||
{
|
||||
name: "放松",
|
||||
va: EmotionAnchors[0].va,
|
||||
p: {
|
||||
amp: 0.22,
|
||||
cycles: 5.0,
|
||||
harmonics: 0.12,
|
||||
noise: 0.02,
|
||||
jitter: 0.02,
|
||||
fm: 0.05,
|
||||
spikeRate: 0.05,
|
||||
spikeAmp: 0.20,
|
||||
baselineWander: 0.03,
|
||||
drive: 0.60,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "开心",
|
||||
va: EmotionAnchors[1].va,
|
||||
p: {
|
||||
amp: 0.32,
|
||||
cycles: 6.4,
|
||||
harmonics: 0.35,
|
||||
noise: 0.04,
|
||||
jitter: 0.04,
|
||||
fm: 0.10,
|
||||
spikeRate: 0.08,
|
||||
spikeAmp: 0.22,
|
||||
baselineWander: 0.025,
|
||||
drive: 0.70,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "紧张",
|
||||
va: EmotionAnchors[2].va,
|
||||
p: {
|
||||
amp: 0.40,
|
||||
cycles: 8.4,
|
||||
harmonics: 0.22,
|
||||
noise: 0.10,
|
||||
jitter: 0.18,
|
||||
fm: 0.30,
|
||||
spikeRate: 0.45,
|
||||
spikeAmp: 0.55,
|
||||
baselineWander: 0.02,
|
||||
drive: 1.10,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "压力",
|
||||
va: EmotionAnchors[3].va,
|
||||
p: {
|
||||
amp: 0.55,
|
||||
cycles: 10.5,
|
||||
harmonics: 0.20,
|
||||
noise: 0.20,
|
||||
jitter: 0.35,
|
||||
fm: 0.55,
|
||||
spikeRate: 0.90,
|
||||
spikeAmp: 0.90,
|
||||
baselineWander: 0.015,
|
||||
drive: 1.70,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function rbfWeights(va, sigma) {
|
||||
const s2 = Math.max(1e-6, sigma * sigma);
|
||||
const w = [];
|
||||
let sum = 0;
|
||||
for (const a of ParamAnchors) {
|
||||
const d2 = v2Len2(v2Sub(va, a.va));
|
||||
const wi = Math.exp(-d2 / (2 * s2));
|
||||
w.push(wi);
|
||||
sum += wi;
|
||||
}
|
||||
if (sum <= 1e-9) return w.map(() => 1 / w.length);
|
||||
return w.map((x) => x / sum);
|
||||
}
|
||||
|
||||
function mixParams(ps, w) {
|
||||
const out = {
|
||||
amp: 0,
|
||||
cycles: 0,
|
||||
harmonics: 0,
|
||||
noise: 0,
|
||||
jitter: 0,
|
||||
fm: 0,
|
||||
spikeRate: 0,
|
||||
spikeAmp: 0,
|
||||
baselineWander: 0,
|
||||
drive: 0,
|
||||
};
|
||||
for (let i = 0; i < ps.length; i++) {
|
||||
const p = ps[i];
|
||||
const wi = w[i];
|
||||
out.amp += p.amp * wi;
|
||||
out.cycles += p.cycles * wi;
|
||||
out.harmonics += p.harmonics * wi;
|
||||
out.noise += p.noise * wi;
|
||||
out.jitter += p.jitter * wi;
|
||||
out.fm += p.fm * wi;
|
||||
out.spikeRate += p.spikeRate * wi;
|
||||
out.spikeAmp += p.spikeAmp * wi;
|
||||
out.baselineWander += p.baselineWander * wi;
|
||||
out.drive += p.drive * wi;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lerpParams(a, b, t) {
|
||||
return {
|
||||
amp: lerp(a.amp, b.amp, t),
|
||||
cycles: lerp(a.cycles, b.cycles, t),
|
||||
harmonics: lerp(a.harmonics, b.harmonics, t),
|
||||
noise: lerp(a.noise, b.noise, t),
|
||||
jitter: lerp(a.jitter, b.jitter, t),
|
||||
fm: lerp(a.fm, b.fm, t),
|
||||
spikeRate: lerp(a.spikeRate, b.spikeRate, t),
|
||||
spikeAmp: lerp(a.spikeAmp, b.spikeAmp, t),
|
||||
baselineWander: lerp(a.baselineWander, b.baselineWander, t),
|
||||
drive: lerp(a.drive, b.drive, t),
|
||||
};
|
||||
}
|
||||
|
||||
function segmentMixParams(t01) {
|
||||
const t = smootherstep01(t01);
|
||||
const segCount = 3;
|
||||
const s = clamp01(t) * segCount;
|
||||
const seg = Math.min(segCount - 1, Math.floor(s));
|
||||
const u = smootherstep01(s - seg);
|
||||
const ps = ParamAnchors.map((a) => a.p);
|
||||
if (seg === 0) return lerpParams(ps[0], ps[1], u);
|
||||
if (seg === 1) return lerpParams(ps[1], ps[2], u);
|
||||
return lerpParams(ps[2], ps[3], u);
|
||||
}
|
||||
|
||||
const fract = (x) => x - Math.floor(x);
|
||||
const hash1 = (n) => fract(Math.sin(n * 127.1) * 43758.5453123);
|
||||
const fade = (t) => t * t * (3 - 2 * t);
|
||||
|
||||
function valueNoise1(x) {
|
||||
const i = Math.floor(x);
|
||||
const f = x - i;
|
||||
const a = hash1(i);
|
||||
const b = hash1(i + 1);
|
||||
return lerp(a, b, fade(f));
|
||||
}
|
||||
|
||||
function fbm1(x) {
|
||||
let sum = 0;
|
||||
let amp = 0.5;
|
||||
let freq = 1.0;
|
||||
for (let o = 0; o < 4; o++) {
|
||||
sum += amp * (valueNoise1(x * freq) * 2 - 1);
|
||||
freq *= 2.02;
|
||||
amp *= 0.5;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
function hash2(n, salt) {
|
||||
return fract(Math.sin((n + salt) * 311.7) * 95123.317);
|
||||
}
|
||||
|
||||
function spikeSignal(tSec, ratePerSec, amp) {
|
||||
const cell = 0.12;
|
||||
const decayTau = 0.06;
|
||||
const idx = Math.floor(tSec / cell);
|
||||
const p = clamp01(ratePerSec * cell);
|
||||
|
||||
let s = 0;
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const c = idx - k;
|
||||
const r = hash2(c, 0.13);
|
||||
if (r < p) {
|
||||
const within = hash2(c, 2.71);
|
||||
const spikeT = (c + within) * cell;
|
||||
const dt = tSec - spikeT;
|
||||
if (dt >= 0 && dt < 0.35) {
|
||||
const polarity = hash2(c, 9.91) < 0.82 ? 1 : -1;
|
||||
s += polarity * amp * Math.exp(-dt / decayTau);
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
function softClipTanh(x, drive) {
|
||||
const d = Math.max(1e-4, drive);
|
||||
const den = Math.tanh(d);
|
||||
if (den < 1e-6) return x;
|
||||
return Math.tanh(d * x) / den;
|
||||
}
|
||||
|
||||
function sampleWave(tSec, p, windowSeconds) {
|
||||
const baseHz = Math.max(0.05, p.cycles / Math.max(0.1, windowSeconds));
|
||||
|
||||
const lfo = Math.sin(TAU * (0.45 + 0.75 * p.fm) * tSec + 1.3 * fbm1(tSec * 0.21));
|
||||
const am = 1 + (0.18 + 0.18 * p.harmonics) * lfo;
|
||||
|
||||
const fmN = fbm1(tSec * (1.2 + 1.4 * p.fm) + 10.0);
|
||||
const jitterN = fbm1(tSec * (7.0 + 7.0 * p.jitter) + 33.3);
|
||||
|
||||
let phase = TAU * baseHz * tSec;
|
||||
phase += (p.fm * 2.7) * fmN;
|
||||
phase += (p.jitter * 2.2) * jitterN;
|
||||
|
||||
const h2 = p.harmonics * 0.65;
|
||||
const h3 = p.harmonics * 0.40;
|
||||
const h5 = p.harmonics * 0.16;
|
||||
|
||||
const core =
|
||||
Math.sin(phase) +
|
||||
h2 * Math.sin(2 * phase + 0.12) +
|
||||
h3 * Math.sin(3 * phase + 0.33) +
|
||||
h5 * Math.sin(5 * phase + 0.81);
|
||||
|
||||
const baseline = p.baselineWander * fbm1(tSec * 0.22 + 99.0);
|
||||
const noiseTerm = p.noise * 0.55 * fbm1(tSec * 12.0 + 7.0);
|
||||
const spikes = spikeSignal(tSec, p.spikeRate, p.spikeAmp);
|
||||
|
||||
const yRaw = p.amp * am * core + baseline + noiseTerm + spikes;
|
||||
return softClipTanh(yRaw, p.drive);
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
let dpr = 1;
|
||||
let cw = 0;
|
||||
let ch = 0;
|
||||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dpr = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1));
|
||||
cw = Math.max(2, Math.floor(rect.width * dpr));
|
||||
ch = Math.max(2, Math.floor(rect.height * dpr));
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", resizeCanvas, { passive: true });
|
||||
resizeCanvas();
|
||||
|
||||
function drawScanlines(w, h) {
|
||||
const step = Math.max(2, Math.floor(4 * dpr));
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = "rgba(190,255,225,0.045)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let y = 0; y < h; y += step) {
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(w, y + 0.5);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawGrid(w, h) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = "rgba(120,255,190,0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
const vLines = 10;
|
||||
const hLines = 6;
|
||||
for (let i = 1; i < vLines; i++) {
|
||||
const x = (i / vLines) * w;
|
||||
ctx.moveTo(x + 0.5, 0);
|
||||
ctx.lineTo(x + 0.5, h);
|
||||
}
|
||||
for (let j = 1; j < hLines; j++) {
|
||||
const y = (j / hLines) * h;
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(w, y + 0.5);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderScope(timeSec, p, opts) {
|
||||
const w = cw;
|
||||
const h = ch;
|
||||
const midY = h * 0.5;
|
||||
const ampPx = h * 0.33;
|
||||
|
||||
if (opts.phosphor) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.16)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
drawGrid(w, h);
|
||||
if (opts.scanlines) drawScanlines(w, h);
|
||||
|
||||
const windowSeconds = 2.0;
|
||||
const N = Math.min(1400, Math.max(480, Math.floor(w * 0.75)));
|
||||
|
||||
const xs = new Array(N);
|
||||
const ys = new Array(N);
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x01 = i / (N - 1);
|
||||
const t = timeSec - windowSeconds * (1 - x01);
|
||||
const y = sampleWave(t, p, windowSeconds);
|
||||
|
||||
xs[i] = x01 * w;
|
||||
ys[i] = midY - y * ampPx;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
const strokes = [
|
||||
{ width: 6.5 * dpr, alpha: 0.055, color: "rgba(80,255,154,1)" },
|
||||
{ width: 3.2 * dpr, alpha: 0.11, color: "rgba(120,255,190,1)" },
|
||||
{ width: 1.35 * dpr, alpha: 0.90, color: "rgba(190,255,225,1)" },
|
||||
];
|
||||
|
||||
for (const s of strokes) {
|
||||
ctx.globalAlpha = s.alpha;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = s.width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xs[0], ys[0]);
|
||||
for (let i = 1; i < N; i++) ctx.lineTo(xs[i], ys[i]);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.10;
|
||||
ctx.strokeStyle = "rgba(190,255,225,1)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, midY + 0.5);
|
||||
ctx.lineTo(w, midY + 0.5);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
let start = performance.now();
|
||||
let last = start;
|
||||
|
||||
let pCurrent = { ...ParamAnchors[0].p };
|
||||
|
||||
function tick(now) {
|
||||
const dt = Math.min(0.05, Math.max(0.0001, (now - last) / 1000));
|
||||
last = now;
|
||||
|
||||
const t01 = clamp01(parseFloat(progressEl.value));
|
||||
const va = emotionPathVA(t01);
|
||||
|
||||
let pTarget;
|
||||
if (rbfEl.checked) {
|
||||
const w = rbfWeights(va, 0.62);
|
||||
pTarget = mixParams(
|
||||
ParamAnchors.map((a) => a.p),
|
||||
w
|
||||
);
|
||||
} else {
|
||||
pTarget = segmentMixParams(t01);
|
||||
}
|
||||
|
||||
const tau = 0.12;
|
||||
const k = 1 - Math.exp(-dt / Math.max(1e-4, tau));
|
||||
pCurrent = lerpParams(pCurrent, pTarget, k);
|
||||
|
||||
hudT.textContent = t01.toFixed(3);
|
||||
hudV.textContent = (va.x >= 0 ? "+" : "") + va.x.toFixed(3);
|
||||
hudA.textContent = va.y.toFixed(3);
|
||||
hudE.textContent = nearestEmotionName(va);
|
||||
|
||||
if (canvas.width !== cw || canvas.height !== ch) resizeCanvas();
|
||||
renderScope((now - start) / 1000, pCurrent, {
|
||||
phosphor: phosphorEl.checked,
|
||||
scanlines: scanlinesEl.checked,
|
||||
});
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
requestAnimationFrame(tick);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,518 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>情绪电磁场可视化</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<div class="title__main">情绪电磁场</div>
|
||||
<div class="title__sub">单滑条驱动形态变换:正弦波 → 环形磁场 → 磁偶极 → 涡旋</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label class="control">
|
||||
<div class="control__label">进度</div>
|
||||
<input id="progress" type="range" min="0" max="1" step="0.0005" value="0" />
|
||||
</label>
|
||||
|
||||
<label class="control control--checkbox">
|
||||
<input id="phosphor" type="checkbox" checked />
|
||||
<span>余辉</span>
|
||||
</label>
|
||||
|
||||
<label class="control control--checkbox">
|
||||
<input id="scanlines" type="checkbox" checked />
|
||||
<span>扫描线</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="scope" class="scope"></canvas>
|
||||
<div class="hud">
|
||||
<div class="hud__row">
|
||||
<div class="hud__kv"><span class="k">t</span><span id="hudT" class="v">0.000</span></div>
|
||||
<div class="hud__kv"><span class="k">valence</span><span id="hudV" class="v">+0.000</span></div>
|
||||
<div class="hud__kv"><span class="k">arousal</span><span id="hudA" class="v">0.000</span></div>
|
||||
<div class="hud__kv"><span class="k">emotion</span><span id="hudE" class="v">放松</span></div>
|
||||
</div>
|
||||
<div class="hud__row">
|
||||
<div class="hud__kv"><span class="k">主导模式</span><span id="hudPattern" class="v">Wave</span></div>
|
||||
</div>
|
||||
<div class="hud__row hud__row--small">
|
||||
<div class="hud__hint">
|
||||
拖动滑条观察电磁场形态从正弦波平滑变形为环形→偶极→涡旋,表达情绪从放松到压力的演化
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const canvas = $("#scope");
|
||||
const progressEl = $("#progress");
|
||||
const phosphorEl = $("#phosphor");
|
||||
const scanlinesEl = $("#scanlines");
|
||||
const hudT = $("#hudT");
|
||||
const hudV = $("#hudV");
|
||||
const hudA = $("#hudA");
|
||||
const hudE = $("#hudE");
|
||||
const hudPattern = $("#hudPattern");
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const clamp01 = (t) => Math.min(1, Math.max(0, t));
|
||||
const clamp = (lo, hi, v) => Math.min(hi, Math.max(lo, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const smootherstep01 = (t) => {
|
||||
t = clamp01(t);
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
};
|
||||
|
||||
const vec2 = (x, y) => ({ x, y });
|
||||
const v2Add = (a, b) => vec2(a.x + b.x, a.y + b.y);
|
||||
const v2Sub = (a, b) => vec2(a.x - b.x, a.y - b.y);
|
||||
const v2Mul = (a, s) => vec2(a.x * s, a.y * s);
|
||||
const v2Len = (a) => Math.sqrt(a.x * a.x + a.y * a.y);
|
||||
const v2Len2 = (a) => a.x * a.x + a.y * a.y;
|
||||
const v2Norm = (a) => { const l = v2Len(a); return l > 1e-9 ? v2Mul(a, 1/l) : vec2(0, 0); };
|
||||
|
||||
function catmullRom(p0, p1, p2, p3, t) {
|
||||
const t2 = t * t, t3 = t2 * t;
|
||||
const a = v2Mul(p1, 2);
|
||||
const b = v2Mul(v2Sub(p2, p0), t);
|
||||
const c = v2Mul(v2Add(v2Sub(v2Mul(p0, 2), v2Mul(p1, 5)), v2Sub(v2Mul(p2, 4), p3)), t2);
|
||||
const d = v2Mul(v2Add(v2Sub(v2Mul(p1, 3), p0), v2Sub(p3, v2Mul(p2, 3))), t3);
|
||||
return v2Mul(v2Add(v2Add(v2Add(a, b), c), d), 0.5);
|
||||
}
|
||||
|
||||
// 情绪锚点
|
||||
const EmotionAnchors = [
|
||||
{ name: "放松", va: vec2(+0.7, 0.2) },
|
||||
{ name: "开心", va: vec2(+0.8, 0.75) },
|
||||
{ name: "紧张", va: vec2(-0.4, 0.7) },
|
||||
{ name: "压力", va: vec2(-0.7, 0.95) },
|
||||
];
|
||||
|
||||
function emotionPathVA(t01) {
|
||||
const t = smootherstep01(t01);
|
||||
const p = EmotionAnchors.map((e) => e.va);
|
||||
const segCount = 3;
|
||||
const s = clamp01(t) * segCount;
|
||||
const seg = Math.min(segCount - 1, Math.floor(s));
|
||||
const u = s - seg;
|
||||
let q;
|
||||
if (seg === 0) q = catmullRom(p[0], p[0], p[1], p[2], u);
|
||||
else if (seg === 1) q = catmullRom(p[0], p[1], p[2], p[3], u);
|
||||
else q = catmullRom(p[1], p[2], p[3], p[3], u);
|
||||
return vec2(clamp(-1, 1, q.x), clamp01(q.y));
|
||||
}
|
||||
|
||||
function nearestEmotionName(va) {
|
||||
let best = EmotionAnchors[0].name;
|
||||
let bestD2 = Infinity;
|
||||
for (const e of EmotionAnchors) {
|
||||
const d2 = v2Len2(v2Sub(va, e.va));
|
||||
if (d2 < bestD2) { bestD2 = d2; best = e.name; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// 连续噪声
|
||||
const fract = (x) => x - Math.floor(x);
|
||||
const hash1 = (n) => fract(Math.sin(n * 127.1) * 43758.5453123);
|
||||
const fade = (t) => t * t * (3 - 2 * t);
|
||||
function valueNoise1(x) {
|
||||
const i = Math.floor(x), f = x - i;
|
||||
return lerp(hash1(i), hash1(i + 1), fade(f));
|
||||
}
|
||||
function fbm1(x) {
|
||||
let sum = 0, amp = 0.5, freq = 1.0;
|
||||
for (let o = 0; o < 4; o++) {
|
||||
sum += amp * (valueNoise1(x * freq) * 2 - 1);
|
||||
freq *= 2.02; amp *= 0.5;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
function hash2(n, salt) {
|
||||
return fract(Math.sin((n + salt) * 311.7) * 95123.317);
|
||||
}
|
||||
|
||||
// ========== 四种图案生成器 ==========
|
||||
|
||||
class HorizontalWave {
|
||||
generate(time, params, width, height) {
|
||||
const particles = [];
|
||||
const windowSeconds = 2.0;
|
||||
const N = Math.min(600, Math.max(300, Math.floor(width * 0.5)));
|
||||
const midY = height * 0.5;
|
||||
const ampPx = height * 0.3;
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x01 = i / (N - 1);
|
||||
const t = time - windowSeconds * (1 - x01);
|
||||
const y = this._sampleWave(t, params, windowSeconds);
|
||||
|
||||
particles.push({
|
||||
x: x01 * width,
|
||||
y: midY - y * ampPx,
|
||||
alpha: 0.8,
|
||||
hue: 180,
|
||||
brightness: 0.7
|
||||
});
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
|
||||
_sampleWave(tSec, p, windowSeconds) {
|
||||
const baseHz = Math.max(0.05, p.cycles / Math.max(0.1, windowSeconds));
|
||||
const lfo = Math.sin(TAU * (0.45 + 0.75 * p.fm) * tSec + 1.3 * fbm1(tSec * 0.21));
|
||||
const am = 1 + (0.18 + 0.18 * p.harmonics) * lfo;
|
||||
const fmN = fbm1(tSec * (1.2 + 1.4 * p.fm) + 10.0);
|
||||
const jitterN = fbm1(tSec * (7.0 + 7.0 * p.jitter) + 33.3);
|
||||
|
||||
let phase = TAU * baseHz * tSec;
|
||||
phase += (p.fm * 2.7) * fmN + (p.jitter * 2.2) * jitterN;
|
||||
|
||||
const h2 = p.harmonics * 0.65, h3 = p.harmonics * 0.40, h5 = p.harmonics * 0.16;
|
||||
const core = Math.sin(phase) + h2 * Math.sin(2 * phase + 0.12) +
|
||||
h3 * Math.sin(3 * phase + 0.33) + h5 * Math.sin(5 * phase + 0.81);
|
||||
|
||||
const baseline = p.baselineWander * fbm1(tSec * 0.22 + 99.0);
|
||||
const noiseTerm = p.noise * 0.55 * fbm1(tSec * 12.0 + 7.0);
|
||||
const yRaw = p.amp * am * core + baseline + noiseTerm;
|
||||
|
||||
const d = Math.max(1e-4, p.drive);
|
||||
return Math.tanh(d * yRaw) / Math.tanh(d);
|
||||
}
|
||||
}
|
||||
|
||||
class ToroidalField {
|
||||
generate(time, params, width, height) {
|
||||
const particles = [];
|
||||
const cx = width * 0.5, cy = height * 0.5;
|
||||
const R = Math.min(width, height) * 0.28;
|
||||
const r = R * (0.15 + 0.1 * params.harmonics);
|
||||
|
||||
const numRings = Math.floor(8 + params.cycles * 1.5);
|
||||
const ptsPerRing = Math.floor(40 + params.harmonics * 60);
|
||||
const flowSpeed = 0.3 + params.fm * 0.8;
|
||||
|
||||
for (let ring = 0; ring < numRings; ring++) {
|
||||
const phi0 = (ring / numRings) * TAU + time * flowSpeed;
|
||||
|
||||
for (let i = 0; i < ptsPerRing; i++) {
|
||||
const theta = (i / ptsPerRing) * TAU;
|
||||
const phi = phi0 + fbm1(theta * 3 + time * 0.5) * params.jitter * 0.5;
|
||||
|
||||
const rx = R + r * Math.cos(phi);
|
||||
const x = cx + rx * Math.cos(theta);
|
||||
const y = cy + rx * Math.sin(theta);
|
||||
|
||||
const noise = fbm1(theta * 5 + phi * 3 + time);
|
||||
const brightness = 0.5 + 0.4 * Math.abs(noise) + 0.3 * params.harmonics;
|
||||
|
||||
particles.push({
|
||||
x, y,
|
||||
alpha: 0.3 + 0.4 * (1 - ring / numRings),
|
||||
hue: 120 + params.fm * 60,
|
||||
brightness: clamp01(brightness)
|
||||
});
|
||||
}
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
}
|
||||
|
||||
class DipoleField {
|
||||
generate(time, params, width, height) {
|
||||
const particles = [];
|
||||
const cx = width * 0.5, cy = height * 0.5;
|
||||
const d = Math.min(width, height) * (0.15 + 0.15 * params.arousal);
|
||||
|
||||
const numLines = Math.floor(18 + params.cycles * 3);
|
||||
const ptsPerLine = Math.floor(60 + params.jitter * 40);
|
||||
|
||||
for (let line = 0; line < numLines; line++) {
|
||||
const angle = (line / numLines) * Math.PI;
|
||||
const asymmetry = params.jitter * fbm1(time * 0.3 + line);
|
||||
|
||||
for (let i = 0; i < ptsPerLine; i++) {
|
||||
const t = (i / ptsPerLine) * 2 - 1;
|
||||
const r = d * (0.3 + Math.abs(t) * 2.5);
|
||||
const theta = angle + asymmetry * 0.3;
|
||||
|
||||
const fieldFactor = 1 / Math.max(0.1, Math.pow(Math.abs(r), 0.7));
|
||||
const curve = Math.sin(theta) * fieldFactor * 0.08 * params.fm;
|
||||
|
||||
const noise = fbm1(t * 10 + time + line) * params.noise * 0.3;
|
||||
const x = cx + r * Math.cos(theta) + curve * width + noise * width * 0.05;
|
||||
const y = cy + r * Math.sin(theta) * (1 + t * 0.5) + noise * height * 0.05;
|
||||
|
||||
const brightness = clamp01(0.4 + fieldFactor * 0.3 + Math.abs(noise) * 2);
|
||||
|
||||
particles.push({
|
||||
x, y,
|
||||
alpha: 0.4 + 0.3 * (1 - Math.abs(t)),
|
||||
hue: 40 + params.arousal * 40,
|
||||
brightness
|
||||
});
|
||||
}
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
}
|
||||
|
||||
class VortexField {
|
||||
generate(time, params, width, height) {
|
||||
const particles = [];
|
||||
const cx = width * 0.5, cy = height * 0.5;
|
||||
const maxR = Math.min(width, height) * 0.45;
|
||||
|
||||
const numSpirals = Math.floor(5 + params.cycles * 0.8);
|
||||
const ptsPerSpiral = Math.floor(80 + params.jitter * 60);
|
||||
const vorticity = 2.5 + params.fm * 3;
|
||||
const inflow = -0.3 - params.arousal * 0.5;
|
||||
|
||||
for (let spiral = 0; spiral < numSpirals; spiral++) {
|
||||
const phase = (spiral / numSpirals) * TAU;
|
||||
|
||||
for (let i = 0; i < ptsPerSpiral; i++) {
|
||||
const t = i / ptsPerSpiral;
|
||||
const r = maxR * Math.pow(t, 0.7);
|
||||
const theta = phase + vorticity * Math.log(1 + r / 10) + time * (1 + spiral * 0.2);
|
||||
|
||||
const turbulence = fbm1(r * 0.05 + theta * 2 + time * 0.5) * params.noise;
|
||||
const burst = hash2(Math.floor(time * 10 + spiral), 0.5) < params.spikeRate * 0.01 ?
|
||||
Math.exp(-((time * 10) % 1) * 5) * 0.5 : 0;
|
||||
|
||||
const rFinal = r * (1 + turbulence * 0.3 + burst);
|
||||
const x = cx + rFinal * Math.cos(theta);
|
||||
const y = cy + rFinal * Math.sin(theta);
|
||||
|
||||
const brightness = clamp01(0.6 + Math.abs(turbulence) * 2 + burst * 3);
|
||||
|
||||
particles.push({
|
||||
x, y,
|
||||
alpha: 0.5 * (1 - t * 0.7) + burst,
|
||||
hue: 0 + params.drive * 10,
|
||||
brightness
|
||||
});
|
||||
}
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 参数系统 ==========
|
||||
const PatternAnchors = [
|
||||
{
|
||||
name: "Wave",
|
||||
va: EmotionAnchors[0].va,
|
||||
p: { amp: 0.22, cycles: 5.0, harmonics: 0.12, noise: 0.02, jitter: 0.02,
|
||||
fm: 0.05, spikeRate: 0.05, spikeAmp: 0.20, baselineWander: 0.03,
|
||||
drive: 0.60, arousal: 0.2 }
|
||||
},
|
||||
{
|
||||
name: "Toroidal",
|
||||
va: EmotionAnchors[1].va,
|
||||
p: { amp: 0.32, cycles: 6.4, harmonics: 0.35, noise: 0.04, jitter: 0.04,
|
||||
fm: 0.10, spikeRate: 0.08, spikeAmp: 0.22, baselineWander: 0.025,
|
||||
drive: 0.70, arousal: 0.75 }
|
||||
},
|
||||
{
|
||||
name: "Dipole",
|
||||
va: EmotionAnchors[2].va,
|
||||
p: { amp: 0.40, cycles: 8.4, harmonics: 0.22, noise: 0.10, jitter: 0.18,
|
||||
fm: 0.30, spikeRate: 0.45, spikeAmp: 0.55, baselineWander: 0.02,
|
||||
drive: 1.10, arousal: 0.7 }
|
||||
},
|
||||
{
|
||||
name: "Vortex",
|
||||
va: EmotionAnchors[3].va,
|
||||
p: { amp: 0.55, cycles: 10.5, harmonics: 0.20, noise: 0.20, jitter: 0.35,
|
||||
fm: 0.55, spikeRate: 0.90, spikeAmp: 0.90, baselineWander: 0.015,
|
||||
drive: 1.70, arousal: 0.95 }
|
||||
},
|
||||
];
|
||||
|
||||
function rbfWeights(va, sigma) {
|
||||
const s2 = Math.max(1e-6, sigma * sigma);
|
||||
const w = [], sum = PatternAnchors.reduce((s, a) => {
|
||||
const wi = Math.exp(-v2Len2(v2Sub(va, a.va)) / (2 * s2));
|
||||
w.push(wi);
|
||||
return s + wi;
|
||||
}, 0);
|
||||
return sum <= 1e-9 ? w.map(() => 1 / w.length) : w.map(x => x / sum);
|
||||
}
|
||||
|
||||
function lerpParams(a, b, t) {
|
||||
const out = {};
|
||||
for (const k in a) out[k] = lerp(a[k], b[k], t);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ========== 渲染系统 ==========
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
let dpr = 1, cw = 0, ch = 0;
|
||||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dpr = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1));
|
||||
cw = Math.max(2, Math.floor(rect.width * dpr));
|
||||
ch = Math.max(2, Math.floor(rect.height * dpr));
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
}
|
||||
window.addEventListener("resize", resizeCanvas, { passive: true });
|
||||
resizeCanvas();
|
||||
|
||||
function drawGrid(w, h) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(120,255,190,0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
const vLines = 10, hLines = 6;
|
||||
for (let i = 1; i < vLines; i++) {
|
||||
const x = (i / vLines) * w;
|
||||
ctx.moveTo(x + 0.5, 0);
|
||||
ctx.lineTo(x + 0.5, h);
|
||||
}
|
||||
for (let j = 1; j < hLines; j++) {
|
||||
const y = (j / hLines) * h;
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(w, y + 0.5);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawScanlines(w, h) {
|
||||
const step = Math.max(2, Math.floor(4 * dpr));
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(190,255,225,0.045)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let y = 0; y < h; y += step) {
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(w, y + 0.5);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderParticles(particles, opts) {
|
||||
const w = cw, h = ch;
|
||||
|
||||
if (opts.phosphor) {
|
||||
ctx.fillStyle = "rgba(0,0,0,0.12)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
drawGrid(w, h);
|
||||
if (opts.scanlines) drawScanlines(w, h);
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
// 按alpha排序,先画透明的
|
||||
particles.sort((a, b) => a.alpha - b.alpha);
|
||||
|
||||
for (const p of particles) {
|
||||
const sat = clamp(30, 90, 50 + p.brightness * 40);
|
||||
const light = clamp(30, 95, 40 + p.brightness * 55);
|
||||
const color = `hsl(${p.hue}, ${sat}%, ${light}%)`;
|
||||
|
||||
ctx.globalAlpha = p.alpha * 0.8;
|
||||
ctx.fillStyle = color;
|
||||
const size = (1.8 + p.brightness * 2) * dpr;
|
||||
ctx.fillRect(p.x - size/2, p.y - size/2, size, size);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ========== 主循环 ==========
|
||||
const patterns = [
|
||||
new HorizontalWave(),
|
||||
new ToroidalField(),
|
||||
new DipoleField(),
|
||||
new VortexField()
|
||||
];
|
||||
|
||||
let start = performance.now();
|
||||
let last = start;
|
||||
let pCurrent = { ...PatternAnchors[0].p };
|
||||
|
||||
function tick(now) {
|
||||
const dt = Math.min(0.05, Math.max(0.0001, (now - last) / 1000));
|
||||
last = now;
|
||||
const timeSec = (now - start) / 1000;
|
||||
|
||||
const t01 = clamp01(parseFloat(progressEl.value));
|
||||
const va = emotionPathVA(t01);
|
||||
|
||||
// 计算图案权重
|
||||
const weights = rbfWeights(va, 0.55);
|
||||
|
||||
// 参数混合
|
||||
let pTarget = { amp: 0, cycles: 0, harmonics: 0, noise: 0, jitter: 0,
|
||||
fm: 0, spikeRate: 0, spikeAmp: 0, baselineWander: 0,
|
||||
drive: 0, arousal: 0 };
|
||||
for (let i = 0; i < PatternAnchors.length; i++) {
|
||||
const p = PatternAnchors[i].p;
|
||||
const w = weights[i];
|
||||
for (const k in pTarget) pTarget[k] += p[k] * w;
|
||||
}
|
||||
|
||||
// 低通平滑
|
||||
const tau = 0.15;
|
||||
const k = 1 - Math.exp(-dt / Math.max(1e-4, tau));
|
||||
pCurrent = lerpParams(pCurrent, pTarget, k);
|
||||
|
||||
// 生成粒子(加权混合)
|
||||
let allParticles = [];
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
if (weights[i] > 0.01) {
|
||||
const pts = patterns[i].generate(timeSec, pCurrent, cw, ch);
|
||||
for (const pt of pts) {
|
||||
pt.alpha *= weights[i];
|
||||
allParticles.push(pt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HUD更新
|
||||
hudT.textContent = t01.toFixed(3);
|
||||
hudV.textContent = (va.x >= 0 ? "+" : "") + va.x.toFixed(3);
|
||||
hudA.textContent = va.y.toFixed(3);
|
||||
hudE.textContent = nearestEmotionName(va);
|
||||
|
||||
const dominantIdx = weights.indexOf(Math.max(...weights));
|
||||
hudPattern.textContent = PatternAnchors[dominantIdx].name;
|
||||
|
||||
// 渲染
|
||||
if (canvas.width !== cw || canvas.height !== ch) resizeCanvas();
|
||||
renderParticles(allParticles, {
|
||||
phosphor: phosphorEl.checked,
|
||||
scanlines: scanlinesEl.checked
|
||||
});
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
requestAnimationFrame(tick);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+553
@@ -0,0 +1,553 @@
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
|
||||
const canvas = /** @type {HTMLCanvasElement} */ ($("#scope"));
|
||||
const progressEl = /** @type {HTMLInputElement} */ ($("#progress"));
|
||||
const rbfEl = /** @type {HTMLInputElement} */ ($("#rbf"));
|
||||
const phosphorEl = /** @type {HTMLInputElement} */ ($("#phosphor"));
|
||||
const scanlinesEl = /** @type {HTMLInputElement} */ ($("#scanlines"));
|
||||
|
||||
const hudT = $("#hudT");
|
||||
const hudV = $("#hudV");
|
||||
const hudA = $("#hudA");
|
||||
const hudE = $("#hudE");
|
||||
|
||||
/** @typedef {{x:number,y:number}} Vec2 */
|
||||
/** @typedef {{
|
||||
* amp:number,
|
||||
* cycles:number,
|
||||
* harmonics:number,
|
||||
* noise:number,
|
||||
* jitter:number,
|
||||
* fm:number,
|
||||
* spikeRate:number,
|
||||
* spikeAmp:number,
|
||||
* baselineWander:number,
|
||||
* drive:number
|
||||
* }} Params
|
||||
*/
|
||||
|
||||
const clamp01 = (t) => Math.min(1, Math.max(0, t));
|
||||
const clamp = (lo, hi, v) => Math.min(hi, Math.max(lo, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const invLerp = (a, b, v) => (v - a) / (b - a);
|
||||
const smootherstep01 = (t) => {
|
||||
t = clamp01(t);
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
};
|
||||
|
||||
const vec2 = (x, y) => ({ x, y });
|
||||
const v2Add = (a, b) => vec2(a.x + b.x, a.y + b.y);
|
||||
const v2Sub = (a, b) => vec2(a.x - b.x, a.y - b.y);
|
||||
const v2Mul = (a, s) => vec2(a.x * s, a.y * s);
|
||||
const v2Len2 = (a) => a.x * a.x + a.y * a.y;
|
||||
|
||||
// Catmull-Rom (uniform) spline segment
|
||||
/** @param {Vec2} p0 @param {Vec2} p1 @param {Vec2} p2 @param {Vec2} p3 @param {number} t */
|
||||
function catmullRom(p0, p1, p2, p3, t) {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
// 0.5 * ((2*p1) + (-p0+p2)*t + (2*p0-5*p1+4*p2-p3)*t^2 + (-p0+3*p1-3*p2+p3)*t^3)
|
||||
const a = v2Mul(p1, 2);
|
||||
const b = v2Mul(v2Sub(p2, p0), t);
|
||||
const c = v2Mul(v2Add(v2Sub(v2Mul(p0, 2), v2Mul(p1, 5)), v2Sub(v2Mul(p2, 4), p3)), t2);
|
||||
const d = v2Mul(v2Add(v2Sub(v2Mul(p1, 3), p0), v2Sub(p3, v2Mul(p2, 3))), t3);
|
||||
return v2Mul(v2Add(v2Add(v2Add(a, b), c), d), 0.5);
|
||||
}
|
||||
|
||||
/** 情绪锚点(2D: valence×arousal) */
|
||||
const EmotionAnchors = [
|
||||
{ name: "放松", va: vec2(+0.7, 0.2) },
|
||||
{ name: "开心", va: vec2(+0.8, 0.75) },
|
||||
{ name: "紧张", va: vec2(-0.4, 0.7) },
|
||||
{ name: "压力", va: vec2(-0.7, 0.95) },
|
||||
];
|
||||
|
||||
/**
|
||||
* 单滑条 t∈[0,1] 沿情绪路径输出 (valence, arousal)。
|
||||
* 这里用 3 段 Catmull-Rom(端点重复)保证在锚点处一阶连续。
|
||||
* @param {number} t01
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
function emotionPathVA(t01) {
|
||||
const t = smootherstep01(t01);
|
||||
const p = EmotionAnchors.map((e) => e.va);
|
||||
|
||||
const segCount = 3;
|
||||
const s = clamp01(t) * segCount;
|
||||
const seg = Math.min(segCount - 1, Math.floor(s));
|
||||
const u = s - seg;
|
||||
|
||||
// segment i between Pi and P(i+1)
|
||||
// seg0: P0->P1 uses P0,P0,P1,P2
|
||||
// seg1: P1->P2 uses P0,P1,P2,P3
|
||||
// seg2: P2->P3 uses P1,P2,P3,P3
|
||||
let q;
|
||||
if (seg === 0) q = catmullRom(p[0], p[0], p[1], p[2], u);
|
||||
else if (seg === 1) q = catmullRom(p[0], p[1], p[2], p[3], u);
|
||||
else q = catmullRom(p[1], p[2], p[3], p[3], u);
|
||||
|
||||
// 防止样条过冲导致 arousal < 0 或 > 1(过渡仍保持平滑)
|
||||
return vec2(clamp(-1, 1, q.x), clamp01(q.y));
|
||||
}
|
||||
|
||||
/** @param {Vec2} va */
|
||||
function nearestEmotionName(va) {
|
||||
let best = EmotionAnchors[0].name;
|
||||
let bestD2 = Infinity;
|
||||
for (const e of EmotionAnchors) {
|
||||
const d2 = v2Len2(v2Sub(va, e.va));
|
||||
if (d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
best = e.name;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 参数锚点(对应四情绪) */
|
||||
const ParamAnchors = [
|
||||
{
|
||||
name: "放松",
|
||||
va: EmotionAnchors[0].va,
|
||||
p: /** @type {Params} */ ({
|
||||
amp: 0.22,
|
||||
cycles: 5.0,
|
||||
harmonics: 0.12,
|
||||
noise: 0.02,
|
||||
jitter: 0.02,
|
||||
fm: 0.05,
|
||||
spikeRate: 0.05,
|
||||
spikeAmp: 0.20,
|
||||
baselineWander: 0.03,
|
||||
drive: 0.60,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "开心",
|
||||
va: EmotionAnchors[1].va,
|
||||
p: /** @type {Params} */ ({
|
||||
amp: 0.32,
|
||||
cycles: 6.4,
|
||||
harmonics: 0.35,
|
||||
noise: 0.04,
|
||||
jitter: 0.04,
|
||||
fm: 0.10,
|
||||
spikeRate: 0.08,
|
||||
spikeAmp: 0.22,
|
||||
baselineWander: 0.025,
|
||||
drive: 0.70,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "紧张",
|
||||
va: EmotionAnchors[2].va,
|
||||
p: /** @type {Params} */ ({
|
||||
amp: 0.40,
|
||||
cycles: 8.4,
|
||||
harmonics: 0.22,
|
||||
noise: 0.10,
|
||||
jitter: 0.18,
|
||||
fm: 0.30,
|
||||
spikeRate: 0.45,
|
||||
spikeAmp: 0.55,
|
||||
baselineWander: 0.02,
|
||||
drive: 1.10,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "压力",
|
||||
va: EmotionAnchors[3].va,
|
||||
p: /** @type {Params} */ ({
|
||||
amp: 0.55,
|
||||
cycles: 10.5,
|
||||
harmonics: 0.20,
|
||||
noise: 0.20,
|
||||
jitter: 0.35,
|
||||
fm: 0.55,
|
||||
spikeRate: 0.90,
|
||||
spikeAmp: 0.90,
|
||||
baselineWander: 0.015,
|
||||
drive: 1.70,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/** @param {Vec2} va @param {number} sigma */
|
||||
function rbfWeights(va, sigma) {
|
||||
const s2 = Math.max(1e-6, sigma * sigma);
|
||||
/** @type {number[]} */
|
||||
const w = [];
|
||||
let sum = 0;
|
||||
for (const a of ParamAnchors) {
|
||||
const d2 = v2Len2(v2Sub(va, a.va));
|
||||
const wi = Math.exp(-d2 / (2 * s2));
|
||||
w.push(wi);
|
||||
sum += wi;
|
||||
}
|
||||
if (sum <= 1e-9) return w.map(() => 1 / w.length);
|
||||
return w.map((x) => x / sum);
|
||||
}
|
||||
|
||||
/** @param {Params[]} ps @param {number[]} w */
|
||||
function mixParams(ps, w) {
|
||||
/** @type {Params} */
|
||||
const out = {
|
||||
amp: 0,
|
||||
cycles: 0,
|
||||
harmonics: 0,
|
||||
noise: 0,
|
||||
jitter: 0,
|
||||
fm: 0,
|
||||
spikeRate: 0,
|
||||
spikeAmp: 0,
|
||||
baselineWander: 0,
|
||||
drive: 0,
|
||||
};
|
||||
for (let i = 0; i < ps.length; i++) {
|
||||
const p = ps[i];
|
||||
const wi = w[i];
|
||||
out.amp += p.amp * wi;
|
||||
out.cycles += p.cycles * wi;
|
||||
out.harmonics += p.harmonics * wi;
|
||||
out.noise += p.noise * wi;
|
||||
out.jitter += p.jitter * wi;
|
||||
out.fm += p.fm * wi;
|
||||
out.spikeRate += p.spikeRate * wi;
|
||||
out.spikeAmp += p.spikeAmp * wi;
|
||||
out.baselineWander += p.baselineWander * wi;
|
||||
out.drive += p.drive * wi;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {Params} a @param {Params} b @param {number} t */
|
||||
function lerpParams(a, b, t) {
|
||||
/** @type {Params} */
|
||||
return {
|
||||
amp: lerp(a.amp, b.amp, t),
|
||||
cycles: lerp(a.cycles, b.cycles, t),
|
||||
harmonics: lerp(a.harmonics, b.harmonics, t),
|
||||
noise: lerp(a.noise, b.noise, t),
|
||||
jitter: lerp(a.jitter, b.jitter, t),
|
||||
fm: lerp(a.fm, b.fm, t),
|
||||
spikeRate: lerp(a.spikeRate, b.spikeRate, t),
|
||||
spikeAmp: lerp(a.spikeAmp, b.spikeAmp, t),
|
||||
baselineWander: lerp(a.baselineWander, b.baselineWander, t),
|
||||
drive: lerp(a.drive, b.drive, t),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 备选:沿“情绪序列”做分段插值(不依赖 2D)
|
||||
* @param {number} t01
|
||||
*/
|
||||
function segmentMixParams(t01) {
|
||||
const t = smootherstep01(t01);
|
||||
const segCount = 3;
|
||||
const s = clamp01(t) * segCount;
|
||||
const seg = Math.min(segCount - 1, Math.floor(s));
|
||||
const u = smootherstep01(s - seg);
|
||||
const ps = ParamAnchors.map((a) => a.p);
|
||||
if (seg === 0) return lerpParams(ps[0], ps[1], u);
|
||||
if (seg === 1) return lerpParams(ps[1], ps[2], u);
|
||||
return lerpParams(ps[2], ps[3], u);
|
||||
}
|
||||
|
||||
// ---------- 连续噪声(1D value noise + fbm) ----------
|
||||
const fract = (x) => x - Math.floor(x);
|
||||
const hash1 = (n) => fract(Math.sin(n * 127.1) * 43758.5453123);
|
||||
const fade = (t) => t * t * (3 - 2 * t);
|
||||
|
||||
/** @param {number} x */
|
||||
function valueNoise1(x) {
|
||||
const i = Math.floor(x);
|
||||
const f = x - i;
|
||||
const a = hash1(i);
|
||||
const b = hash1(i + 1);
|
||||
return lerp(a, b, fade(f));
|
||||
}
|
||||
|
||||
/** @param {number} x */
|
||||
function fbm1(x) {
|
||||
let sum = 0;
|
||||
let amp = 0.5;
|
||||
let freq = 1.0;
|
||||
for (let o = 0; o < 4; o++) {
|
||||
sum += amp * (valueNoise1(x * freq) * 2 - 1);
|
||||
freq *= 2.02;
|
||||
amp *= 0.5;
|
||||
}
|
||||
return sum; // ~[-1,1]
|
||||
}
|
||||
|
||||
// ---------- 尖峰事件(确定性伪泊松) ----------
|
||||
/** @param {number} n @param {number} salt */
|
||||
function hash2(n, salt) {
|
||||
return fract(Math.sin((n + salt) * 311.7) * 95123.317);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} tSec
|
||||
* @param {number} ratePerSec
|
||||
* @param {number} amp
|
||||
*/
|
||||
function spikeSignal(tSec, ratePerSec, amp) {
|
||||
const cell = 0.12; // seconds
|
||||
const decayTau = 0.06; // seconds
|
||||
const idx = Math.floor(tSec / cell);
|
||||
const p = clamp01(ratePerSec * cell);
|
||||
|
||||
let s = 0;
|
||||
// look-back window for decays
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const c = idx - k;
|
||||
const r = hash2(c, 0.13);
|
||||
if (r < p) {
|
||||
const within = hash2(c, 2.71); // [0,1)
|
||||
const spikeT = (c + within) * cell;
|
||||
const dt = tSec - spikeT;
|
||||
if (dt >= 0 && dt < 0.35) {
|
||||
const polarity = hash2(c, 9.91) < 0.82 ? 1 : -1;
|
||||
s += polarity * amp * Math.exp(-dt / decayTau);
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- 波形采样 ----------
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
/** @param {number} x */
|
||||
function softClipTanh(x, drive) {
|
||||
const d = Math.max(1e-4, drive);
|
||||
const den = Math.tanh(d);
|
||||
if (den < 1e-6) return x;
|
||||
return Math.tanh(d * x) / den;
|
||||
}
|
||||
|
||||
/** @param {number} tSec @param {Params} p @param {number} windowSeconds */
|
||||
function sampleWave(tSec, p, windowSeconds) {
|
||||
// Base frequency derived from "cycles in window"
|
||||
const baseHz = Math.max(0.05, p.cycles / Math.max(0.1, windowSeconds));
|
||||
|
||||
// Smooth, continuous modulators (avoid white noise harshness)
|
||||
const lfo = Math.sin(TAU * (0.45 + 0.75 * p.fm) * tSec + 1.3 * fbm1(tSec * 0.21));
|
||||
const am = 1 + (0.18 + 0.18 * p.harmonics) * lfo;
|
||||
|
||||
const fmN = fbm1(tSec * (1.2 + 1.4 * p.fm) + 10.0);
|
||||
const jitterN = fbm1(tSec * (7.0 + 7.0 * p.jitter) + 33.3);
|
||||
|
||||
let phase = TAU * baseHz * tSec;
|
||||
phase += (p.fm * 2.7) * fmN;
|
||||
phase += (p.jitter * 2.2) * jitterN;
|
||||
|
||||
const h2 = p.harmonics * 0.65;
|
||||
const h3 = p.harmonics * 0.40;
|
||||
const h5 = p.harmonics * 0.16;
|
||||
|
||||
const core =
|
||||
Math.sin(phase) +
|
||||
h2 * Math.sin(2 * phase + 0.12) +
|
||||
h3 * Math.sin(3 * phase + 0.33) +
|
||||
h5 * Math.sin(5 * phase + 0.81);
|
||||
|
||||
const baseline = p.baselineWander * fbm1(tSec * 0.22 + 99.0);
|
||||
const noiseTerm = p.noise * 0.55 * fbm1(tSec * 12.0 + 7.0);
|
||||
const spikes = spikeSignal(tSec, p.spikeRate, p.spikeAmp);
|
||||
|
||||
const yRaw = p.amp * am * core + baseline + noiseTerm + spikes;
|
||||
return softClipTanh(yRaw, p.drive);
|
||||
}
|
||||
|
||||
// ---------- Canvas 渲染 ----------
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
let dpr = 1;
|
||||
let cw = 0;
|
||||
let ch = 0;
|
||||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dpr = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1));
|
||||
cw = Math.max(2, Math.floor(rect.width * dpr));
|
||||
ch = Math.max(2, Math.floor(rect.height * dpr));
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", resizeCanvas, { passive: true });
|
||||
resizeCanvas();
|
||||
|
||||
/** @param {number} w @param {number} h */
|
||||
function drawScanlines(w, h) {
|
||||
const step = Math.max(2, Math.floor(4 * dpr));
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = "rgba(190,255,225,0.045)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let y = 0; y < h; y += step) {
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(w, y + 0.5);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** @param {number} w @param {number} h */
|
||||
function drawGrid(w, h) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = "rgba(120,255,190,0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
const vLines = 10;
|
||||
const hLines = 6;
|
||||
for (let i = 1; i < vLines; i++) {
|
||||
const x = (i / vLines) * w;
|
||||
ctx.moveTo(x + 0.5, 0);
|
||||
ctx.lineTo(x + 0.5, h);
|
||||
}
|
||||
for (let j = 1; j < hLines; j++) {
|
||||
const y = (j / hLines) * h;
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(w, y + 0.5);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} timeSec
|
||||
* @param {Params} p
|
||||
* @param {{phosphor:boolean, scanlines:boolean}} opts
|
||||
*/
|
||||
function renderScope(timeSec, p, opts) {
|
||||
const w = cw;
|
||||
const h = ch;
|
||||
const midY = h * 0.5;
|
||||
const ampPx = h * 0.33;
|
||||
|
||||
// phosphor persistence: fade previous frame
|
||||
if (opts.phosphor) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.16)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
drawGrid(w, h);
|
||||
if (opts.scanlines) drawScanlines(w, h);
|
||||
|
||||
const windowSeconds = 2.0;
|
||||
const N = Math.min(1400, Math.max(480, Math.floor(w * 0.75)));
|
||||
|
||||
/** @type {number[]} */
|
||||
const xs = new Array(N);
|
||||
/** @type {number[]} */
|
||||
const ys = new Array(N);
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x01 = i / (N - 1);
|
||||
const t = timeSec - windowSeconds * (1 - x01);
|
||||
const y = sampleWave(t, p, windowSeconds);
|
||||
|
||||
xs[i] = x01 * w;
|
||||
ys[i] = midY - y * ampPx;
|
||||
}
|
||||
|
||||
// glow strokes
|
||||
ctx.save();
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
const strokes = [
|
||||
{ width: 6.5 * dpr, alpha: 0.055, color: "rgba(80,255,154,1)" },
|
||||
{ width: 3.2 * dpr, alpha: 0.11, color: "rgba(120,255,190,1)" },
|
||||
{ width: 1.35 * dpr, alpha: 0.90, color: "rgba(190,255,225,1)" },
|
||||
];
|
||||
|
||||
for (const s of strokes) {
|
||||
ctx.globalAlpha = s.alpha;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = s.width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xs[0], ys[0]);
|
||||
for (let i = 1; i < N; i++) ctx.lineTo(xs[i], ys[i]);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// subtle center line
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.10;
|
||||
ctx.strokeStyle = "rgba(190,255,225,1)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, midY + 0.5);
|
||||
ctx.lineTo(w, midY + 0.5);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---------- 主循环:情绪路径→参数→低通→渲染 ----------
|
||||
let start = performance.now();
|
||||
let last = start;
|
||||
|
||||
/** @type {Params} */
|
||||
let pCurrent = { ...ParamAnchors[0].p };
|
||||
|
||||
function tick(now) {
|
||||
const dt = Math.min(0.05, Math.max(0.0001, (now - last) / 1000));
|
||||
last = now;
|
||||
|
||||
// slider t
|
||||
const t01 = clamp01(parseFloat(progressEl.value));
|
||||
const va = emotionPathVA(t01);
|
||||
|
||||
// target params from 2D RBF or segment mix
|
||||
let pTarget;
|
||||
if (rbfEl.checked) {
|
||||
// sigma 越小越“贴近锚点”(情绪差异更明确);越大越“融”(过渡更柔)
|
||||
const w = rbfWeights(va, 0.62);
|
||||
pTarget = mixParams(
|
||||
ParamAnchors.map((a) => a.p),
|
||||
w
|
||||
);
|
||||
} else {
|
||||
pTarget = segmentMixParams(t01);
|
||||
}
|
||||
|
||||
// 1st-order low-pass to avoid abrupt change under fast dragging
|
||||
const tau = 0.12;
|
||||
const k = 1 - Math.exp(-dt / Math.max(1e-4, tau));
|
||||
pCurrent = lerpParams(pCurrent, pTarget, k);
|
||||
|
||||
// HUD
|
||||
hudT.textContent = t01.toFixed(3);
|
||||
hudV.textContent = (va.x >= 0 ? "+" : "") + va.x.toFixed(3);
|
||||
hudA.textContent = va.y.toFixed(3);
|
||||
hudE.textContent = nearestEmotionName(va);
|
||||
|
||||
// render
|
||||
if (canvas.width !== cw || canvas.height !== ch) resizeCanvas();
|
||||
renderScope((now - start) / 1000, pCurrent, {
|
||||
phosphor: phosphorEl.checked,
|
||||
scanlines: scanlinesEl.checked,
|
||||
});
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// Prime first frame
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
:root{
|
||||
--bg0:#06090d;
|
||||
--bg1:#0a1118;
|
||||
--fg0:#d7fbe9;
|
||||
--fg1:#99d7b6;
|
||||
--muted:#6f8c7d;
|
||||
--line:#50ff9a;
|
||||
--line2:#a7ffd4;
|
||||
--panel:#0b121a;
|
||||
--panel2:#0f1a24;
|
||||
--border:rgba(153,215,182,.22);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
background:radial-gradient(1200px 800px at 30% 20%, #0d1a24 0%, var(--bg0) 55%, #04070a 100%);
|
||||
color:var(--fg0);
|
||||
font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans","PingFang SC","Microsoft YaHei",sans-serif;
|
||||
}
|
||||
|
||||
.app{
|
||||
height:100%;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
|
||||
.topbar{
|
||||
display:flex;
|
||||
gap:16px;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
padding:14px 16px 12px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:linear-gradient(180deg, rgba(15,26,36,.92), rgba(8,12,18,.86));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.title__main{
|
||||
font-weight:700;
|
||||
letter-spacing:.2px;
|
||||
}
|
||||
.title__sub{
|
||||
margin-top:2px;
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
|
||||
.controls{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:10px 14px;
|
||||
align-items:center;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
.control{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:6px;
|
||||
padding:10px 12px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
background:linear-gradient(180deg, rgba(15,26,36,.75), rgba(9,13,18,.6));
|
||||
}
|
||||
|
||||
.control__label{
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
|
||||
.control--checkbox{
|
||||
flex-direction:row;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:10px 12px;
|
||||
}
|
||||
|
||||
input[type="range"]{
|
||||
width:min(340px, 42vw);
|
||||
accent-color:var(--line);
|
||||
}
|
||||
|
||||
.stage{
|
||||
position:relative;
|
||||
flex:1;
|
||||
min-height:320px;
|
||||
padding:14px;
|
||||
}
|
||||
|
||||
.scope{
|
||||
width:100%;
|
||||
height:100%;
|
||||
border-radius:14px;
|
||||
border:1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(900px 600px at 40% 35%, rgba(17,48,38,.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(11,18,26,.86), rgba(6,9,13,.86));
|
||||
}
|
||||
|
||||
.hud{
|
||||
position:absolute;
|
||||
left:28px;
|
||||
top:28px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:8px;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.hud__row{
|
||||
display:flex;
|
||||
gap:14px;
|
||||
flex-wrap:wrap;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
.hud__row--small{
|
||||
max-width:min(720px, 70vw);
|
||||
}
|
||||
|
||||
.hud__kv{
|
||||
display:flex;
|
||||
gap:8px;
|
||||
align-items:baseline;
|
||||
padding:6px 10px;
|
||||
border:1px solid rgba(153,215,182,.18);
|
||||
border-radius:999px;
|
||||
background:rgba(8,12,18,.55);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.hud__kv .k{
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.hud__kv .v{
|
||||
font-variant-numeric: tabular-nums;
|
||||
color:var(--fg0);
|
||||
}
|
||||
|
||||
.hud__hint{
|
||||
color:rgba(153,215,182,.74);
|
||||
font-size:12px;
|
||||
padding:8px 10px;
|
||||
border:1px dashed rgba(153,215,182,.18);
|
||||
border-radius:10px;
|
||||
background:rgba(8,12,18,.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>思维侧写</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;500&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#analysis-canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 最终图片 */
|
||||
#final-image-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 420px;
|
||||
height: 520px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.real-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 漂浮的句子 */
|
||||
.clue {
|
||||
position: absolute;
|
||||
color: rgba(220, 200, 180, 0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
cursor: crosshair;
|
||||
transition: color 0.15s, text-shadow 0.15s;
|
||||
animation: float 4s ease-in-out infinite alternate;
|
||||
z-index: 50;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.clue:hover {
|
||||
color: rgba(255, 245, 230, 1);
|
||||
text-shadow: 0 0 15px rgba(255, 200, 150, 0.6);
|
||||
}
|
||||
|
||||
.clue.triggered {
|
||||
pointer-events: none;
|
||||
animation: none;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ASCII字符层 */
|
||||
#ascii-layer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 单个ASCII字符 */
|
||||
.char {
|
||||
position: absolute;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
text-align: center;
|
||||
transition: opacity 0.2s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.char.placed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.char.flying {
|
||||
transition: left 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0) rotate(-0.5deg); }
|
||||
100% { transform: translateY(-6px) rotate(0.5deg); }
|
||||
}
|
||||
|
||||
/* 进度 */
|
||||
#progress {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* 提示 */
|
||||
#hint {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 12px;
|
||||
letter-spacing: 5px;
|
||||
transition: opacity 1s;
|
||||
}
|
||||
#hint.hidden { opacity: 0; }
|
||||
|
||||
/* 结局文字 */
|
||||
#reveal-text {
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(255, 240, 220, 0);
|
||||
font-size: 14px;
|
||||
letter-spacing: 12px;
|
||||
z-index: 100;
|
||||
transition: color 2s ease 0.5s;
|
||||
}
|
||||
#reveal-text.show {
|
||||
color: rgba(255, 240, 220, 0.85);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="stage">
|
||||
<canvas id="analysis-canvas"></canvas>
|
||||
|
||||
<div id="ascii-layer"></div>
|
||||
|
||||
<div id="final-image-container">
|
||||
<img id="source-image"
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=800&auto=format&fit=crop"
|
||||
crossorigin="anonymous"
|
||||
class="real-photo" alt="">
|
||||
</div>
|
||||
<div id="reveal-text">就 是 她</div>
|
||||
</div>
|
||||
|
||||
<div id="progress">0%</div>
|
||||
<div id="hint">移动鼠标 · 拼凑真相</div>
|
||||
|
||||
<script>
|
||||
// 80句
|
||||
const vocab = [
|
||||
"黑色风衣女人", "长发遮半边脸", "雨夜独自站", "高跟鞋声响",
|
||||
"淡淡的烟味", "红唇微颤抖", "沉默的背影", "躲闪的眼神",
|
||||
"昨晚十点整", "最后的渡轮", "她在说谎", "左手的戒指",
|
||||
"指尖有泥土", "侧脸的轮廓", "电话突然断", "车票的日期",
|
||||
"颤抖的双手", "欲言又止", "匆忙离现场", "丝巾遮脖颈",
|
||||
"眼角的泪痕", "紧握的拳头", "深夜的来访", "无法解释的",
|
||||
"那晚她在场", "目击者证词", "最后见到她", "消失的证据",
|
||||
"她知道真相", "不在场证明", "香水的味道", "雨中的身影",
|
||||
"码头的尽头", "转身的瞬间", "路灯下剪影", "低语的声音",
|
||||
"犹豫的脚步", "紧锁的眉头", "冰冷的手指", "破碎的谎言",
|
||||
"隐藏的秘密", "午夜的电话", "模糊的记忆", "真相的碎片",
|
||||
"无声的控诉", "逃离的背影", "最后的晚餐", "致命的证据",
|
||||
"她就是凶手", "就是她", "那个女人", "雨夜的秘密",
|
||||
"无人知晓的", "深藏的恐惧", "午夜的访客", "不可告人的",
|
||||
"最后的线索", "关键的证人", "遗失的记忆", "隐藏的伤痕",
|
||||
"沉默的真相", "破碎的承诺", "无法回头的", "命运的交点",
|
||||
"时间的裂缝", "被遗忘的夜", "最后的告白", "无声的呐喊",
|
||||
"迷失的方向", "黑暗中的影", "月光下的她", "寂静的街道",
|
||||
"最后的机会", "不能说的秘", "被掩盖的罪", "午夜的约定"
|
||||
];
|
||||
|
||||
// 配置
|
||||
const IMG_WIDTH = 420;
|
||||
const IMG_HEIGHT = 520;
|
||||
const CELL_SIZE = 10; // 更小的网格 = 更密集
|
||||
const totalClues = 300; // 120个句子
|
||||
|
||||
let collectedCount = 0;
|
||||
let isRevealed = false;
|
||||
let gridData = [];
|
||||
let gridIndex = 0;
|
||||
|
||||
const stage = document.getElementById('stage');
|
||||
const asciiLayer = document.getElementById('ascii-layer');
|
||||
const finalImageContainer = document.getElementById('final-image-container');
|
||||
const sourceImage = document.getElementById('source-image');
|
||||
const canvas = document.getElementById('analysis-canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const progress = document.getElementById('progress');
|
||||
const hint = document.getElementById('hint');
|
||||
const revealText = document.getElementById('reveal-text');
|
||||
|
||||
// 分析图像
|
||||
function analyzeImage() {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
canvas.width = IMG_WIDTH;
|
||||
canvas.height = IMG_HEIGHT;
|
||||
|
||||
// 强对比度 + 灰度
|
||||
ctx.filter = 'contrast(1.8) brightness(1.0) saturate(0)';
|
||||
ctx.drawImage(img, 0, 0, IMG_WIDTH, IMG_HEIGHT);
|
||||
ctx.filter = 'none';
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, IMG_WIDTH, IMG_HEIGHT);
|
||||
const data = imageData.data;
|
||||
|
||||
// 构建亮度图并找极值
|
||||
let brightnessMap = [];
|
||||
let minB = 255, maxB = 0;
|
||||
for (let y = 0; y < IMG_HEIGHT; y++) {
|
||||
brightnessMap[y] = [];
|
||||
for (let x = 0; x < IMG_WIDTH; x++) {
|
||||
const i = (y * IMG_WIDTH + x) * 4;
|
||||
const b = data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114;
|
||||
brightnessMap[y][x] = b;
|
||||
if (b < minB) minB = b;
|
||||
if (b > maxB) maxB = b;
|
||||
}
|
||||
}
|
||||
|
||||
// 归一化
|
||||
const range = maxB - minB || 1;
|
||||
for (let y = 0; y < IMG_HEIGHT; y++) {
|
||||
for (let x = 0; x < IMG_WIDTH; x++) {
|
||||
brightnessMap[y][x] = ((brightnessMap[y][x] - minB) / range) * 255;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成网格数据
|
||||
gridData = [];
|
||||
const cols = Math.floor(IMG_WIDTH / CELL_SIZE);
|
||||
const rows = Math.floor(IMG_HEIGHT / CELL_SIZE);
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const cx = col * CELL_SIZE + CELL_SIZE / 2;
|
||||
const cy = row * CELL_SIZE + CELL_SIZE / 2;
|
||||
|
||||
// 采样3x3区域取平均,更平滑
|
||||
let sum = 0, count = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const sy = Math.floor(cy) + dy;
|
||||
const sx = Math.floor(cx) + dx;
|
||||
if (sy >= 0 && sy < IMG_HEIGHT && sx >= 0 && sx < IMG_WIDTH) {
|
||||
sum += brightnessMap[sy][sx];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const brightness = sum / count;
|
||||
const darkness = 255 - brightness;
|
||||
|
||||
// 更低的阈值,捕获更多细节
|
||||
if (darkness > 15) {
|
||||
// 非线性映射,增强对比
|
||||
const d = darkness / 255;
|
||||
const dd = Math.pow(d, 0.8); // 提升暗部
|
||||
|
||||
const size = 5 + dd * 9; // 5-14px
|
||||
const alpha = 0.08 + dd * 0.9; // 0.08-0.98
|
||||
|
||||
gridData.push({
|
||||
x: cx,
|
||||
y: cy,
|
||||
size: size,
|
||||
alpha: alpha,
|
||||
darkness: darkness
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按暗度排序
|
||||
gridData.sort((a, b) => b.darkness - a.darkness);
|
||||
|
||||
// 分块打乱
|
||||
const n = gridData.length;
|
||||
const c1 = shuffleArray(gridData.slice(0, n * 0.2));
|
||||
const c2 = shuffleArray(gridData.slice(n * 0.2, n * 0.4));
|
||||
const c3 = shuffleArray(gridData.slice(n * 0.4, n * 0.6));
|
||||
const c4 = shuffleArray(gridData.slice(n * 0.6, n * 0.8));
|
||||
const c5 = shuffleArray(gridData.slice(n * 0.8));
|
||||
gridData = [...c1, ...c2, ...c3, ...c4, ...c5];
|
||||
|
||||
console.log(`生成了 ${gridData.length} 个网格位置`);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
generateFallbackGrid();
|
||||
resolve();
|
||||
};
|
||||
img.src = sourceImage.src;
|
||||
});
|
||||
}
|
||||
|
||||
function generateFallbackGrid() {
|
||||
gridData = [];
|
||||
const cols = Math.floor(IMG_WIDTH / CELL_SIZE);
|
||||
const rows = Math.floor(IMG_HEIGHT / CELL_SIZE);
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const cx = col * CELL_SIZE + CELL_SIZE / 2;
|
||||
const cy = row * CELL_SIZE + CELL_SIZE / 2;
|
||||
const dx = (cx - IMG_WIDTH/2) / (IMG_WIDTH * 0.4);
|
||||
const dy = (cy - IMG_HEIGHT/2) / (IMG_HEIGHT * 0.48);
|
||||
if (dx*dx + dy*dy < 1) {
|
||||
const d = 1 - Math.sqrt(dx*dx + dy*dy);
|
||||
gridData.push({
|
||||
x: cx, y: cy,
|
||||
size: 6 + d * 8,
|
||||
alpha: 0.2 + d * 0.6,
|
||||
darkness: d * 200
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
gridData = shuffleArray(gridData);
|
||||
}
|
||||
|
||||
function shuffleArray(array) {
|
||||
const arr = [...array];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function getNextGridCells(count) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < count && gridIndex < gridData.length; i++) {
|
||||
cells.push(gridData[gridIndex++]);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await analyzeImage();
|
||||
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
|
||||
asciiLayer.style.width = IMG_WIDTH + 'px';
|
||||
asciiLayer.style.height = IMG_HEIGHT + 'px';
|
||||
|
||||
// 创建更多句子
|
||||
for (let i = 0; i < totalClues; i++) {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('clue');
|
||||
const text = vocab[i % vocab.length];
|
||||
el.innerText = text;
|
||||
el.dataset.text = text;
|
||||
|
||||
const angle = (i / totalClues) * Math.PI * 2 + Math.random() * 0.3;
|
||||
const dist = 280 + Math.random() * 280;
|
||||
const startX = centerX + Math.cos(angle) * dist;
|
||||
const startY = centerY + Math.sin(angle) * dist;
|
||||
el.style.left = startX + 'px';
|
||||
el.style.top = startY + 'px';
|
||||
el.dataset.startX = startX;
|
||||
el.dataset.startY = startY;
|
||||
|
||||
el.style.fontSize = (11 + Math.random() * 3) + 'px';
|
||||
el.style.animationDelay = (Math.random() * 3) + 's';
|
||||
|
||||
el.addEventListener('mouseenter', handleHover);
|
||||
stage.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHover(e) {
|
||||
const el = e.target;
|
||||
if (el.classList.contains('triggered') || isRevealed) return;
|
||||
|
||||
el.classList.add('triggered');
|
||||
|
||||
const text = el.dataset.text;
|
||||
const startX = parseFloat(el.dataset.startX);
|
||||
const startY = parseFloat(el.dataset.startY);
|
||||
|
||||
const imgRect = finalImageContainer.getBoundingClientRect();
|
||||
const imgCenterX = imgRect.left + imgRect.width / 2;
|
||||
const imgCenterY = imgRect.top + imgRect.height / 2;
|
||||
|
||||
const chars = text.split('');
|
||||
const cells = getNextGridCells(chars.length);
|
||||
|
||||
chars.forEach((char, i) => {
|
||||
if (i >= cells.length) return;
|
||||
|
||||
const cell = cells[i];
|
||||
const charEl = document.createElement('div');
|
||||
charEl.classList.add('char', 'flying');
|
||||
charEl.innerText = char;
|
||||
|
||||
const startPosX = startX - imgCenterX + IMG_WIDTH/2;
|
||||
const startPosY = startY - imgCenterY + IMG_HEIGHT/2;
|
||||
charEl.style.left = startPosX + 'px';
|
||||
charEl.style.top = startPosY + 'px';
|
||||
|
||||
charEl.style.fontSize = cell.size + 'px';
|
||||
charEl.style.color = `rgba(255, 255, 255, ${cell.alpha})`;
|
||||
charEl.style.width = CELL_SIZE + 'px';
|
||||
charEl.style.height = CELL_SIZE + 'px';
|
||||
charEl.style.lineHeight = CELL_SIZE + 'px';
|
||||
|
||||
asciiLayer.appendChild(charEl);
|
||||
|
||||
setTimeout(() => {
|
||||
charEl.style.left = cell.x + 'px';
|
||||
charEl.style.top = cell.y + 'px';
|
||||
charEl.classList.add('placed');
|
||||
}, 5 + i * 10);
|
||||
});
|
||||
|
||||
collectedCount++;
|
||||
const pct = Math.round((gridIndex / gridData.length) * 100);
|
||||
progress.textContent = pct + '%';
|
||||
|
||||
if (collectedCount > 5) hint.classList.add('hidden');
|
||||
|
||||
// 90% 格子填满触发结局
|
||||
if (gridIndex >= gridData.length * 0.9) {
|
||||
revealIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
function revealIdentity() {
|
||||
if (isRevealed) return;
|
||||
isRevealed = true;
|
||||
|
||||
const allChars = document.querySelectorAll('.char.placed');
|
||||
allChars.forEach((el, i) => {
|
||||
setTimeout(() => {
|
||||
el.style.transition = 'opacity 1.5s ease';
|
||||
el.style.opacity = '0.03';
|
||||
}, i * 0.3);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
finalImageContainer.style.transition = 'opacity 2s ease';
|
||||
finalImageContainer.style.opacity = 1;
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
revealText.classList.add('show');
|
||||
}, 800);
|
||||
|
||||
document.querySelectorAll('.clue:not(.triggered)').forEach(el => {
|
||||
el.style.transition = 'opacity 0.3s';
|
||||
el.style.opacity = 0;
|
||||
});
|
||||
|
||||
progress.style.transition = 'opacity 0.5s';
|
||||
progress.style.opacity = 0;
|
||||
}
|
||||
|
||||
window.onload = init;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user