← 제니앱

입자 시뮬레이션

시드로 입자를 만들어 중력과 벽 반사로 굴린 결과를 SVG로 그립니다. 같은 입력은 항상 같은 그림을 냅니다.

입력은 시드, 입자 개수, 스텝 수를 공백으로 구분해 적습니다. 네 번째 값으로 중력을 줄 수 있고 1은 0.01입니다.

sim.tpz
// 결정론 입자 시뮬레이션. 시드로 PRNG를 초기화해 입자를 만들고, 고정 스텝 물리로 굴린 뒤 SVG로 그린다.
// Deterministic particle simulation. Seed a PRNG, create particles, run fixed-step physics, draw SVG.
// 같은 입력(시드 개수 스텝 중력)은 항상 바이트 동일한 SVG를 낸다. 이것이 리플레이 결정론이다.
// The same input (seed count steps gravity) always yields a byte-identical SVG. This is replay determinism.
// 식별자 용어집 / glossary:
//   입자=particle, 엑스/와이=x/y, 브이엑스/브이와이=vx/vy, 다음난수=next PRNG int, 단위난수=[0,1) float,
//   정수인가=isInt, 이스케이프=html-escape, 처리=process, 자취=trajectory, 반발=restitution.
// 안 하는 것: 실시간 시계 애니메이션, 시스템 난수, 네트워크, 입자간 충돌, sqrt 힘, canvas 픽셀버퍼(SVG만).
// Won't do: clock-driven animation, system RNG, network, particle collisions, sqrt forces, canvas pixels.

type 입자 = { 엑스: float, 와이: float, 브이엑스: float, 브이와이: float }

let 너비 = 360
let 높이 = 260
let 최대개수 = 100
let 최대스텝 = 1500
let 최대작업 = 15000

// LCG 의사난수. 시드 기반 결정론, 시스템 난수/시계 미사용.
// LCG PRNG: seeded and deterministic, no system randomness or clock.
function 다음난수(상태: int) -> int {
  let 다음 = (1103515245 * 상태 + 12345) % 2147483648
  if 다음 < 0 { 다음 + 2147483648 } else { 다음 }
}

// PRNG 상태를 [0,1) 실수로 바꾼다.
// Map a PRNG state to a [0,1) float.
function 단위난수(상태: int) -> float {
  toFloat(상태) / 2147483648.0
}

// 정수 문자열인가(선행 - 허용).
// Is the string an integer (optional leading minus)?
function 정수인가(글자열: string) -> bool {
  let 글자들 = 글자열.scalars()
  if 글자들.length == 0 { return false }
  let mut 시작 = 0
  if 글자들[0] == "-" { 시작 = 1 }
  if 글자들.length == 시작 { return false }
  let mut 번호 = 시작
  while 번호 < 글자들.length {
    let 코 = 글자들[번호].codePointAt(0) ?? 0
    if 코 < 48 || 코 > 57 { return false }
    번호 = 번호 + 1
  }
  true
}

// HTML 이스케이프. 사용자 입력은 출력 전에 반드시 이스케이프한다.
// HTML escape: always escape user input before output.
function 이스케이프(원문: string) -> string {
  let mut 결과 = ""
  for 글 in 원문.scalars() {
    if 글 == "&" { 결과 = 결과 + "&amp;" }
    else if 글 == "<" { 결과 = 결과 + "&lt;" }
    else if 글 == ">" { 결과 = 결과 + "&gt;" }
    else if 글 == "\"" { 결과 = 결과 + "&quot;" }
    else { 결과 = 결과 + 글 }
  }
  결과
}

