yasshi2525/RushHour

View on GitHub
client/src/common/coord.tsx

Summary

Maintainability
C
1 day
Test Coverage
import React, {
  FC,
  createContext,
  useEffect,
  useMemo,
  useState,
  useContext,
  useCallback
} from "react";
import { useCounter } from "./utils/tick";
import WindowContext from "./windows";
import ConfigContext from "./config";
import PixiContext from "./pixi";

/**
 * 座標に変化があった際、位置合わせをするまでの遅延フレーム数
 */
const LATENCY = 30;

interface ICoordinates {
  /**
   * 左上x座標(サーバにおけるマップ座標系)
   */
  x: number;
  /**
   * 左上y座標(サーバにおけるマップ座標系)
   */
  y: number;
  /**
   * 拡大率(クライアントウィンドウの幅がサーバマップ座標系の2^sに対応する)
   */
  s: number;
}
type Dimension = [number, number];
type Coordinates = [number, number, number];

/**
 * 座標系の中心を求める
 */
const defaultCoordinates = (min: number, max: number): Coordinates => {
  const scale = Math.floor((max + min) / 2);
  const pos = 1 << (scale - 1);
  return [pos, pos, scale];
};

/**
 * 画面横幅、縦幅が `scale` 表記でどの値になるか求める。
 * 長辺の幅が `2 ^ scale`
 * ```
 * 求める値 (x, y) は 画面幅 (w, h) に対し、
 *        w  = 2 ^ x
 *        h  = 2 ^ y
 * max(w, h) = 2 ^ scale
 *
 * <=>     x = log 2 w
 *         y = log 2 h
 *     scale = log 2 max(w, h)
 *
 * h = a * w (0 < a) とすると
 * y = log 2 h
 *   = log 2 (a * w)
 *   = log 2 a + log 2 w
 *   = log 2 a + x
 *   = log 2 (h / w) + x
 *
 * w > h ... x =                  scale, y = log 2 (h / w) + scale
 * w = h ... x =                  scale, y =                 scale
 * w < h ... x = -log 2 (h / w) + scale, y =                 scale
 * ```
 */
const scalize = (scale: number, width: number, height: number): Dimension =>
  width > height
    ? [scale, scale + Math.log2(height / width)]
    : [scale + Math.log2(width / height), scale];

/**
 * ```
 * left   = 0
 * top    = 0
 * right  = 2 ^ maxScale
 * bottom = 2 ^ maxScale
 *
 * サーバの座標幅 = srv(画面幅) とすると、
 * srv(w) = 2 ^ xScale
 * srv(h) = 2 ^ yScale
 *
 * 左上点 (x, y) の取りうる値は、
 * left < x
 * top  < y
 *        x + srv(w) < right
 *        y + srv(h) < bottom
 *
 * 0 < x
 * 0 < y
 *     x + 2 ^ xScale < 2 ^ maxScale
 *     y + 2 ^ yScale < 2 ^ maxScale
 *
 * 0 < x < 2 ^ maxScale - 2 ^ xScale
 * 0 < y < 2 ^ maxScale - 2 ^ yScale
 * ```
 */
const validate = (
  x: number,
  y: number,
  s: number,
  xScale: number,
  yScale: number,
  minScale: number,
  maxScale: number
): Coordinates => {
  const mapSize = 1 << maxScale;
  console.info(`mapSize=${mapSize} xS=${xScale} yS=${yScale}`);
  {
    const xLen = Math.pow(2, xScale);
    const left = 0;
    const right = mapSize - xLen;
    x = x < left ? left : x > right ? right : x;
    console.info(`xLen=${xLen}`);
  }
  {
    const yLen = Math.pow(2, yScale);
    const top = 0;
    const buttom = mapSize - yLen;
    y = y < top ? top : y > buttom ? buttom : y;
    console.info(`yLen=${yLen}`);
  }
  {
    const min = minScale;
    const max = maxScale;
    s = s < min ? min : s > max ? max : s;
  }

  return [x, y, s];
};

