提交web原型存档

This commit is contained in:
2026-02-09 14:02:41 +08:00
parent a77f65b432
commit c509362707
25 changed files with 10094 additions and 3874 deletions
+272
View File
@@ -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
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();
}
-655
View File
@@ -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>
+110
View File
@@ -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` 的字段名完全相同
+31
View File
@@ -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 渲染
+121
View File
@@ -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]`
+536
View File
@@ -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>
+518
View File
@@ -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>
+553
View File
@@ -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);
+151
View File
@@ -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
View File
@@ -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>