// 입력을 받아 시뮬레이션 결과 SVG(또는 오류)를 낸다.
// Take the input and produce the simulation SVG (or an error).
function 처리(입력: string) -> string {
  let 토큰들 = filter(입력.trim().split(" "), 토 => 토 != "")
  if 토큰들.length == 0 {
    return "<div class=\"meta\">시드 개수 스텝을 입력하세요. 중력은 선택입니다. 예: 42 20 250 40</div>"
  }
  if 토큰들.length < 3 {
    return "<div class=\"err\">시드, 개수, 스텝을 모두 입력하세요. 예: 42 20 250 40</div>"
  }
  if 토큰들.length > 4 {
    return "<div class=\"err\">입력은 시드 개수 스텝과 선택 중력까지 네 개입니다. 예: 42 20 250 40</div>"
  }
  let mut 검번호 = 0
  while 검번호 < 토큰들.length {
    let 토 = 토큰들[검번호]
    if !정수인가(토) {
      return "<div class=\"err\">숫자만 입력하세요: {이스케이프(토)}</div>"
    }
    if 토.scalars().length > 12 {
      return "<div class=\"err\">숫자가 너무 큽니다: {이스케이프(토)}</div>"
    }
    검번호 = 검번호 + 1
  }
  let 시드원 = toInt(토큰들[0]) ?? 0
  let 개수 = toInt(토큰들[1]) ?? 0
  let 스텝 = toInt(토큰들[2]) ?? 0
  let mut 중력정수 = 40
  if 토큰들.length >= 4 { 중력정수 = toInt(토큰들[3]) ?? 0 }
  if 개수 < 1 || 개수 > 최대개수 {
    return "<div class=\"err\">입자 개수는 1부터 {최대개수}까지여야 합니다.</div>"
  }
  if 스텝 < 1 || 스텝 > 최대스텝 {
    return "<div class=\"err\">스텝은 1부터 {최대스텝}까지여야 합니다.</div>"
  }
  if 개수 * 스텝 > 최대작업 {
    return "<div class=\"err\">개수와 스텝의 곱이 너무 큽니다. 최대 {최대작업}까지 가능합니다.</div>"
  }

  // 시드를 [0, 2^31)로 접어 LCG 곱이 넘치지 않게 한다.
  // Fold the seed into [0, 2^31) so the LCG multiply cannot overflow.
  let 시드절대 = ((시드원 % 2147483648) + 2147483648) % 2147483648
  let mut 상태 = 시드절대 % 2147483648
  상태 = 다음난수(상태)
  상태 = 다음난수(상태)

  let 중력 = toFloat(중력정수) / 100.0
  let 반발 = 0.82
  let 너비실수 = toFloat(너비)
  let 높이실수 = toFloat(높이)

  // 입자 초기화(시드에서 위치와 속도를 뽑는다).
  // Initialize particles, drawing position and velocity from the seed.
  let mut 입자들: Array<입자> = []
  let mut 자취들: Array<string> = []
  let mut 만든수 = 0
  while 만든수 < 개수 {
    상태 = 다음난수(상태)
    let 시작엑스 = 단위난수(상태) * (너비실수 - 20.0) + 10.0
    상태 = 다음난수(상태)
    let 시작와이 = 단위난수(상태) * (높이실수 * 0.5) + 10.0
    상태 = 다음난수(상태)
    let 시작브이엑스 = (단위난수(상태) - 0.5) * 8.0
    상태 = 다음난수(상태)
    let 시작브이와이 = (단위난수(상태) - 0.5) * 4.0
    입자들.push({ 엑스: 시작엑스, 와이: 시작와이, 브이엑스: 시작브이엑스, 브이와이: 시작브이와이 })
    자취들.push("")
    만든수 = 만든수 + 1
  }

  // 고정 스텝 물리: 중력 + 벽 반사. 일정 간격으로 자취를 표본한다.
  // Fixed-step physics: gravity plus wall reflection. Sample the trajectory at a fixed interval.
  let 표본간격 = if 스텝 / 30 < 1 { 1 } else { 스텝 / 30 }
  let mut 스텝번호 = 0
  while 스텝번호 < 스텝 {
    let mut 입자번호 = 0
    while 입자번호 < 개수 {
      let 현재 = 입자들[입자번호]
      let mut 새브이와이 = 현재.브이와이 + 중력
      let mut 새브이엑스 = 현재.브이엑스
      let mut 새엑스 = 현재.엑스 + 새브이엑스
      let mut 새와이 = 현재.와이 + 새브이와이
      if 새엑스 < 5.0 { 새엑스 = 5.0; 새브이엑스 = -새브이엑스 * 반발 }
      else if 새엑스 > 너비실수 - 5.0 { 새엑스 = 너비실수 - 5.0; 새브이엑스 = -새브이엑스 * 반발 }
      if 새와이 < 5.0 { 새와이 = 5.0; 새브이와이 = -새브이와이 * 반발 }
      else if 새와이 > 높이실수 - 5.0 { 새와이 = 높이실수 - 5.0; 새브이와이 = -새브이와이 * 반발 }
      입자들[입자번호] = 현재{ 엑스: 새엑스, 와이: 새와이, 브이엑스: 새브이엑스, 브이와이: 새브이와이 }
      입자번호 = 입자번호 + 1
    }
    if 스텝번호 % 표본간격 == 0 {
      let mut 표번호 = 0
      while 표번호 < 개수 {
        자취들[표번호] = 자취들[표번호] + " {입자들[표번호].엑스},{입자들[표번호].와이}"
        표번호 = 표번호 + 1
      }
    }
    스텝번호 = 스텝번호 + 1
  }

  // SVG로 그린다: 상자, 입자별 자취(옅게), 마지막 위치(원).
  // Render SVG: the box, each particle's trajectory (faint), and its final position (circle).
  let mut 본문 = "<rect x=\"0\" y=\"0\" width=\"{너비}\" height=\"{높이}\" fill=\"none\" stroke=\"#111\" />"
  let mut 그릴번호 = 0
  while 그릴번호 < 개수 {
    본문 = 본문 + "<polyline points=\"{자취들[그릴번호]}\" fill=\"none\" stroke=\"#bbb\" stroke-width=\"1\" />"
    본문 = 본문 + "<circle cx=\"{입자들[그릴번호].엑스}\" cy=\"{입자들[그릴번호].와이}\" r=\"3\" fill=\"#111\" />"
    그릴번호 = 그릴번호 + 1
  }
  let 머리 = "<div class=\"meta\">시드 {시드원}, 입자 {개수}개, 스텝 {스텝}회. 같은 입력은 같은 그림을 냅니다.</div>"
  머리 + "<svg viewBox=\"0 0 {너비} {높이}\" width=\"100%\" style=\"border:1px solid #e3e3e3;margin-top:12px\">" + 본문 + "</svg>"
}

print(처리(input()))
시드 42, 입자 20개, 스텝 250회. 같은 입력은 같은 그림을 냅니다.