const DELTA = 0.01;
const round = (current: number, want: number) =>
  Math.abs(want - current) > DELTA ? current : want;
const smooth = (dest: number) => (delta: number) => (prev: number) =>
  round(prev * delta + dest * (1 - delta), dest);

const session = localStorage.getItem("loc");
const locSession: ICoordinates | undefined =
  session !== null ? JSON.parse(session) : undefined;

const initCoordinates = (
  width: number,
  height: number,
  minScale: number,
  maxScale: number
): Coordinates => {
  if (locSession === undefined) {
    return defaultCoordinates(minScale, maxScale);
  } else {
    const [xScale, yScale] = scalize(locSession.s, width, height);
    return validate(
      locSession.x,
      locSession.y,
      locSession.s,
      xScale,
      yScale,
      minScale,
      maxScale
    );
  }
};
type CoordState = [
  number,
  number,
  number,
  number,
  number,
  (x: number, y: number, s: number) => void
];
const useCoord = (): CoordState => {
  const [{ min_scale, max_scale }] = useContext(ConfigContext);
  const [w, h] = useContext(WindowContext);
  const pixi = useContext(PixiContext);

  const [initX, initY, initS] = useMemo(
    () => initCoordinates(w, h, min_scale, max_scale),
    [w, h, min_scale, max_scale]
  );

  const [x, setX] = useState(initX);
  const [y, setY] = useState(initY);
  const [s, setS] = useState(initS);

  useEffect(() => {
    console.info(`coord=(${x},${y},${s})`);
  }, [x, y, s]);

  const [xScale, yScale] = useMemo(() => scalize(s, w, h), [s, w, h]);

  const [dstX, setDstX] = useState(initX);
  const [dstY, setDstY] = useState(initY);
  const [dstS, setDstS] = useState(initS);

  const smoothX = useCallback((delta: number) => setX(smooth(dstX)(delta)), [
    dstX
  ]);
  const smoothY = useCallback((delta: number) => setY(smooth(dstY)(delta)), [
    dstY
  ]);
  const smoothS = useCallback((delta: number) => setS(smooth(dstS)(delta)), [
    dstS
  ]);

  const [latency, setLatency] = useState(LATENCY);

  const updateCoord = useCallback(
    (newX: number, newY: number, newS: number, force: boolean = false) => {
      console.info(`udpateCoord=${force ? 0 : LATENCY}`);
      const [vx, vy, vs] = validate(
        newX,
        newY,
        newS,
        xScale,
        yScale,
        min_scale,
        max_scale
      );
      console.info(`validate ${vx}, ${vy}, ${vs}`);
      setDstX(vx);
      setDstY(vy);
      setDstS(vs);
      setLatency(force ? 1 : LATENCY);
    },
    [xScale, yScale, min_scale, max_scale]
  );

  const smoother = useCallback(
    (offset: number) => {
      console.info(`smoother ${offset.toFixed(2)}`);
      const delta = Math.min(offset, 1 - offset);
      smoothX(delta);
      smoothY(delta);
      smoothS(delta);
    },
    [smoothX, smoothY, smoothS]
  );

  useCounter(pixi, smoother, latency);

  return [x, y, s, xScale, yScale, updateCoord];
};

/**
 * ```
 * [x, y, s, xScale, yScale, update] = useContext(CoordContext)
 * ```
 */
const CoordContext = createContext<CoordState>([
  0,
  0,
  0,
  0,
  0,
  () => {
    console.warn("not initialized CoordContext");
  }
]);
CoordContext.displayName = "CoordContext";

export const CoordProvider: FC = props => {
  const context = useCoord();
  useEffect(() => {
    console.info("after CoordProvider");
  }, []);
  return useMemo(
    () => (
      <CoordContext.Provider value={context}>
        {props.children}
      </CoordContext.Provider>
    ),
    [context]
  );
};

export default CoordContext;