Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
publishNpm:
description: "Publish to npm (requires NPM_TOKEN)"
required: true
default: "false"
distTag:
description: "npm dist-tag (e.g. next, latest)"
required: true
default: "next"
prerelease:
description: "Create GitHub prerelease"
required: true
default: "true"

permissions:
contents: write

jobs:
publish:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
registry-url: https://registry.npmjs.org

- name: Install
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test

- name: Build
run: npm run build

- name: Pack
run: npm pack

- name: Determine release flags
id: flags
shell: bash
run: |
REF_NAME="${GITHUB_REF_NAME}"

# Prefer explicit workflow_dispatch inputs; otherwise infer from tag naming.
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
echo "publish_npm=${{ inputs.publishNpm }}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${{ inputs.distTag }}" >> "$GITHUB_OUTPUT"
echo "prerelease=${{ inputs.prerelease }}" >> "$GITHUB_OUTPUT"
else
if [[ "$REF_NAME" == *"-"* ]]; then
echo "publish_npm=false" >> "$GITHUB_OUTPUT"
echo "dist_tag=next" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "publish_npm=true" >> "$GITHUB_OUTPUT"
echo "dist_tag=latest" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
fi

- name: Publish to npm
if: ${{ steps.flags.outputs.publish_npm == 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --tag ${{ steps.flags.outputs.dist_tag }}

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
prerelease: ${{ steps.flags.outputs.prerelease }}
generate_release_notes: true
files: |
glitch-animate-*.tgz
12 changes: 6 additions & 6 deletions demo/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "./styles.css";

import { Engine, bounds, follow, snap, attachPointerDrag } from "../src";
import { Engine, bounds, follow, attachPointerDrag } from "../src";

const elStage = document.querySelector<HTMLElement>("#stage");
const elA = document.querySelector<HTMLElement>("#a");
Expand Down Expand Up @@ -36,12 +36,12 @@ engine.addConstraint(follow({ a: "A.x", b: "B.x", offset: 160, kC: 650, cC: 70 }
engine.addConstraint(bounds({ ch: "A.x", min: 0, max: 820, mode: "soft", kC: 1200, cC: 110 }));
engine.addConstraint(bounds({ ch: "A.y", min: 0, max: 350, mode: "soft", kC: 1200, cC: 110 }));

// snap grid
const grid = Array.from({ length: 13 }, (_, i) => i * 60);
engine.addConstraint(snap({ ch: "A.x", points: grid, radius: 10, strength: 1600, cC: 120 }));
engine.addConstraint(snap({ ch: "A.y", points: grid, radius: 10, strength: 1600, cC: 120 }));
// // snap grid
// const grid = Array.from({ length: 13 }, (_, i) => i * 60);
// engine.addConstraint(snap({ ch: "A.x", points: grid, radius: 10, strength: 1600, cC: 120 }));
// engine.addConstraint(snap({ ch: "A.y", points: grid, radius: 10, strength: 1600, cC: 120 }));

attachPointerDrag(engine, "A", { mode: "target" });
attachPointerDrag(engine, "A", { mode: "force" });

engine.setState("A", "idle");
engine.setState("B", "idle");
Expand Down
4 changes: 4 additions & 0 deletions doc/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
This folder contains design docs, development notes, and user-facing docs.

## Develop

- Release process: ./develop/release-process.md
36 changes: 25 additions & 11 deletions doc/develop/api-and-struct.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export interface EngineOptions {
dtClamp?: number; // 默认 0.033
substeps?: number; // 默认 3
iterations?: number; // 约束迭代次数,ForceSolver 默认 1-3,ProjectionSolver 默认 3-8
solverType?: SolverType;// 默认 "force"
solverType?: SolverType;// 默认 "force"(注意:当前仅实现了 force 求解器)

sleepPositionEps?: number; // 默认 0.01
sleepVelocityEps?: number; // 默认 0.01
sleepFrames?: number; // 连续多少帧满足阈值后休眠,默认 10

enableLayoutRead?: boolean; // 默认 false,允许 Read Phase 读取布局
enableLayoutRead?: boolean; // 默认 false,预留选项,未来用于 Read Phase 读取布局
renderer?: Renderer; // 默认 DOMRenderer

onFrameStats?: (stats: FrameStats) => void; // 性能统计钩子
Expand All @@ -42,7 +42,7 @@ export interface FrameStats {

```ts
export interface ChannelParams {
mass?: number; // m
mass?: number; // m (必须 > 0)
stiffness?: number; // k
damping?: number; // c
vmax?: number; // 速度上限
Expand All @@ -54,7 +54,8 @@ export class Channel {
value: number;
target: number;
v: number;
force: number;
force: number; // 约束力,每次迭代清零
externalForce: number; // 外部力(如拖拽),每帧清零
params: Required<ChannelParams>;

constructor(initial: number, params?: ChannelParams);
Expand All @@ -67,6 +68,12 @@ export class Channel {
}
```

**注意**: Channel 现在有两个力字段:
- `force`: 约束力,在每次约束迭代前被清零。
- `externalForce`: 外部力(如拖拽适配器),在每帧所有子步与迭代完成后由 `Solver` 统一清零。

这样设计使得外部力可以在整个约束求解过程中(跨子步与多次迭代)保持有效。

Channel.step 的参考实现。

```ts
Expand All @@ -81,15 +88,15 @@ step(dt: number) {
const spring = k * (this.target - x);
const damper = -c * v;

const a = (spring + damper + this.force) / m;
const a = (spring + damper + this.force + this.externalForce) / m;

let v2 = v + a * dt;
const vmax = this.params.vmax;
if (v2 > vmax) v2 = vmax;
if (v2 < -vmax) v2 = -vmax;

// friction 为 0..1,建议在没有外力且接近目标时增强
const fr = this详.params.friction;
const fr = this.params.friction;
v2 *= (1 - fr);

const x2 = x + v2 * dt;
Expand Down Expand Up @@ -135,7 +142,7 @@ export class Node {
ensureChannel(name: string, initial?: number, params?: ChannelParams): Channel;

defineState(name: string, pose: Pose): void;
setState(name: string, opts?: { merge?: boolean }): void; // merge true 仅覆盖 pose 中的键
setState(name: string, opts?: { merge?: boolean }): void; // merge true(默认)仅覆盖 pose 中的键;false 时会将所有通道目标设为当前值后再应用 pose
overrideTarget(pose: Pose): void;

isSleeping(posEps: number, velEps: number, sleepFrames: number): boolean;
Expand Down Expand Up @@ -172,11 +179,12 @@ export interface Solver {

ForceSolver 生命周期。

1. 每子步先清理通道外力。
2. 执行 N 次约束迭代:每次遍历 constraints 调用 solve,累积外力。
3. 执行通道 step。
1. 每子步先清理通道约束力(force 字段,不清理 externalForce)。
2. 执行 N 次约束迭代:每次遍历 constraints 调用 solve,累积约束力到 force 字段。
3. 执行通道 step,同时使用 force(约束力)和 externalForce(外部力)。
4. 在所有子步与迭代完成后,统一清除 `externalForce`。

ProjectionSolver 生命周期。
ProjectionSolver 生命周期(当前未实现)

1. 每子步先执行通道的预测积分,得到 x*
2. 执行 N 次投影约束迭代,直接调整 value。
Expand All @@ -189,7 +197,13 @@ export interface Renderer {
prepare?(engine: Engine): void;
render(engine: Engine): void;
}
```

**更新说明**:
- 自 v0.2 起,`willChange: transform, opacity` 的设置已从渲染器准备阶段移至 `Engine.registerNode()` 中自动处理。
- `Renderer.prepare()` 目前变为可选方法。如果自定义渲染器需要进行全局初始化(例如创建共用的 Canvas 上下文或注入全局样式),仍可实现此方法。

```ts
export class DOMRenderer implements Renderer {
render(engine: Engine): void;
}
Expand Down
87 changes: 87 additions & 0 deletions doc/develop/release-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 发布流程(CI/CD)

本项目使用 GitHub Actions 进行持续集成(CI)与持续交付(CD)。

## 目标

- CI:在 PR / main 分支提交时自动执行 `typecheck`、`test`、`build`。
- CD:通过 **tag push** 或 **手动触发** 发布。
- 自动创建 GitHub Release(可为 pre-release)。
- 自动发布到 npm(pre-release 默认发布到 `next` dist-tag)。

## 产物结构

- 库构建产物:`dist/lib/`
- demo 构建产物:`dist/demo/`

发布到 npm 的内容仅包含 `dist/lib`(由 `package.json#files` 控制)。

## 前置条件

1. npm 包权限
- 需要确保 npm 账号拥有 `glitch-animate` 的发布权限。

2. GitHub 仓库 Secret(仅在发布到 npm 时需要)
- 在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加:
- `NPM_TOKEN`:用于 `npm publish` 的 token。

## 发布策略

### 预发布(pre-release)建议

建议使用 semver 预发布版本号:

- `0.1.0-next.0`
- `0.1.0-next.1`
- `0.1.0-rc.0`

并使用对应的 tag:

- `v0.1.0-next.0`

GitHub Actions 会根据 tag 名是否包含 `-` 自动判断是否为 pre-release:

- tag 含 `-` → GitHub Release 为 pre-release,默认 **不发布到 npm**
- tag 不含 `-` → GitHub Release 为正式版,默认发布到 npm dist-tag `latest`

## 具体操作

### 方式 A:推送 tag(推荐)

1. 更新版本号(示例:预发布)

- 将版本设置为 `0.1.0-next.0`:
- 手动改 `package.json#version`,或使用 npm:
- `npm version 0.1.0-next.0 --no-git-tag-version`

2. 提交并打 tag

- `git add package.json package-lock.json`
- `git commit -m "chore(release): v0.1.0-next.0"`
- `git tag v0.1.0-next.0`
- `git push origin HEAD --tags`

3. 等待 GitHub Actions

- 工作流:`.github/workflows/release.yml`
- 执行内容:`npm ci` → `typecheck` → `test` → `build` → `npm publish` → GitHub Release

### 方式 B:手动触发(workflow_dispatch)

进入 GitHub Actions → Release → Run workflow:

- `publishNpm`:是否发布到 npm(默认 `false`)
- `distTag`:默认 `next`(预发布推荐)
- `prerelease`:默认 `true`

注意:手动触发不会自动修改 `package.json#version`,请先确保版本号正确。

## 常见问题

1. `npm publish` 权限失败
- 检查 `NPM_TOKEN` 是否有发布权限。
- 确认包名 `glitch-animate` 是否已被占用。

2. 产物不完整
- 检查是否先执行了 `npm run build`。
- 确认 `dist/lib` 内包含 `index.js` 与 `index.d.ts`。
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 30 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,46 @@
{
"name": "glitch-animate",
"version": "0.1.0",
"version": "0.1.0-next.0",
"description": "A constraint-based animation system for the web.",
"type": "module",
"license": "Apache-2.0",
"author": "",
"author": "LummiGhost",
"repository": {
"type": "git",
"url": ""
"url": "git+https://github.com/LummiGhost/glitch-animate.git"
},
"bugs": {
"url": "https://github.com/LummiGhost/glitch-animate/issues"
},
"homepage": "https://github.com/LummiGhost/glitch-animate#readme",
"sideEffects": false,
"main": "./dist/lib/index.js",
"types": "./dist/lib/index.d.ts",
"exports": {
".": {
"types": "./dist/lib/index.d.ts",
"import": "./dist/lib/index.js"
}
},
"files": [
"dist/lib",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"clean": "node ./scripts/clean.mjs",
"build": "npm run build:lib && npm run build:demo",
"build:lib": "tsc -p tsconfig.build.json",
"build:demo": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"prepublishOnly": "npm run clean && npm run build"
},
"devDependencies": {
"typescript": "^5.6.3",
Expand Down
7 changes: 7 additions & 0 deletions scripts/clean.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { rm } from "node:fs/promises";

const targets = [new URL("../dist", import.meta.url)];

for (const url of targets) {
await rm(url, { recursive: true, force: true });
}
Loading